diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b420d9c31..c02bf3406 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,44 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - +# Automated dependency maintenance for every shipped ecosystem and workflow surface. version: 2 updates: - # Enable version updates for npm - - package-ecosystem: "npm" - # Look for `package.json` and `lock` files in the `root` directory - directory: "/" - # Check the npm registry for updates every day (weekdays) + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + time: '06:00' + timezone: 'America/New_York' + open-pull-requests-limit: 10 + labels: + - 'dependencies' + - 'npm' + commit-message: + prefix: 'deps' + + - package-ecosystem: 'cargo' + directory: '/crates/pairofcleats-tui' + schedule: + interval: 'weekly' + day: 'monday' + time: '06:00' + timezone: 'America/New_York' + open-pull-requests-limit: 10 + labels: + - 'dependencies' + - 'cargo' + commit-message: + prefix: 'deps' + + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "daily" + interval: 'weekly' + day: 'monday' + time: '06:00' + timezone: 'America/New_York' + open-pull-requests-limit: 5 + labels: + - 'dependencies' + - 'github-actions' + commit-message: + prefix: 'ci' diff --git a/.github/workflows/ci-long.yml b/.github/workflows/ci-long.yml index f36e4e234..9c441c073 100644 --- a/.github/workflows/ci-long.yml +++ b/.github/workflows/ci-long.yml @@ -4,6 +4,9 @@ permissions: contents: read on: + push: + tags: + - 'v*' workflow_dispatch: concurrency: @@ -53,9 +56,9 @@ jobs: npm run bootstrap:ci else echo "node_modules cache hit; verifying native modules." - if ! npm run verify:native; then + if ! node tools/setup/rebuild-native.js --verify; then echo "native verification failed; running targeted repair:native." - npm run repair:native + node tools/setup/rebuild-native.js --repair fi fi node -v @@ -82,4 +85,5 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07920f37b..57675da8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ permissions: on: push: branches: [main] + tags: + - 'v*' + workflow_dispatch: pull_request: concurrency: @@ -52,6 +55,12 @@ jobs: - name: Env usage guardrail run: npm run env:check + - name: Generated surfaces freshness + run: node tools/docs/generated-surfaces.js --check-freshness + + - name: Command surface audit + run: node tools/ci/check-command-surface.js + - name: Gate lane run: | node tests/run.js --lane gate --allow-timeouts --junit .testLogs/junit-gate.xml --log-dir .testLogs @@ -65,8 +74,50 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 + rust-tui: + name: rust-tui + runs-on: ubuntu-latest + needs: gate + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.86.0 + components: rustfmt, clippy + + - name: Restore Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + crates/pairofcleats-tui/target + key: cargo-${{ runner.os }}-${{ hashFiles('crates/pairofcleats-tui/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Cargo fmt + working-directory: crates/pairofcleats-tui + run: cargo fmt --check + + - name: Cargo check + working-directory: crates/pairofcleats-tui + run: cargo check --locked + + - name: Cargo test + working-directory: crates/pairofcleats-tui + run: cargo test --locked + + - name: Cargo clippy + working-directory: crates/pairofcleats-tui + run: cargo clippy --locked -- -D warnings + ubuntu: name: ubuntu runs-on: ubuntu-latest @@ -110,14 +161,25 @@ jobs: npm run bootstrap:ci else echo "node_modules cache hit; verifying native modules." - if ! npm run verify:native; then + if ! node tools/setup/rebuild-native.js --verify; then echo "native verification failed; running targeted repair:native." - npm run repair:native + node tools/setup/rebuild-native.js --repair fi fi node -v npm -v + - name: Install optional LSP tools (best effort) + shell: bash + run: | + set +e + node tools/tooling/install.js --scope cache --tools pyright,yaml-language-server,gopls,csharp-ls,solargraph,phpactor --json + status=$? + if [ $status -ne 0 ]; then + echo "optional tooling install had non-fatal failures (continuing)." + fi + exit 0 + - name: CI Suite run: | node tools/ci/run-suite.js --mode ci --skip-prechecks @@ -130,6 +192,21 @@ jobs: $env:PAIROFCLEATS_CACHE_ROOT = $cacheRoot node tools/index/cache-gc.js --apply --grace-days 3 --max-deletes 200 --repo . + - name: Upload quality artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-quality-artifacts-ubuntu + path: | + .diagnostics/test-summary.json + .diagnostics/test-timings.json + .diagnostics/test-profile.json + .diagnostics/coverage/** + .testLogs/junit.xml + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4 @@ -139,6 +216,7 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 windows: @@ -182,20 +260,44 @@ jobs: npm run bootstrap:ci } else { Write-Host 'node_modules cache hit; verifying native modules.' - npm run verify:native + node tools/setup/rebuild-native.js --verify if ($LASTEXITCODE -ne 0) { Write-Host 'native verification failed; running targeted repair:native.' - npm run repair:native + node tools/setup/rebuild-native.js --repair if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } } node -v npm -v + - name: Install optional LSP tools (best effort) + shell: pwsh + run: | + node tools/tooling/install.js --scope cache --tools pyright,yaml-language-server,gopls,csharp-ls,solargraph,phpactor --json + if ($LASTEXITCODE -ne 0) { + Write-Host 'optional tooling install had non-fatal failures (continuing).' + } + exit 0 + - name: CI Suite run: | node tools/ci/run-suite.js --mode ci --skip-prechecks + - name: Upload quality artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-quality-artifacts-windows + path: | + .diagnostics/test-summary.json + .diagnostics/test-timings.json + .diagnostics/test-profile.json + .diagnostics/coverage/** + .testLogs/junit.xml + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4 @@ -205,6 +307,7 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 macos: @@ -248,18 +351,44 @@ jobs: npm run bootstrap:ci else echo "node_modules cache hit; verifying native modules." - if ! npm run verify:native; then + if ! node tools/setup/rebuild-native.js --verify; then echo "native verification failed; running targeted repair:native." - npm run repair:native + node tools/setup/rebuild-native.js --repair fi fi node -v npm -v + - name: Install optional LSP tools (best effort) + shell: bash + run: | + set +e + node tools/tooling/install.js --scope cache --tools pyright,yaml-language-server,gopls,csharp-ls,solargraph,phpactor --json + status=$? + if [ $status -ne 0 ]; then + echo "optional tooling install had non-fatal failures (continuing)." + fi + exit 0 + - name: CI Suite run: | node tools/ci/run-suite.js --mode ci --skip-prechecks + - name: Upload quality artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-quality-artifacts-macos + path: | + .diagnostics/test-summary.json + .diagnostics/test-timings.json + .diagnostics/test-profile.json + .diagnostics/coverage/** + .testLogs/junit.xml + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4 @@ -269,4 +398,5 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f73616a5a..945e15215 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,8 +14,16 @@ on: jobs: analyze: - name: CodeQL (javascript) + name: CodeQL (${{ matrix.language }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: javascript + build-mode: none + - language: rust + build-mode: autobuild steps: - name: Rate limit (nightly) if: github.event_name == 'schedule' @@ -71,8 +79,21 @@ jobs: if: (github.event_name != 'push' || steps.rate.outputs.skip != 'true') && (github.event_name != 'schedule' || steps.nightly.outputs.skip != 'true') uses: github/codeql-action/init@v4 with: - languages: javascript + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Setup Rust toolchain + if: matrix.language == 'rust' && (github.event_name != 'push' || steps.rate.outputs.skip != 'true') && (github.event_name != 'schedule' || steps.nightly.outputs.skip != 'true') + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.86.0 + + - name: Autobuild Rust + if: matrix.language == 'rust' && (github.event_name != 'push' || steps.rate.outputs.skip != 'true') && (github.event_name != 'schedule' || steps.nightly.outputs.skip != 'true') + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis if: (github.event_name != 'push' || steps.rate.outputs.skip != 'true') && (github.event_name != 'schedule' || steps.nightly.outputs.skip != 'true') uses: github/codeql-action/analyze@v4 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 673440c82..ad63f6fe2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -58,9 +58,9 @@ jobs: npm run bootstrap:ci else echo "node_modules cache hit; verifying native modules." - if ! npm run verify:native; then + if ! node tools/setup/rebuild-native.js --verify; then echo "native verification failed; running targeted repair:native." - npm run repair:native + node tools/setup/rebuild-native.js --repair fi fi node -v @@ -77,6 +77,21 @@ jobs: $env:PAIROFCLEATS_CACHE_ROOT = $cacheRoot node tools/index/cache-gc.js --apply --grace-days 3 --max-deletes 200 --repo . + - name: Upload quality artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: nightly-quality-artifacts-${{ matrix.os }} + path: | + .diagnostics/test-summary.json + .diagnostics/test-timings.json + .diagnostics/test-profile.json + .diagnostics/coverage/** + .testLogs/junit.xml + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4 @@ -86,4 +101,45 @@ jobs: .testLogs/** .diagnostics/** if-no-files-found: ignore + include-hidden-files: true compression-level: 0 + + rust-tui: + name: rust-tui-nightly + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.86.0 + components: rustfmt, clippy + + - name: Restore Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + crates/pairofcleats-tui/target + key: cargo-${{ runner.os }}-${{ hashFiles('crates/pairofcleats-tui/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Cargo fmt + working-directory: crates/pairofcleats-tui + run: cargo fmt --check + + - name: Cargo check + working-directory: crates/pairofcleats-tui + run: cargo check --locked + + - name: Cargo test + working-directory: crates/pairofcleats-tui + run: cargo test --locked + + - name: Cargo clippy + working-directory: crates/pairofcleats-tui + run: cargo clippy --locked -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..742889a38 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,746 @@ +name: Release + +permissions: + contents: read + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: Existing release tag to promote (for manual runs) + required: true + type: string + publish: + description: Publish or update the GitHub release after verification + required: false + default: false + type: boolean + +concurrency: + group: release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + prepare: + name: prepare + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release-metadata.outputs.release_tag }} + release_version: ${{ steps.release-metadata.outputs.release_version }} + release_git_sha: ${{ steps.release-revision.outputs.release_git_sha }} + release_ref: ${{ steps.release-revision.outputs.release_ref }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Resolve checked out release revision + id: release-revision + shell: bash + env: + RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + echo "release_git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "release_ref=$RELEASE_REF" >> "$GITHUB_OUTPUT" + + - name: Resolve release metadata + id: release-metadata + shell: bash + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + RELEASE_GIT_SHA: ${{ steps.release-revision.outputs.release_git_sha }} + run: | + mkdir -p dist/release/prepare + node tools/release/metadata.js --tag "$RELEASE_TAG" --git-sha "$RELEASE_GIT_SHA" --out dist/release/prepare/metadata.json --notes-out dist/release/prepare/notes.md + echo "release_tag=$(node -e "const fs=require('fs');const payload=JSON.parse(fs.readFileSync('dist/release/prepare/metadata.json','utf8'));process.stdout.write(payload.releaseTag||'');")" >> "$GITHUB_OUTPUT" + echo "release_version=$(node -e "const fs=require('fs');const payload=JSON.parse(fs.readFileSync('dist/release/prepare/metadata.json','utf8'));process.stdout.write(payload.releaseVersion||'');")" >> "$GITHUB_OUTPUT" + + - name: Release prepare gates + run: > + node tools/release/check.js + --phases changelog,contracts,toolchain + --report dist/release/prepare/release_check_report.json + --manifest dist/release/prepare/release-manifest.json + + - name: Upload prepare artifacts + uses: actions/upload-artifact@v4 + with: + name: release-prepare + path: | + dist/release/prepare/** + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + - name: Upload prepare doc drift artifacts + uses: actions/upload-artifact@v4 + with: + name: release-prepare-docs + path: | + docs/tooling/doc-contract-drift.json + docs/tooling/doc-contract-drift.md + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + build-node-packages: + name: build-node-packages + runs-on: ubuntu-latest + needs: prepare + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Build packaged node surfaces + run: > + node tools/release/check.js + --surfaces vscode,sublime + --phases build + --report dist/release/build-node/release_check_report.json + --manifest dist/release/build-node/release-manifest.json + + - name: Upload packaged node artifacts + uses: actions/upload-artifact@v4 + with: + name: release-node-packages + path: | + dist/vscode/** + dist/sublime/** + dist/release/build-node/** + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + verify-runtime-surfaces: + name: verify-runtime-surfaces + runs-on: ubuntu-latest + needs: prepare + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Verify runtime surfaces + run: > + node tools/release/check.js + --surfaces cli,api,mcp,indexer-service + --phases boot,smoke + --report dist/release/verify-runtime/release_check_report.json + --manifest dist/release/verify-runtime/release-manifest.json + + - name: Upload runtime verification artifacts + uses: actions/upload-artifact@v4 + with: + name: release-runtime-verify + path: | + dist/release-verification/** + dist/release/verify-runtime/** + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + + verify-node-packages: + name: verify-node-packages + runs-on: ubuntu-latest + needs: + - prepare + - build-node-packages + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Download packaged node artifacts + uses: actions/download-artifact@v4 + with: + name: release-node-packages + path: dist + + - name: Verify packaged node installs + run: > + node tools/release/check.js + --surfaces vscode,sublime + --phases install + --report dist/release/verify-node/release_check_report.json + --manifest dist/release/verify-node/release-manifest.json + + - name: Upload node package verification artifacts + uses: actions/upload-artifact@v4 + with: + name: release-node-package-verify + path: | + dist/release-verification/** + dist/release/verify-node/** + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + + build-tui: + name: build-tui-${{ matrix.release_id }} + runs-on: ${{ matrix.os }} + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + release_id: ubuntu + - os: windows-latest + release_id: windows + - os: macos-latest + release_id: macos + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.86.0 + + - name: Install deps + run: npm run bootstrap:ci + + - name: Build TUI release artifacts + run: > + node tools/release/check.js + --surfaces tui + --phases build + --report dist/release/build-tui-${{ matrix.release_id }}/release_check_report.json + --manifest dist/release/build-tui-${{ matrix.release_id }}/release-manifest.json + + - name: Upload TUI build artifacts + uses: actions/upload-artifact@v4 + with: + name: release-tui-${{ matrix.release_id }} + path: | + dist/tui/** + dist/release/build-tui-${{ matrix.release_id }}/** + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + verify-tui: + name: verify-tui-${{ matrix.release_id }} + runs-on: ${{ matrix.os }} + needs: + - prepare + - build-tui + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + release_id: ubuntu + - os: windows-latest + release_id: windows + - os: macos-latest + release_id: macos + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Download TUI build artifacts + uses: actions/download-artifact@v4 + with: + name: release-tui-${{ matrix.release_id }} + path: dist + + - name: Verify TUI install and wrapper boot + run: > + node tools/release/check.js + --surfaces tui + --phases install,boot + --runtime-target ${{ matrix.release_id }} + --report dist/release/verify-tui-${{ matrix.release_id }}/release_check_report.json + --manifest dist/release/verify-tui-${{ matrix.release_id }}/release-manifest.json + + - name: Upload TUI verification artifacts + uses: actions/upload-artifact@v4 + with: + name: release-tui-verify-${{ matrix.release_id }} + path: | + dist/release-verification/** + dist/release/verify-tui-${{ matrix.release_id }}/** + if-no-files-found: ignore + include-hidden-files: true + compression-level: 0 + + release-bundle: + name: release-bundle + runs-on: ubuntu-latest + needs: + - prepare + - build-node-packages + - verify-runtime-surfaces + - verify-node-packages + - verify-tui + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Download prepare artifacts + uses: actions/download-artifact@v4 + with: + name: release-prepare + path: dist/release/downloads/release-prepare + + - name: Download prepare doc drift artifacts + uses: actions/download-artifact@v4 + with: + name: release-prepare-docs + path: dist/release/downloads/release-prepare-docs + + - name: Download packaged node artifacts + uses: actions/download-artifact@v4 + with: + name: release-node-packages + path: dist/release/downloads/release-node-packages + + - name: Download runtime verification artifacts + uses: actions/download-artifact@v4 + with: + name: release-runtime-verify + path: dist/release/downloads/release-runtime-verify + + - name: Download node package verification artifacts + uses: actions/download-artifact@v4 + with: + name: release-node-package-verify + path: dist/release/downloads/release-node-package-verify + + - name: Download TUI build artifacts for ubuntu + uses: actions/download-artifact@v4 + with: + name: release-tui-ubuntu + path: dist/release/downloads/release-tui-ubuntu + + - name: Download TUI build artifacts for windows + uses: actions/download-artifact@v4 + with: + name: release-tui-windows + path: dist/release/downloads/release-tui-windows + + - name: Download TUI build artifacts for macos + uses: actions/download-artifact@v4 + with: + name: release-tui-macos + path: dist/release/downloads/release-tui-macos + + - name: Download TUI verification artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-tui-verify-* + path: dist/release/downloads + merge-multiple: false + + - name: Assemble release bundle + run: > + node tools/release/assemble-bundle.js + --artifact-root dist/release/downloads + --metadata dist/release/downloads/release-prepare/metadata.json + --out dist/release/bundle + + - name: Upload release bundle + uses: actions/upload-artifact@v4 + with: + name: release-bundle + path: | + dist/release/bundle/** + dist/release/downloads/** + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + attest: + name: attest + runs-on: ubuntu-latest + needs: + - release-bundle + - trust-materials + permissions: + contents: read + attestations: write + id-token: write + steps: + - name: Download release bundle + uses: actions/download-artifact@v4 + with: + name: release-bundle + path: dist/release + + - name: Download trust materials + uses: actions/download-artifact@v4 + with: + name: release-trust-materials + path: dist/release/trust + + - name: Attest release bundle provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + dist/release/bundle/** + dist/release/downloads/** + dist/release/trust/** + + trust-materials: + name: trust-materials + runs-on: ubuntu-latest + needs: release-bundle + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.86.0 + + - name: Install deps + run: npm run bootstrap:ci + + - name: Install cargo-cyclonedx + run: cargo install cargo-cyclonedx --locked + + - name: Download release bundle + uses: actions/download-artifact@v4 + with: + name: release-bundle + path: dist/release + + - name: Generate trust materials + run: > + node tools/release/generate-trust-materials.js + --bundle-dir dist/release/bundle + --metadata dist/release/downloads/release-prepare/metadata.json + --out-dir dist/release/trust + + - name: Upload trust materials + uses: actions/upload-artifact@v4 + with: + name: release-trust-materials + path: | + dist/release/trust + if-no-files-found: error + include-hidden-files: true + compression-level: 0 + + publish: + name: publish + runs-on: ubuntu-latest + needs: + - attest + - prepare + - readiness-gate + if: github.event_name == 'push' || inputs.publish == true + environment: release + permissions: + contents: write + steps: + - name: Download release bundle + uses: actions/download-artifact@v4 + with: + name: release-bundle + path: dist/release + + - name: Download trust materials + uses: actions/download-artifact@v4 + with: + name: release-trust-materials + path: dist/release/trust + + - name: Publish verified artifacts + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + RELEASE_GIT_SHA: ${{ needs.prepare.outputs.release_git_sha }} + run: | + if [ -z "$RELEASE_TAG" ]; then + echo "release tag is required for publish" >&2 + exit 1 + fi + if [ -z "$RELEASE_GIT_SHA" ]; then + echo "release git SHA is required for publish" >&2 + exit 1 + fi + tag_ref_json=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG") + tag_object_sha=$(node -e "const payload=JSON.parse(process.argv[1]);process.stdout.write(payload.object?.sha || '');" "$tag_ref_json") + tag_object_type=$(node -e "const payload=JSON.parse(process.argv[1]);process.stdout.write(payload.object?.type || '');" "$tag_ref_json") + if [ "$tag_object_type" = "tag" ]; then + tag_json=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha") + current_tag_sha=$(node -e "const payload=JSON.parse(process.argv[1]);process.stdout.write(payload.object?.sha || '');" "$tag_json") + elif [ "$tag_object_type" = "commit" ]; then + current_tag_sha="$tag_object_sha" + else + echo "release tag $RELEASE_TAG resolved to unsupported object type: ${tag_object_type:-missing}" >&2 + exit 1 + fi + if [ "${current_tag_sha,,}" != "${RELEASE_GIT_SHA,,}" ]; then + echo "release tag $RELEASE_TAG points at $current_tag_sha, not verified SHA $RELEASE_GIT_SHA" >&2 + exit 1 + fi + NOTES_PATH='dist/release/downloads/release-prepare/notes.md' + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --verify-tag --title "PairOfCleats $RELEASE_VERSION" --notes-file "$NOTES_PATH" + fi + mapfile -t release_files < <(find dist/release/downloads dist/release/bundle dist/release/trust -type f | sort) + upload_args=() + for file_path in "${release_files[@]}"; do + rel_path="${file_path#dist/release/}" + upload_args+=("${file_path}#${rel_path}") + done + gh release upload "$RELEASE_TAG" "${upload_args[@]}" --clobber + + readiness-gate: + name: readiness-gate + runs-on: ubuntu-latest + needs: + - prepare + - verify-runtime-surfaces + - verify-node-packages + - verify-tui + - trust-materials + - attest + permissions: + contents: read + actions: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24.13.0' + cache: npm + + - name: Install deps + run: npm run bootstrap:ci + + - name: Download prepare artifacts + uses: actions/download-artifact@v4 + with: + name: release-prepare + path: dist/release/downloads/release-prepare + + - name: Download prepare doc drift artifacts + uses: actions/download-artifact@v4 + with: + name: release-prepare-docs + path: dist/release/downloads/release-prepare-docs + + - name: Download runtime verification artifacts + uses: actions/download-artifact@v4 + with: + name: release-runtime-verify + path: dist/release/downloads/release-runtime-verify + + - name: Download node package verification artifacts + uses: actions/download-artifact@v4 + with: + name: release-node-package-verify + path: dist/release/downloads/release-node-package-verify + + - name: Download TUI verification artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-tui-verify-* + path: dist/release/downloads + merge-multiple: false + + - name: Download trust materials + uses: actions/download-artifact@v4 + with: + name: release-trust-materials + path: dist/release/trust + + - name: Resolve CI workflow status and quality artifacts + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_GIT_SHA: ${{ needs.prepare.outputs.release_git_sha }} + RELEASE_REF: ${{ needs.prepare.outputs.release_ref }} + RELEASE_EVENT_NAME: ${{ github.event_name }} + run: | + mkdir -p dist/release/readiness dist/release/ci-quality + target_sha="${RELEASE_GIT_SHA:-$GITHUB_SHA}" + target_ref="${RELEASE_REF:-$GITHUB_REF_NAME}" + poll_interval_seconds=30 + max_attempts=120 + + find_run_state() { + local workflow="$1" + gh run list --workflow "$workflow" --commit "$target_sha" --json databaseId,status,conclusion,event,headSha,createdAt,updatedAt --limit 20 + } + + maybe_dispatch_workflow() { + local workflow="$1" + local runs_json="$2" + if [ "$RELEASE_EVENT_NAME" != "workflow_dispatch" ]; then + return 0 + fi + local existing_count + existing_count=$(node -e "const payload=JSON.parse(process.argv[1]);process.stdout.write(String(Array.isArray(payload)?payload.length:0));" "$runs_json") + if [ "$existing_count" != "0" ]; then + return 0 + fi + echo "dispatching $workflow for $target_ref ($target_sha)" >&2 + gh workflow run "$workflow" --ref "$target_ref" >&2 + } + + wait_for_successful_run() { + local workflow="$1" + local allow_dispatch="$2" + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + local runs_json + runs_json=$(find_run_state "$workflow") + if [ "$allow_dispatch" = "dispatch" ]; then + maybe_dispatch_workflow "$workflow" "$runs_json" + runs_json=$(find_run_state "$workflow") + fi + local result + result=$(node --input-type=module -e "import { selectLatestWorkflowRunGateState } from './tools/release/workflow-run-selection.js'; const payload=JSON.parse(process.argv[1]); process.stdout.write(selectLatestWorkflowRunGateState(payload).text);" "$runs_json") + case "$result" in + success:*) + echo "${result#success:}" + return 0 + ;; + failed:*) + echo "workflow $workflow did not succeed for $target_sha ($result)" >&2 + return 1 + ;; + pending:*) + echo "waiting for $workflow on $target_sha ($result)" >&2 + ;; + *) + echo "waiting for $workflow to appear for $target_sha" >&2 + ;; + esac + attempt=$((attempt + 1)) + sleep "$poll_interval_seconds" + done + echo "timed out waiting for $workflow on $target_sha" >&2 + return 1 + } + + ci_run_id=$(wait_for_successful_run 'ci.yml' 'dispatch') + ci_long_run_id=$(wait_for_successful_run 'ci-long.yml' 'dispatch') + case "$ci_run_id" in ''|*[!0-9]*) echo "invalid CI run id: $ci_run_id" >&2; exit 1 ;; esac + case "$ci_long_run_id" in ''|*[!0-9]*) echo "invalid CI Long run id: $ci_long_run_id" >&2; exit 1 ;; esac + gh run download "$ci_run_id" -n ci-quality-artifacts-ubuntu -D dist/release/ci-quality + node -e "const fs=require('fs');const payload={schemaVersion:1,generatedAt:new Date().toISOString(),targetSha:process.argv[3],workflows:[{workflow:'CI',runId:Number(process.argv[1]),conclusion:'success'},{workflow:'CI Long',runId:Number(process.argv[2]),conclusion:'success'}]};fs.writeFileSync('dist/release/readiness/ci-statuses.json', JSON.stringify(payload, null, 2)+'\n');" "$ci_run_id" "$ci_long_run_id" "$target_sha" + + - name: Generate integrated readiness summary + run: > + node tools/release/readiness-gate.js + --prepare-report dist/release/downloads/release-prepare/release_check_report.json + --runtime-report dist/release/downloads/release-runtime-verify/release_check_report.json + --node-verify-report dist/release/downloads/release-node-package-verify/release_check_report.json + --tui-verify-root dist/release/downloads + --trust-root dist/release/trust + --ci-statuses dist/release/readiness/ci-statuses.json + --ci-test-summary dist/release/ci-quality/.diagnostics/test-summary.json + --release-git-sha ${{ needs.prepare.outputs.release_git_sha }} + --coverage-dir dist/release/ci-quality/.diagnostics/coverage + --attested + --out-json dist/release/readiness/readiness-summary.json + --out-md dist/release/readiness/readiness-summary.md + + - name: Upload readiness artifacts + uses: actions/upload-artifact@v4 + with: + name: release-readiness + path: | + dist/release/readiness/** + dist/release/ci-quality/** + if-no-files-found: error + include-hidden-files: true + compression-level: 0 diff --git a/.gitignore b/.gitignore index c4833de95..328e2252a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ index-code/ index-prose/ ci-artifacts/ /artifacts/ +temp/ +dist/ +.c8/ +release-manifest.json +release_check_report.json tests/.cache/ tests/.logs/ benchmarks/repos/ diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 000000000..1d857120b --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,49 @@ +{ + "minLines": 8, + "minTokens": 80, + "maxLines": 2000, + "maxSize": "500kb", + "mode": "weak", + "reporters": [ + "console", + "json", + "markdown" + ], + "output": "temp/jscpd", + "gitignore": true, + "noSymlinks": true, + "ignore": [ + "**/node_modules/**", + "**/.git/**", + "**/.tmp/**", + "**/.testCache/**", + "**/.testLogs/**", + "**/.benchCache/**", + "**/temp/**", + "**/artifacts/**", + "**/target/**", + "**/coverage/**", + "**/package-lock.json", + "**/*.json", + "**/*.jsonc", + "**/*.min.js", + "**/*.map", + "**/*.patch", + "extensions/vscode/windows-cmd-core.cjs", + "**/extensions/vscode/windows-cmd-core.cjs", + "**\\extensions\\vscode\\windows-cmd-core.cjs", + "assets/**", + "benchmarks/repos/**", + "benchmarks/cache/**", + "benchmarks/results/**", + "docs/archived/**", + "docs/worklogs/**", + "docs/config/*.json", + "docs/testing/*.json", + "docs/tooling/**/*.json", + "**/tests/fixtures/**", + "**/tests/**/fixtures/**", + "tests/**/fixtures/**", + "tests/fixtures/**" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 99176d527..359616c1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## You are working in PowerShell 7.5 -- Avoid bash-style heredocs (`<` runs the CLI from source. -- `npm run lint` checks style; `npm run format` auto-fixes via ESLint. - Important Note: The script should be setting `PAIROFCLEATS_TESTING=1`, or other `PAIROFCLEATS_TEST_*` env vars will be ignored. - There is a helper you can import to handle this boilerplate. ## Script Policy References +- `docs/roadmap.md` (canonical execution/status roadmap) - `docs/tooling/script-inventory.json` (generated inventory) - `docs/guides/commands.md` (generated commands list) ## Spec deprecation + archival process +- `docs/roadmap.md` is the single source for roadmap status, execution order, and "what remains" summaries. +- Do not add or revive root roadmap files such as `AINTKNOWMAP.md`, `LEXI.md`, or `TES_LAYN_ROADMAP.md`; link to `docs/roadmap.md` instead. - Move deprecated or superseded spec docs to `docs/archived/` (do not delete them). - Preserve the original filename whenever possible. - Add a DEPRECATED header block at the top of the archived file with: @@ -64,22 +59,3 @@ ## Commit & Pull Request Guidelines - Commit history does not enforce a strict format; Use descriptive yet concise titles and comprehensive lists in summary. -## Roadmap & Phase Tracking -- Roadmaps (e.g., `GIGAROADMAP.md`) contain current work plans. -- `AINTKNOWMAP.md` is the authoritative active roadmap sequence for current hard-cutover execution. -- When working on a phase, mark it as in progress, update as you go. -- When writing status log entries or marking when something was last changed, use ISO 8601 timestamps instead of just the date -- Checkboxes should be completed only at the same time you commit the work that completes them. -- Test checkboxes cannot be checked until the test has run and passed. -- If fixing a failing test, log each attempted fix as sub-details under that test’s checkbox. -- After 3 failed fix attempts, stop and log the failure, what was tried, and the next best fix -- Move on to the next test until no tests remain and you are out of attempts -- If a tiny post-commit update is needed (e.g., updating roadmap checkboxes), amend instead of commiting unless you are explicitly told not to. -- When all tasks in a phase are complete and concerns addressed: - - Remove that phase and append it to `COMPLETED_PHASES.md` blindly - - Do not look inside `COMPLETED_PHASES.md` or worry about ordering, it is a dump file. -- Hard cutover policy is mandatory for roadmap execution: - - Keep one active behavior per surface; do not keep compatibility shims or dual-write/dual-read paths after a phase cutover. - - Remove superseded flags/paths/contracts in the same phase where replacement behavior is introduced. - - Keep specs/tests in lockstep with the active behavior in the same change set. - diff --git a/COMPLETED_PHASES.md b/COMPLETED_PHASES.md deleted file mode 100644 index 1ae03d7ae..000000000 --- a/COMPLETED_PHASES.md +++ /dev/null @@ -1,22006 +0,0 @@ -# Completed Phases - -Any time a phase is fully completed, AFTER it has been merged into main: - - The numbering and ordering of phases does not matter whatsoever - - Remove the phase from the current roadmap - - Append the Title and a brief, single item summary - - Some phase numbers are reused - - Nothing in this document should be treated as authoritative, refer to code for truth - -Completed phase snapshots are archived here after being removed from GIGAROADMAP.md. - ---- - -- Phase R -- Make Monoliths Modular, My Man - -## Phase R -- Refactor MegaCut - ---- - -### R.0 Refactor playbook (applies to every subtask) - -**Goal:** Split “fat” modules into cohesive, testable, side-effect-minimized modules **without changing behavior**. - -**Hard rules** -- **No behavior changes** unless explicitly called out in the task’s “Behavior deltas” section. -- **Preserve public entrypoints.** If you move code, keep a tiny compatibility shim at the old path that re-exports the same names. -- **Keep ESM import style** (repo is `"type": "module"`; keep `.js` extensions on relative imports). -- **No new global state**; prefer pure helpers and dependency injection (pass `log`, `signal`, configs, caches). -- **Avoid circular imports** by extracting shared primitives to `src/shared/**` and keeping “leaf” modules free of imports from high-level orchestrators. - -**PR checklist** -- [x] New modules have a single responsibility and minimal export surface. -- [x] All call sites updated (or compatibility re-export added). -- [x] All relevant tests pass: - - `node tests/run.js --lane pr` (preferred) or `npm run test:pr` - - plus any targeted tests called out per task. -- [x] `npm run lint` passes. -- [x] File sizes: prefer < ~500 LOC for “leaf” modules; orchestrators may be larger but should be mostly glue. - ---- - -### R.1 Script surface policy + docs (package.json sprawl) - -**Spec conflict (resolved):** The original Phase R.1 demanded `package.json` scripts be reduced to <10. -The current repo has already adopted a **policy-based approach** instead: -- `tools/docs/script-inventory.js` generates `docs/tooling/script-inventory.json` and `docs/guides/commands.md`. -- `tests/indexing/policy/script-surface-policy.test.js` enforces that the inventory matches `package.json`. - -This is a better trade-off than “<10 scripts” because: -- Many scripts are intentionally “debuggable entrypoints” for specific tests/tools. -- The repo has explicit policy + inventory tooling already; rewriting the entire script surface would be noisy and high-churn. - -#### R.1.1 Script inventory + policy enforcement -- [x] Keep `tools/docs/script-inventory.js` as the single generator for: - - `docs/tooling/script-inventory.json` - - `docs/guides/commands.md` -- [x] Keep `tests/indexing/policy/script-surface-policy.test.js` enforcing inventory ↔ package parity. - -**Callouts** -- Generator: `tools/docs/script-inventory.js` -- Inventory: `docs/tooling/script-inventory.json` -- Policy test: `tests/indexing/policy/script-surface-policy.test.js` - -#### R.1.2 Fix doc drift: commands.md must be reproducible -- [x] Resolve the current mismatch where `docs/guides/commands.md` contains a “Phase 3 specs” section that **is not emitted** by `tools/docs/script-inventory.js`. - - **Best choice:** make `commands.md` purely generated; either: - 1) Update generator to also emit a “Phase specs” section (recommended), or - 2) Remove the non-generated section from `commands.md` and move it to a separate doc (less ideal; increases doc surface). -- [x] Add a policy test to prevent future drift: - - New test file: `tests/indexing/policy/script-inventory-docs.test.js` - - It should run the generator in-memory and compare against `docs/guides/commands.md` OR at minimum assert: - - the “generated by” header is present - - the “Stable entrypoints” block matches the generator’s output - - the script table contains the same script names as inventory - -**Why option (1) is recommended:** -The repo already treats `commands.md` as generated. Making the generator responsible for *all* its sections is the only way to keep it deterministic and enforceable. - -#### R.1.3 Tighten the “stable entrypoints” contract -- [x] Document (and keep stable) the following script entrypoints: - - `test`, `test:pr`, `test:nightly`, `lint`, `format`, `config:budget`, `env:check`, `verify` -- [x] Update any user-facing docs / error messages that reference non-stable scripts to prefer: - - `pairofcleats ...` CLI entrypoints (preferred for users) - - or `node \n'); +await write('web/frpc/src/main.ts', 'console.log("frpc");\n'); await write('cmake/main.cmake', 'include(modules/common.cmake)\n'); await write('cmake/modules/common.cmake', '# helper\n'); @@ -87,10 +95,24 @@ await write( 'load("//tools/pkg", "pkg_macro")', 'load("//tools/pkg:defs", "defs_macro")', 'load(":local.bzl", "local_macro")', + 'load(":missing_local.bzl", "missing_local_macro")', + 'load("@repo_tools//defs:missing.bzl", "missing_external_macro")', '' ].join('\n') ); await write('app/local.bzl', 'def local_macro():\n pass\n'); +await write( + 'MODULE.bazel', + 'load("//go:extensions.bzl", "go_deps")\nload("//go:missing_extension.bzl", "go_missing")\n' +); +await write('go/extensions.bzl', 'def go_deps():\n pass\n'); +await write('Makefile', 'include ./Dockerfile-kubernetes\n'); +await write('Dockerfile-kubernetes', 'FROM scratch\n'); +await write( + 'src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj', + '\n' +); +await write('src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems', '\n'); await write('lib/main.dart', "import 'package:benchapp/src/util.dart';\nimport 'src/local.dart';\nimport 'package:flutter/material.dart';\n"); await write('lib/src/util.dart', 'class Util {}\n'); @@ -103,6 +125,8 @@ await write('src/main/groovy/com/acme/GMain.groovy', 'import com.acme.util.GHelp await write('src/main/groovy/com/acme/util/GHelper.groovy', 'package com.acme.util\n'); await write('src/plugin/main.js', "import '@repo/dep.js';\n"); await write('src/repo_alias/dep.js', 'export const dep = 1;\n'); +await write('src/custom/main.ts', "import './code-output/client.codegen.ts';\n"); +await write('src/web/main.ts', "import '../vendor/runtime/app.min.js';\n"); await write('src/Main.jl', 'using Util.Core\n'); await write('src/Util/Core.jl', 'module Core\nend\n'); @@ -127,6 +151,8 @@ const entries = [ 'python/pkg/helpers.py', 'python/pkg/stubs.pyi', 'python/pkg/utils/__init__.py', + 'python/service/main.py', + 'python/service/proto/client.proto', 'python/pydantic_core/__init__.py', 'python/pydantic_core/_pydantic_core.pyi', 'lib/App/Main.pm', @@ -151,7 +177,11 @@ const entries = [ 'src/util/parser.rs', 'scripts/main.sh', 'scripts/system.sh', + 'src/gen/consumer.ts', 'scripts/lib/helpers.sh', + 'index.html', + 'web/frpc/index.html', + 'web/frpc/src/main.ts', 'cmake/main.cmake', 'cmake/modules/common.cmake', 'cmake/sub/main.cmake', @@ -165,6 +195,12 @@ const entries = [ 'tools/pkg/defs.bzl', 'app/rules.bzl', 'app/local.bzl', + 'MODULE.bazel', + 'go/extensions.bzl', + 'Makefile', + 'Dockerfile-kubernetes', + 'src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj', + 'src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems', 'lib/main.dart', 'lib/src/util.dart', 'lib/src/local.dart', @@ -174,6 +210,8 @@ const entries = [ 'src/main/groovy/com/acme/util/GHelper.groovy', 'src/plugin/main.js', 'src/repo_alias/dep.js', + 'src/custom/main.ts', + 'src/web/main.ts', 'src/Main.jl', 'src/Util/Core.jl', 'src/main.cpp', @@ -189,6 +227,7 @@ const entries = [ const importsByFile = { 'python/pkg/main.py': ['helpers', '.utils', 'requests'], 'python/pkg/stubs.pyi': ['.helpers'], + 'python/service/main.py': ['./proto/client_pb2.py'], 'python/pydantic_core/__init__.py': ['._pydantic_core'], 'lib/App/Main.pm': ['App::Util'], 'lua/app/main.lua': ['app.util'], @@ -201,14 +240,31 @@ const importsByFile = { 'src/lib.rs': ['crate::util::parser'], 'scripts/main.sh': ['lib/helpers.sh', './lib/missing.sh'], 'scripts/system.sh': ['/etc/bash_completion', '/usr/local/share/chruby/auto.sh'], + 'src/gen/consumer.ts': ['./generated/client.pb.ts'], + 'index.html': ['/logo/logo-clear.png'], + 'web/frpc/index.html': ['/src/main.ts'], 'cmake/main.cmake': ['modules/common.cmake'], 'cmake/sub/main.cmake': ['../modules/common.cmake'], 'nix/flake.nix': ['./modules', './pkgs/tool', './git-hooks.nix'], - 'app/rules.bzl': ['//tools:defs.bzl', '//tools/pkg', '//tools/pkg:defs', ':local.bzl'], + 'app/rules.bzl': [ + '//tools:defs.bzl', + '//tools/pkg', + '//tools/pkg:defs', + ':local.bzl', + ':missing_local.bzl', + '@repo_tools//defs:missing.bzl' + ], + 'MODULE.bazel': ['//go:extensions.bzl', '//go:missing_extension.bzl'], + Makefile: ['./Dockerfile-kubernetes'], + 'src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj': [ + '..\\..\\..\\..\\Common\\src\\Common.OData.ApiExplorer\\Common.OData.ApiExplorer.projitems' + ], 'lib/main.dart': ['package:benchapp/src/util.dart', 'src/local.dart', 'package:flutter/material.dart'], 'src/main/scala/com/acme/ScalaMain.scala': ['com.acme.util.ScalaHelper'], 'src/main/groovy/com/acme/GMain.groovy': ['com.acme.util.GHelper'], 'src/plugin/main.js': ['@repo/dep.js'], + 'src/custom/main.ts': ['./code-output/client.codegen.ts'], + 'src/web/main.ts': ['../vendor/runtime/app.min.js'], 'src/Main.jl': ['Util.Core'], 'src/main.cpp': ['myproj/foo.hpp', 'vector'], 'rust/Cargo.toml': ['crates/util'], @@ -231,8 +287,14 @@ const resolution = resolveImportLinks({ rules: [ { match: '@repo/*', replace: 'src/repo_alias/*' } ] + }, + buildContext: { + generatedArtifactsConfig: { + suffixes: ['.codegen.ts'] + } } - } + }, + generatedPolicy: buildGeneratedPolicyConfig({}) }); const assertLinks = (file, expected) => { @@ -270,10 +332,18 @@ assertLinks('Sources/Core/Main.swift', ['Sources/CoreNetworking/Client.swift']); assertLinks('src/lib.rs', ['src/util/parser.rs']); assertLinks('scripts/main.sh', ['scripts/lib/helpers.sh']); assertExternal('scripts/system.sh', ['/etc/bash_completion', '/usr/local/share/chruby/auto.sh']); +assertExternal('index.html', ['/logo/logo-clear.png']); +assertLinks('web/frpc/index.html', ['web/frpc/src/main.ts']); assertLinks('cmake/main.cmake', ['cmake/modules/common.cmake']); assertLinks('cmake/sub/main.cmake', ['cmake/modules/common.cmake']); assertLinks('nix/flake.nix', ['nix/git-hooks.nix', 'nix/modules/default.nix', 'nix/pkgs/tool/default.nix']); assertLinksUnordered('app/rules.bzl', ['app/local.bzl', 'tools/defs.bzl', 'tools/pkg/defs.bzl', 'tools/pkg/pkg.bzl']); +assertLinks('MODULE.bazel', ['go/extensions.bzl']); +assertLinks('Makefile', ['Dockerfile-kubernetes']); +assertLinks( + 'src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj', + ['src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems'] +); assertLinks('lib/main.dart', ['lib/src/local.dart', 'lib/src/util.dart']); assertExternal('lib/main.dart', ['package:flutter/material.dart']); assertLinks('src/main/scala/com/acme/ScalaMain.scala', ['src/main/scala/com/acme/util/ScalaHelper.scala']); @@ -290,45 +360,142 @@ assertLinks( ); const realUnresolvedSamples = enrichUnresolvedImportSamples(resolution.unresolvedSamples || []); -assert.equal(realUnresolvedSamples.length, 1, 'expected one unresolved sample from shell include coverage'); -assert.equal(realUnresolvedSamples[0].specifier, './lib/missing.sh'); -assert.equal(realUnresolvedSamples[0].category, 'missing_file'); +assert.equal(realUnresolvedSamples.length, 8, 'expected unresolved samples from shell, bazel label, and generated coverage'); +const realBySpecifier = Object.fromEntries(realUnresolvedSamples.map((entry) => [entry.specifier, entry])); +assert.equal(realBySpecifier['./lib/missing.sh']?.reasonCode, 'IMP_U_MISSING_FILE_RELATIVE'); +assert.equal(realBySpecifier['./lib/missing.sh']?.failureCause, 'missing_file'); +assert.equal(realBySpecifier['./lib/missing.sh']?.disposition, 'actionable'); +assert.equal(realBySpecifier['./lib/missing.sh']?.resolverStage, 'filesystem_probe'); +assert.equal(realBySpecifier['//go:missing_extension.bzl']?.reasonCode, 'IMP_U_BAZEL_LABEL_TARGET_MISSING'); +assert.equal(realBySpecifier['//go:missing_extension.bzl']?.failureCause, 'missing_file'); +assert.equal(realBySpecifier['//go:missing_extension.bzl']?.disposition, 'actionable'); +assert.equal(realBySpecifier['//go:missing_extension.bzl']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier['//go:missing_extension.bzl']?.resolverAdapter, 'bazel-label'); +assert.equal(realBySpecifier[':missing_local.bzl']?.reasonCode, 'IMP_U_BAZEL_LABEL_TARGET_MISSING'); +assert.equal(realBySpecifier[':missing_local.bzl']?.failureCause, 'missing_file'); +assert.equal(realBySpecifier[':missing_local.bzl']?.disposition, 'actionable'); +assert.equal(realBySpecifier[':missing_local.bzl']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier[':missing_local.bzl']?.resolverAdapter, 'bazel-label'); +assert.equal(realBySpecifier['@repo_tools//defs:missing.bzl']?.reasonCode, 'IMP_U_BAZEL_EXTERNAL_REPOSITORY_UNAVAILABLE'); +assert.equal(realBySpecifier['@repo_tools//defs:missing.bzl']?.failureCause, 'missing_dependency'); +assert.equal(realBySpecifier['@repo_tools//defs:missing.bzl']?.disposition, 'suppress_gate'); +assert.equal(realBySpecifier['@repo_tools//defs:missing.bzl']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier['./generated/client.pb.ts']?.reasonCode, 'IMP_U_GENERATED_EXPECTED_MISSING'); +assert.equal(realBySpecifier['./generated/client.pb.ts']?.failureCause, 'generated_expected_missing'); +assert.equal(realBySpecifier['./generated/client.pb.ts']?.disposition, 'suppress_gate'); +assert.equal(realBySpecifier['./generated/client.pb.ts']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier['./proto/client_pb2.py']?.reasonCode, 'IMP_U_GENERATED_EXPECTED_MISSING'); +assert.equal(realBySpecifier['./proto/client_pb2.py']?.failureCause, 'generated_expected_missing'); +assert.equal(realBySpecifier['./proto/client_pb2.py']?.disposition, 'suppress_gate'); +assert.equal(realBySpecifier['./proto/client_pb2.py']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier['./code-output/client.codegen.ts']?.reasonCode, 'IMP_U_GENERATED_EXPECTED_MISSING'); +assert.equal(realBySpecifier['./code-output/client.codegen.ts']?.failureCause, 'generated_expected_missing'); +assert.equal(realBySpecifier['./code-output/client.codegen.ts']?.disposition, 'suppress_gate'); +assert.equal(realBySpecifier['./code-output/client.codegen.ts']?.resolverStage, 'build_system_resolver'); +assert.equal(realBySpecifier['../vendor/runtime/app.min.js']?.reasonCode, 'IMP_U_GENERATED_EXPECTED_MISSING'); +assert.equal(realBySpecifier['../vendor/runtime/app.min.js']?.failureCause, 'generated_expected_missing'); +assert.equal(realBySpecifier['../vendor/runtime/app.min.js']?.disposition, 'suppress_gate'); +assert.equal(realBySpecifier['../vendor/runtime/app.min.js']?.resolverStage, 'build_system_resolver'); const taxonomySamples = enrichUnresolvedImportSamples([ ...realUnresolvedSamples, - { importer: 'tests/__fixtures__/case.test.js', specifier: './missing-fixture.js', reason: 'unresolved' }, - { importer: 'src/main.js', specifier: 'fsevents', reason: 'optional dependency not installed' }, - { importer: 'src/main.js', specifier: '.\\windows\\path\\module.js', reason: 'unresolved' }, - { importer: 'src/main.js', specifier: './utlis.jss', reason: 'unresolved' } + { + importer: 'tests/__fixtures__/case.test.js', + specifier: './missing-fixture.js', + reasonCode: 'IMP_U_FIXTURE_REFERENCE' + }, + { + importer: 'src/main.js', + specifier: 'fsevents', + reasonCode: 'IMP_U_OPTIONAL_DEPENDENCY' + }, + { + importer: 'MODULE.bazel', + specifier: '//go:missing.bzl', + reasonCode: 'IMP_U_BAZEL_LABEL_TARGET_MISSING' + }, + { + importer: 'src/main.js', + specifier: '.\\windows\\path\\module.js', + reasonCode: 'IMP_U_PATH_NORMALIZATION' + }, + { + importer: 'src/main.js', + specifier: './utlis.jss', + reasonCode: 'IMP_U_TYPO' + } ]); const taxonomy = summarizeUnresolvedImportTaxonomy(taxonomySamples); -const taxonomyBySpecifier = Object.fromEntries( - taxonomySamples.map((sample) => [sample.specifier, sample.category]) -); -assert.equal(taxonomyBySpecifier['./missing-fixture.js'], 'fixture'); -assert.equal(taxonomyBySpecifier.fsevents, 'optional_dependency'); -assert.equal(taxonomyBySpecifier['.\\windows\\path\\module.js'], 'path_normalization'); -assert.equal(taxonomyBySpecifier['./utlis.jss'], 'typo'); -assert.equal(taxonomyBySpecifier['./lib/missing.sh'], 'missing_file'); assert.equal(taxonomy.liveSuppressed, 2); -assert.equal(taxonomy.actionable, 3); +assert.equal(taxonomy.gateSuppressed, 5); +assert.equal(taxonomy.actionable, 6); +assert.equal(Object.keys(taxonomy.reasonCodes).length > 0, true, 'expected reason-code aggregation'); assert.deepEqual( - Object.fromEntries(Object.entries(taxonomy.categories)), + Object.fromEntries(Object.entries(taxonomy.resolverStages)), { - fixture: 1, - missing_file: 1, - optional_dependency: 1, - path_normalization: 1, - typo: 1 - } + build_system_resolver: 8, + classify: 3, + filesystem_probe: 1, + normalize: 1 + }, + 'expected resolver stage aggregation in taxonomy' +); +assert.deepEqual( + Object.fromEntries(Object.entries(taxonomy.resolverAdapters || {})), + { + 'bazel-label': 3, + 'generated-artifacts': 4 + }, + 'expected resolver adapter aggregation in taxonomy' +); +assert.deepEqual( + taxonomy.actionableHotspots, + [ + { importer: 'MODULE.bazel', count: 2 }, + { importer: 'src/main.js', count: 2 }, + { importer: 'app/rules.bzl', count: 1 }, + { importer: 'scripts/main.sh', count: 1 } + ], + 'expected actionable unresolved importer hotspots' +); +assert.deepEqual( + Object.fromEntries(Object.entries(taxonomy.actionableByLanguage || {})), + { bazel: 2, bzl: 1, js: 2, sh: 1 }, + 'expected actionable unresolved language hotspot counts' ); +assert.equal(Number.isFinite(Number(taxonomy.actionableRate)), true, 'expected actionable rate in taxonomy'); +assert.equal(taxonomy.actionableUnresolvedRate, taxonomy.actionableRate, 'expected actionable rate alias'); +assert.equal(taxonomy.parserArtifactRate, 1 / 13, 'expected parser artifact rate in taxonomy'); +assert.equal(taxonomy.resolverGapRate, 0, 'expected resolver-gap rate in taxonomy'); +assert.deepEqual(Object.fromEntries(Object.entries(taxonomy.failureCauses)), { + generated_expected_missing: 4, + missing_dependency: 2, + missing_file: 4, + parser_artifact: 1, + unknown: 2 +}); const parseErrorCategory = classifyUnresolvedImportSample({ importer: 'src/broken.js', specifier: './module.js', reason: 'parse_error' }); -assert.equal(parseErrorCategory.category, 'parse_error'); assert.equal(parseErrorCategory.suppressLive, false); +assert.equal(parseErrorCategory.reasonCode, 'IMP_U_PARSE_ERROR'); +assert.equal(parseErrorCategory.failureCause, 'parse_error'); +assert.equal(parseErrorCategory.resolutionState, 'unresolved'); + +const invalidIncomingFields = classifyUnresolvedImportSample({ + importer: 'src/noise.js', + specifier: './missing.js', + reasonCode: 'IMP_U_PARSER_NOISE_SUPPRESSED', + failureCause: 'invalid_failure', + disposition: 'actionable', + resolverStage: 'invalid_stage' +}); +assert.equal(invalidIncomingFields.reasonCode, 'IMP_U_PARSER_NOISE_SUPPRESSED'); +assert.equal(invalidIncomingFields.failureCause, 'parser_artifact'); +assert.equal(invalidIncomingFields.disposition, 'suppress_live'); +assert.equal(invalidIncomingFields.resolverStage, 'classify'); console.log('import resolution language coverage tests passed'); diff --git a/tests/indexing/imports/import-resolution-llvm-fixture-paths.test.js b/tests/indexing/imports/import-resolution-llvm-fixture-paths.test.js index d467077f6..42a5cd5b7 100644 --- a/tests/indexing/imports/import-resolution-llvm-fixture-paths.test.js +++ b/tests/indexing/imports/import-resolution-llvm-fixture-paths.test.js @@ -96,8 +96,8 @@ assert.deepEqual( ); assert.deepEqual( relations.get('ci/monolithic-linux.sh')?.importLinks || [], - ['ci/utils.sh'], - 'expected .ci path import to resolve from repo root' + ['.ci/utils.sh'], + 'expected .ci path import to preserve repo-relative hidden directory' ); assert.deepEqual( relations.get('clang/unittests/Frontend/PCHPreambleTest.cpp')?.importLinks || [], @@ -120,8 +120,10 @@ assert.deepEqual( ); assert.equal( (result?.graph?.warnings || []).length, - 0, - 'expected fixture-only unresolved imports to be suppressed' + 1, + 'expected fixture-only unresolved imports to remain visible while staying non-actionable' ); +assert.equal(result?.graph?.stats?.unresolvedActionable || 0, 0, 'expected no actionable unresolved warnings'); +assert.equal(result?.graph?.stats?.unresolvedSuppressed, 1, 'expected suppressed unresolved accounting'); console.log('import resolution llvm fixture path test passed'); diff --git a/tests/indexing/imports/import-resolution-policy-contract-matrix.test.js b/tests/indexing/imports/import-resolution-policy-contract-matrix.test.js new file mode 100644 index 000000000..94a31e45c --- /dev/null +++ b/tests/indexing/imports/import-resolution-policy-contract-matrix.test.js @@ -0,0 +1,379 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + createFsExistsIndex, + createImportResolutionBudgetPolicy, + resolveImportLinks +} from '../../../src/index/build/import-resolution.js'; +import { + enrichUnresolvedImportSamples, + sortImportScanItems +} from '../../../src/index/build/imports.js'; +import { collectLanguageImports } from '../../../src/index/language-registry.js'; +import { createImportResolutionTempRoot } from '../../helpers/import-resolution-fixture.js'; + +const cases = [ + { + name: 'adaptive budget policy shifts and respects explicit overrides', + async run() { + const defaultPolicy = createImportResolutionBudgetPolicy(); + assert.equal(defaultPolicy.maxFilesystemProbesPerSpecifier, 32); + assert.equal(defaultPolicy.maxFallbackCandidatesPerSpecifier, 48); + assert.equal(defaultPolicy.maxFallbackDepth, 16); + assert.equal(defaultPolicy.adaptiveEnabled, true); + assert.equal(defaultPolicy.adaptiveProfile, 'normal'); + assert.equal(defaultPolicy.adaptiveScale, 1); + + const pressurePolicy = createImportResolutionBudgetPolicy({ + runtimeSignals: { + scheduler: { + utilizationOverall: 0.5, + pending: 128, + running: 4, + memoryPressure: 0.95, + fdPressure: 0.4 + }, + envelope: { + cpuConcurrency: 8, + ioConcurrency: 8 + } + } + }); + assert.equal(pressurePolicy.adaptiveProfile, 'pressure_critical'); + assert.equal(pressurePolicy.maxFilesystemProbesPerSpecifier, 16); + assert.equal(pressurePolicy.maxFallbackCandidatesPerSpecifier, 24); + assert.equal(pressurePolicy.maxFallbackDepth, 12); + assert.notEqual(pressurePolicy.fingerprint, defaultPolicy.fingerprint); + + const headroomPolicy = createImportResolutionBudgetPolicy({ + runtimeSignals: { + scheduler: { + utilizationOverall: 0.9, + pending: 4, + running: 2, + memoryPressure: 0.2, + fdPressure: 0.2 + }, + envelope: { + cpuConcurrency: 16, + ioConcurrency: 12 + } + } + }); + assert.equal(headroomPolicy.adaptiveProfile, 'capacity_headroom'); + assert.ok(headroomPolicy.maxFilesystemProbesPerSpecifier > defaultPolicy.maxFilesystemProbesPerSpecifier); + assert.ok(headroomPolicy.maxFallbackCandidatesPerSpecifier > defaultPolicy.maxFallbackCandidatesPerSpecifier); + assert.ok(headroomPolicy.maxFallbackDepth > defaultPolicy.maxFallbackDepth); + + const explicitPolicy = createImportResolutionBudgetPolicy({ + resolverPlugins: { + budgets: { + maxFilesystemProbesPerSpecifier: 10, + maxFallbackCandidatesPerSpecifier: 20, + maxFallbackDepth: 5 + } + }, + runtimeSignals: { + scheduler: { + utilizationOverall: 0.2, + pending: 512, + running: 1, + memoryPressure: 0.99, + fdPressure: 0.99 + }, + envelope: { + cpuConcurrency: 16, + ioConcurrency: 16 + } + } + }); + assert.equal(explicitPolicy.maxFilesystemProbesPerSpecifier, 10); + assert.equal(explicitPolicy.maxFallbackCandidatesPerSpecifier, 20); + assert.equal(explicitPolicy.maxFallbackDepth, 5); + + const disabledAdaptivePolicy = createImportResolutionBudgetPolicy({ + resolverPlugins: { + budgets: { + adaptive: false + } + }, + runtimeSignals: { + scheduler: { + utilizationOverall: 0.2, + pending: 512, + running: 1, + memoryPressure: 0.99, + fdPressure: 0.99 + }, + envelope: { + cpuConcurrency: 16, + ioConcurrency: 16 + } + } + }); + assert.equal(disabledAdaptivePolicy.adaptiveEnabled, false); + assert.equal(disabledAdaptivePolicy.adaptiveProfile, 'disabled'); + assert.equal(disabledAdaptivePolicy.adaptiveScale, 1); + assert.equal(disabledAdaptivePolicy.maxFilesystemProbesPerSpecifier, 32); + assert.equal(disabledAdaptivePolicy.maxFallbackCandidatesPerSpecifier, 48); + assert.equal(disabledAdaptivePolicy.maxFallbackDepth, 16); + } + }, + { + name: 'scan priority prefers cached coverage and smaller files', + async run() { + const items = [ + { relKey: 'a', stat: { size: 100 }, index: 0 }, + { relKey: 'b', stat: { size: 1000 }, index: 1 }, + { relKey: 'c', stat: { size: 2000 }, index: 2 }, + { relKey: 'd', stat: { size: 150 }, index: 3 } + ]; + const counts = new Map([ + ['a', 10], + ['b', 5], + ['d', 10] + ]); + + sortImportScanItems(items, counts); + assert.equal(items.map((item) => item.relKey).join(','), 'd,a,b,c'); + } + }, + { + name: 'flow mode forwarding enables Flow import collection only when requested', + async run() { + const text = [ + "import type { Foo } from 'flow-lib';", + 'type Foo = { value: string };' + ].join('\n'); + + const withFlow = collectLanguageImports({ + ext: '.js', + relPath: 'src/flow.js', + text, + mode: 'code', + options: { flowMode: 'on' } + }); + assert.ok(withFlow.includes('flow-lib')); + + const withoutFlow = collectLanguageImports({ + ext: '.js', + relPath: 'src/flow.js', + text, + mode: 'code' + }); + assert.ok(!withoutFlow.includes('flow-lib')); + } + }, + { + name: 'filesystem probe exhaustion reports budgeted unresolved samples', + async run() { + const tempRoot = await createImportResolutionTempRoot('imports-contract-budget-exhaustion'); + const srcRoot = path.join(tempRoot, 'src'); + await fs.mkdir(srcRoot, { recursive: true }); + await fs.writeFile( + path.join(srcRoot, 'main.js'), + "import './missing-a';\nimport './missing-b';\n", + 'utf8' + ); + + const entries = [{ abs: path.join(srcRoot, 'main.js'), rel: 'src/main.js' }]; + const importsByFile = { 'src/main.js': ['./missing-a', './missing-b'] }; + const relations = new Map([ + ['src/main.js', { imports: importsByFile['src/main.js'].slice() }] + ]); + + const result = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations: relations, + enableGraph: true, + resolverPlugins: { + budgets: { + maxFilesystemProbesPerSpecifier: 0, + maxFallbackCandidatesPerSpecifier: 8 + } + } + }); + + const unresolved = enrichUnresolvedImportSamples(result?.unresolvedSamples || []); + assert.equal(unresolved.length, 2); + for (const sample of unresolved) { + assert.equal(sample.reasonCode, 'IMP_U_RESOLVER_BUDGET_EXHAUSTED'); + assert.equal(sample.failureCause, 'resolver_gap'); + assert.equal(sample.disposition, 'suppress_gate'); + assert.equal(sample.resolverStage, 'filesystem_probe'); + } + + assert.equal(result?.stats?.unresolvedBudgetExhausted, 2); + assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedBudgetExhaustedByType || {})), + { filesystem_probe: 2 } + ); + assert.equal(result?.stats?.resolverPipelineStages?.filesystem_probe?.budgetExhausted || 0, 2); + assert.equal(result?.stats?.resolverPipelineStages?.filesystem_probe?.degraded || 0, 2); + assert.equal(result?.graph?.stats?.unresolvedBudgetExhausted, 2); + assert.deepEqual( + Object.fromEntries(Object.entries(result?.graph?.stats?.unresolvedBudgetExhaustedByType || {})), + { filesystem_probe: 2 } + ); + } + }, + { + name: 'fallback depth budgets are reported independently from filesystem probe budgets', + async run() { + const tempRoot = await createImportResolutionTempRoot('imports-contract-fallback-depth'); + const nestedRoot = path.join(tempRoot, 'src', 'nested'); + await fs.mkdir(nestedRoot, { recursive: true }); + await fs.writeFile(path.join(nestedRoot, 'main.js'), "import '../../../missing';\n", 'utf8'); + + const entries = [{ abs: path.join(nestedRoot, 'main.js'), rel: 'src/nested/main.js' }]; + const importsByFile = { 'src/nested/main.js': ['../../../missing'] }; + const relations = new Map([ + ['src/nested/main.js', { imports: importsByFile['src/nested/main.js'].slice() }] + ]); + + const result = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations: relations, + enableGraph: true, + resolverPlugins: { + budgets: { + maxFilesystemProbesPerSpecifier: 32, + maxFallbackCandidatesPerSpecifier: 32, + maxFallbackDepth: 1 + } + } + }); + + const unresolved = enrichUnresolvedImportSamples(result?.unresolvedSamples || []); + assert.equal(unresolved.length, 1); + assert.equal(unresolved[0].reasonCode, 'IMP_U_RESOLVER_BUDGET_EXHAUSTED'); + assert.equal(unresolved[0].failureCause, 'resolver_gap'); + assert.equal(unresolved[0].resolverStage, 'filesystem_probe'); + assert.equal(result?.stats?.unresolvedBudgetExhausted, 1); + assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedBudgetExhaustedByType || {})), + { fallback_depth: 1 } + ); + assert.equal(result?.graph?.stats?.unresolvedBudgetExhausted, 1); + assert.deepEqual( + Object.fromEntries(Object.entries(result?.graph?.stats?.unresolvedBudgetExhaustedByType || {})), + { fallback_depth: 1 } + ); + } + }, + { + name: 'fs-exists index exact hits bypass exhausted filesystem probe budgets', + async run() { + const tempRoot = await createImportResolutionTempRoot('imports-contract-fs-index-shortcircuit'); + const srcRoot = path.join(tempRoot, 'src'); + const depsRoot = path.join(tempRoot, 'deps'); + await fs.mkdir(srcRoot, { recursive: true }); + await fs.mkdir(depsRoot, { recursive: true }); + await fs.writeFile(path.join(srcRoot, 'main.js'), "import '../deps/local.js';\n", 'utf8'); + await fs.writeFile(path.join(depsRoot, 'local.js'), 'export const local = true;\n', 'utf8'); + + const entries = [{ abs: path.join(srcRoot, 'main.js'), rel: 'src/main.js' }]; + const importsByFile = { 'src/main.js': ['../deps/local.js'] }; + + const baselineRelations = new Map([['src/main.js', { imports: ['../deps/local.js'] }]]); + const baseline = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations: baselineRelations, + enableGraph: true, + resolverPlugins: { + budgets: { + maxFilesystemProbesPerSpecifier: 0, + maxFallbackCandidatesPerSpecifier: 16 + } + } + }); + const baselineUnresolved = enrichUnresolvedImportSamples(baseline?.unresolvedSamples || []); + assert.equal(baselineRelations.get('src/main.js')?.externalImports?.length || 0, 0); + assert.equal(baseline?.stats?.unresolvedBudgetExhausted, 1); + assert.equal(baselineUnresolved[0]?.reasonCode, 'IMP_U_RESOLVER_BUDGET_EXHAUSTED'); + + const fsExistsIndex = await createFsExistsIndex({ root: tempRoot, entries }); + const acceleratedRelations = new Map([['src/main.js', { imports: ['../deps/local.js'] }]]); + const accelerated = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations: acceleratedRelations, + enableGraph: true, + fsExistsIndex, + resolverPlugins: { + budgets: { + maxFilesystemProbesPerSpecifier: 0, + maxFallbackCandidatesPerSpecifier: 16 + } + } + }); + assert.deepEqual(acceleratedRelations.get('src/main.js')?.externalImports || [], ['../deps/local.js']); + assert.equal(accelerated?.stats?.unresolvedBudgetExhausted, 0); + assert.equal(accelerated?.stats?.resolverFsExistsIndex?.exactHits, 1); + assert.equal(accelerated?.stats?.resolverFsExistsIndex?.negativeSkips, 0); + } + }, + { + name: 'stage pipeline counters reflect successful and unresolved build-system passes', + async run() { + const tempRoot = await createImportResolutionTempRoot('imports-contract-stage-pipeline'); + await fs.mkdir(path.join(tempRoot, 'go'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'MODULE.bazel'), 'module(name = "demo")\n', 'utf8'); + await fs.writeFile(path.join(tempRoot, 'go', 'extensions.bzl'), 'def go_deps():\n pass\n', 'utf8'); + + const entries = [ + { abs: path.join(tempRoot, 'MODULE.bazel'), rel: 'MODULE.bazel' }, + { abs: path.join(tempRoot, 'go', 'extensions.bzl'), rel: 'go/extensions.bzl' } + ]; + const importsByFile = { + 'MODULE.bazel': ['//go:extensions.bzl', '//go:missing_extension.bzl'] + }; + const fileRelations = new Map([ + ['MODULE.bazel', { imports: importsByFile['MODULE.bazel'].slice() }] + ]); + + const resolution = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations, + enableGraph: true + }); + + const stages = resolution?.stats?.resolverPipelineStages || {}; + assert.equal((stages.normalize?.attempts || 0) >= 2, true); + assert.equal((stages.language_resolver?.attempts || 0) >= 2, true); + assert.equal((stages.build_system_resolver?.attempts || 0) >= 1, true); + assert.equal((stages.classify?.attempts || 0) >= 1, true); + assert.equal((stages.filesystem_probe?.attempts || 0) >= 1, true); + assert.equal(Number.isFinite(Number(stages.classify?.elapsedMs)), true); + assert.equal(Number.isFinite(Number(stages.classify?.budgetExhausted)), true); + assert.equal(Number.isFinite(Number(stages.classify?.degraded)), true); + + const warnings = Array.isArray(resolution?.unresolvedSamples) ? resolution.unresolvedSamples : []; + assert.equal(warnings.length, 1); + assert.equal(warnings[0].reasonCode, 'IMP_U_BAZEL_LABEL_TARGET_MISSING'); + assert.equal(warnings[0].resolverStage, 'build_system_resolver'); + assert.equal(warnings[0].resolverAdapter, 'bazel-label'); + assert.equal(Array.isArray(warnings[0].resolverTrace), true); + assert.equal((stages.build_system_resolver?.reasonCodes?.IMP_U_BAZEL_LABEL_TARGET_MISSING || 0) >= 1, true); + assert.deepEqual(resolution?.graph?.stats?.resolverPipelineStages || {}, stages); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('import resolution policy contract matrix test passed'); diff --git a/tests/indexing/imports/import-scan-budget-runtime-signals.test.js b/tests/indexing/imports/import-scan-budget-runtime-signals.test.js new file mode 100644 index 000000000..29d7f267d --- /dev/null +++ b/tests/indexing/imports/import-scan-budget-runtime-signals.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { postScanImports } from '../../../src/index/build/indexer/steps/relations/import-scan.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'import-scan-budget-runtime-signals'); +const srcRoot = path.join(tempRoot, 'src'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(srcRoot, { recursive: true }); +await fs.writeFile(path.join(srcRoot, 'main.js'), "import './missing.js';\n", 'utf8'); + +const state = new Map([['src/main.js', { imports: ['./missing.js'] }]]); +const stageState = { + fileRelations: state, + importResolutionGraph: null +}; +const timing = {}; +const runtime = { + root: tempRoot, + toolInfo: { version: 'test' }, + indexingConfig: { + importResolution: {} + }, + scheduler: { + stats: () => ({ + utilization: { overall: 0.4 }, + activity: { pending: 160, running: 3 }, + adaptive: { + signals: { + memory: { pressureScore: 0.95 }, + fd: { pressureScore: 0.2 } + } + } + }) + }, + envelope: { + concurrency: { + cpuConcurrency: { value: 8 }, + ioConcurrency: { value: 8 } + } + } +}; + +const result = await postScanImports({ + mode: 'code', + relationsEnabled: true, + scanPlan: { + importScanMode: 'pre', + enableImportLinks: true, + shouldScan: true, + importGraphEnabled: true + }, + state: stageState, + timing, + runtime, + entries: [ + { abs: path.join(srcRoot, 'main.js'), rel: 'src/main.js' } + ], + importResult: { + importsByFile: { + 'src/main.js': ['./missing.js'] + }, + durationMs: 0, + stats: null + }, + incrementalState: null, + fileTextByFile: null, + hangProbeConfig: null, + abortSignal: null +}); + +assert.equal(result?.stats?.resolverBudgetPolicy?.adaptiveEnabled, true); +assert.equal(result?.stats?.resolverBudgetPolicy?.adaptiveProfile, 'pressure_critical'); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFilesystemProbesPerSpecifier, 16); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFallbackCandidatesPerSpecifier, 24); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFallbackDepth, 12); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.adaptiveProfile, 'pressure_critical'); + +console.log('import scan budget runtime signals test passed'); diff --git a/tests/indexing/imports/import-scan-cache-read.test.js b/tests/indexing/imports/import-scan-cache-read.test.js deleted file mode 100644 index 256a9fd25..000000000 --- a/tests/indexing/imports/import-scan-cache-read.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { scanImports } from '../../../src/index/build/imports.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'import-scan-cache-read'); -const srcRoot = path.join(tempRoot, 'src'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcRoot, { recursive: true }); - -const files = []; -for (const name of ['a.js', 'b.js']) { - const filePath = path.join(srcRoot, name); - await fs.writeFile(filePath, 'export const value = 1;\n'); - const stat = await fs.stat(filePath); - files.push({ abs: filePath, rel: `src/${name}`, stat }); -} - -let calls = 0; -const readCachedImportsFn = async () => { - calls += 1; - return null; -}; - -await scanImports({ - files, - root: tempRoot, - mode: 'code', - languageOptions: {}, - importConcurrency: 1, - incrementalState: { - enabled: true, - manifest: { files: {} }, - bundleDir: tempRoot, - bundleFormat: 'json' - }, - readCachedImportsFn -}); - -assert.equal(calls, files.length, 'expected one cached import read per file'); - -console.log('import scan cache read test passed'); - diff --git a/tests/indexing/imports/import-scan-live-suppression-diagnostics.test.js b/tests/indexing/imports/import-scan-live-suppression-diagnostics.test.js new file mode 100644 index 000000000..97a05c6e9 --- /dev/null +++ b/tests/indexing/imports/import-scan-live-suppression-diagnostics.test.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; + +import { logUnresolvedImportSamples } from '../../../src/index/build/indexer/steps/relations/import-scan.js'; + +const writes = []; +const originalWrite = process.stderr.write.bind(process.stderr); +process.stderr.write = (chunk, encoding, callback) => { + writes.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + if (typeof encoding === 'function') encoding(); + if (typeof callback === 'function') callback(); + return true; +}; + +try { + const suppression = logUnresolvedImportSamples({ + samples: [{ + importer: 'src/main.js', + specifier: './missing.js', + reasonCode: 'IMP_U_RESOLVER_GAP', + failureCause: 'resolver_gap', + confidence: 0.92 + }], + suppressed: 0, + unresolvedTotal: 4, + taxonomy: { + total: 4, + actionable: 1, + liveSuppressed: 1, + gateSuppressed: 0, + actionableRate: 0.25, + actionableUnresolvedRate: 0.25, + parserArtifactRate: 0, + resolverGapRate: 0.25, + resolverBudgetExhausted: 0, + resolverBudgetExhaustedByType: {}, + reasonCodes: { + IMP_U_RESOLVER_GAP: 1 + }, + failureCauses: { + resolver_gap: 1 + }, + resolverStages: { + resolver: 1 + }, + resolverAdapters: { + tsconfig: 1 + }, + actionableHotspots: [{ + importer: 'src/main.js', + count: 1 + }], + actionableByLanguage: { + js: 1 + } + }, + alreadyNormalized: true + }); + + assert.equal(suppression?.suppressionPolicy, 'live'); + assert.equal(suppression?.suppressedCount, 1); + assert.equal(suppression?.degradedRun, true); + assert.equal(suppression?.visibleSampleCount, 1, 'expected degraded runs to retain a bounded unresolved sample'); + assert.deepEqual(suppression?.omittedFailureCauses, ['resolver_gap']); + + const output = writes.join(''); + assert.match(output, /retaining 1 bounded unresolved sample\(s\) despite live suppression/i); + assert.match(output, /\[imports\] suppression: policy=live count=1 degraded=1 visible=1 total=4 actionable=1 omittedFailureCauses=resolver_gap/); + + console.log('import scan live suppression diagnostics test passed'); +} finally { + process.stderr.write = originalWrite; +} diff --git a/tests/indexing/imports/import-scan-non-js-end-to-end.test.js b/tests/indexing/imports/import-scan-non-js-end-to-end.test.js new file mode 100644 index 000000000..ba5b8b4a6 --- /dev/null +++ b/tests/indexing/imports/import-scan-non-js-end-to-end.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { scanImports } from '../../../src/index/build/imports.js'; +import { resolveImportLinks } from '../../../src/index/build/import-resolution.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'import-scan-non-js-end-to-end'); +await fs.rm(tempRoot, { recursive: true, force: true }); + +const write = async (relPath, content = '') => { + const absPath = path.join(tempRoot, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf8'); +}; + +await write('cmake/main.cmake', 'include(modules/common.cmake)\n'); +await write('cmake/modules/common.cmake', '# helper\n'); +await write('nix/flake.nix', 'imports = [ ./modules ];\n'); +await write('nix/modules/default.nix', '{ }:\n{ }\n'); +await write('python/app.py', [ + '"""', + 'from fake.docs import Demo', + 'import docs_only', + '"""', + 'from runtime.pkg import Loader', + 'import os' +].join('\n')); +await write('python/runtime/pkg.py', 'class Loader:\n pass\n'); + +const relFiles = [ + 'cmake/main.cmake', + 'cmake/modules/common.cmake', + 'nix/flake.nix', + 'nix/modules/default.nix', + 'python/app.py', + 'python/runtime/pkg.py' +]; + +const files = []; +const entries = []; +for (const rel of relFiles) { + const abs = path.join(tempRoot, rel); + const stat = await fs.stat(abs); + files.push({ abs, rel, stat }); + entries.push({ abs, rel }); +} + +const scanResult = await scanImports({ + files, + root: tempRoot, + mode: 'code', + languageOptions: {}, + importConcurrency: 1 +}); + +const importsByFile = scanResult.importsByFile || {}; +assert.deepEqual(importsByFile['cmake/main.cmake'] || [], ['modules/common.cmake']); +assert.deepEqual(importsByFile['nix/flake.nix'] || [], ['./modules']); +assert.deepEqual(importsByFile['python/app.py'] || [], ['os', 'runtime.pkg']); + +const relations = new Map( + Object.entries(importsByFile).map(([file, imports]) => [file, { imports: imports.slice() }]) +); +const resolution = resolveImportLinks({ + root: tempRoot, + entries, + importsByFile, + fileRelations: relations, + enableGraph: true +}); + +assert.deepEqual(relations.get('cmake/main.cmake')?.importLinks || [], ['cmake/modules/common.cmake']); +assert.deepEqual(relations.get('nix/flake.nix')?.importLinks || [], ['nix/modules/default.nix']); +assert.deepEqual(relations.get('python/app.py')?.importLinks || [], ['python/runtime/pkg.py']); +assert.equal((resolution?.graph?.warnings || []).length, 0, 'expected no unresolved warnings'); + +console.log('import scan non-js end-to-end test passed'); diff --git a/tests/indexing/imports/import-scan-post-graph-stats.test.js b/tests/indexing/imports/import-scan-post-graph-stats.test.js new file mode 100644 index 000000000..5d61a35de --- /dev/null +++ b/tests/indexing/imports/import-scan-post-graph-stats.test.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { postScanImports } from '../../../src/index/build/indexer/steps/relations/import-scan.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'import-scan-post-graph-stats'); +const srcRoot = path.join(tempRoot, 'src'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(srcRoot, { recursive: true }); +await fs.writeFile(path.join(srcRoot, 'main.js'), "import './missing.js';\n", 'utf8'); + +const state = new Map([['src/main.js', { imports: ['./missing.js'] }]]); +const stageState = { + fileRelations: state, + importResolutionGraph: null +}; +const timing = {}; +const result = await postScanImports({ + mode: 'code', + relationsEnabled: true, + scanPlan: { + importScanMode: 'pre', + enableImportLinks: true, + shouldScan: true, + importGraphEnabled: true + }, + state: stageState, + timing, + runtime: { + root: tempRoot, + toolInfo: { version: 'test' }, + indexingConfig: { + importResolution: {} + } + }, + entries: [ + { abs: path.join(srcRoot, 'main.js'), rel: 'src/main.js' } + ], + importResult: { + importsByFile: { + 'src/main.js': ['./missing.js'] + }, + durationMs: 0, + stats: null + }, + incrementalState: null, + fileTextByFile: null, + hangProbeConfig: null, + abortSignal: null +}); + +assert.equal(result?.unresolvedTaxonomy?.total, 1); +assert.equal(result?.unresolvedTaxonomy?.actionable, 1); +assert.equal(result?.unresolvedTaxonomy?.actionableUnresolvedRate, 1); +assert.equal(result?.unresolvedTaxonomy?.parserArtifactRate, 0); +assert.equal(result?.unresolvedTaxonomy?.resolverGapRate, 0); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.unresolvedTaxonomy?.resolverStages || {})), + { filesystem_probe: 1 } +); +assert.deepEqual( + result?.unresolvedTaxonomy?.actionableHotspots || [], + [{ importer: 'src/main.js', count: 1 }] +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.unresolvedTaxonomy?.actionableByLanguage || {})), + { js: 1 } +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedByReasonCode || {})), + { IMP_U_MISSING_FILE_RELATIVE: 1 } +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedByFailureCause || {})), + { missing_file: 1 } +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedByDisposition || {})), + { actionable: 1 } +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedByResolverStage || {})), + { filesystem_probe: 1 } +); +assert.deepEqual( + result?.stats?.unresolvedActionableHotspots || [], + [{ importer: 'src/main.js', count: 1 }] +); +assert.deepEqual( + Object.fromEntries(Object.entries(result?.stats?.unresolvedActionableByLanguage || {})), + { js: 1 } +); +assert.equal(result?.stats?.unresolvedGateEligible, 1); +assert.equal(result?.stats?.unresolvedActionableGateEligible, 1); +assert.equal(result?.stats?.unresolvedGateEligibleActionableRate, 1); +assert.equal(result?.stats?.unresolvedActionableRate, 1); +assert.equal(result?.stats?.unresolvedParserArtifactRate, 0); +assert.equal(result?.stats?.unresolvedResolverGapRate, 0); +assert.equal(result?.stats?.resolverBudgetPolicy?.adaptiveEnabled, true); +assert.equal(result?.stats?.resolverBudgetPolicy?.adaptiveProfile, 'normal'); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFilesystemProbesPerSpecifier, 32); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFallbackCandidatesPerSpecifier, 48); +assert.equal(result?.stats?.resolverBudgetPolicy?.maxFallbackDepth, 16); +assert.deepEqual( + Object.fromEntries(Object.entries(stageState?.importResolutionGraph?.stats?.unresolvedByResolverStage || {})), + { filesystem_probe: 1 } +); +assert.deepEqual( + stageState?.importResolutionGraph?.stats?.unresolvedActionableHotspots || [], + [{ importer: 'src/main.js', count: 1 }] +); +assert.deepEqual( + Object.fromEntries(Object.entries(stageState?.importResolutionGraph?.stats?.unresolvedActionableByLanguage || {})), + { js: 1 } +); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedGateEligible, 1); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedActionableGateEligible, 1); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedGateEligibleActionableRate, 1); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedActionableRate, 1); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedParserArtifactRate, 0); +assert.equal(stageState?.importResolutionGraph?.stats?.unresolvedResolverGapRate, 0); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.adaptiveEnabled, true); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.adaptiveProfile, 'normal'); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.maxFilesystemProbesPerSpecifier, 32); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.maxFallbackCandidatesPerSpecifier, 48); +assert.equal(stageState?.importResolutionGraph?.stats?.resolverBudgetPolicy?.maxFallbackDepth, 16); + +console.log('import scan post graph stats test passed'); diff --git a/tests/indexing/imports/imports-options-forwarding-flowmode.test.js b/tests/indexing/imports/imports-options-forwarding-flowmode.test.js deleted file mode 100644 index 2d03240cd..000000000 --- a/tests/indexing/imports/imports-options-forwarding-flowmode.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { collectLanguageImports } from '../../../src/index/language-registry.js'; - -const text = [ - "import type { Foo } from 'flow-lib';", - 'type Foo = { value: string };' -].join('\n'); - -const withFlow = collectLanguageImports({ - ext: '.js', - relPath: 'src/flow.js', - text, - mode: 'code', - options: { flowMode: 'on' } -}); - -assert.ok(withFlow.includes('flow-lib'), 'expected flow import with flowMode=on'); - -const withoutFlow = collectLanguageImports({ - ext: '.js', - relPath: 'src/flow.js', - text, - mode: 'code' -}); - -assert.ok(!withoutFlow.includes('flow-lib'), 'expected no flow imports without flowMode'); - -console.log('imports options forwarding flowmode test passed'); diff --git a/tests/indexing/imports/non-indexed-fallback-root-containment.test.js b/tests/indexing/imports/non-indexed-fallback-root-containment.test.js new file mode 100644 index 000000000..a3bcbb365 --- /dev/null +++ b/tests/indexing/imports/non-indexed-fallback-root-containment.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveImportLinks } from '../../../src/index/build/import-resolution.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', 'import-non-indexed-fallback-root-containment'); +const repoRoot = path.join(tempRoot, 'repo'); +const srcRoot = path.join(repoRoot, 'src'); +const outsideRoot = path.join(tempRoot, 'outside'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(srcRoot, { recursive: true }); +await fs.mkdir(outsideRoot, { recursive: true }); + +await fs.writeFile(path.join(srcRoot, 'main.js'), "import '../../outside/helper.js';\n"); +await fs.writeFile(path.join(outsideRoot, 'helper.js'), 'export const helper = true;\n'); + +const entries = [ + { abs: path.join(srcRoot, 'main.js'), rel: 'src/main.js' } +]; +const importsByFile = { + 'src/main.js': ['../../outside/helper.js'] +}; +const relations = new Map([ + ['src/main.js', { imports: ['../../outside/helper.js'] }] +]); + +const result = resolveImportLinks({ + root: repoRoot, + entries, + importsByFile, + fileRelations: relations, + enableGraph: false +}); +const rel = relations.get('src/main.js'); + +assert.deepEqual( + rel?.importLinks || [], + [], + 'expected escaped fallback probe to avoid local resolution' +); +assert.deepEqual( + rel?.externalImports || [], + [], + 'expected escaped fallback probe to avoid stable external classification' +); +assert.equal( + result?.stats?.unresolved || 0, + 1, + 'expected escaped fallback probe to remain unresolved' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('import non-indexed fallback root containment test passed'); diff --git a/tests/indexing/imports/path-utils-normalize-rel-path.test.js b/tests/indexing/imports/path-utils-normalize-rel-path.test.js new file mode 100644 index 000000000..477a75dc0 --- /dev/null +++ b/tests/indexing/imports/path-utils-normalize-rel-path.test.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { normalizeRelPath } from '../../../src/index/build/import-resolution/path-utils.js'; + +assert.equal(normalizeRelPath(''), ''); +assert.equal(normalizeRelPath('.'), ''); +assert.equal(normalizeRelPath('./src/app.ts'), 'src/app.ts'); +assert.equal(normalizeRelPath('../shared/util.ts'), '../shared/util.ts'); +assert.equal(normalizeRelPath('../../pkg/mod.ts'), '../../pkg/mod.ts'); +assert.equal(normalizeRelPath('src/../lib/index.ts'), 'lib/index.ts'); +assert.equal(normalizeRelPath('C:\\repo\\src\\main.ts').endsWith('repo/src/main.ts'), true); + +console.log('import path-utils normalizeRelPath test passed'); + diff --git a/tests/indexing/imports/prose-skip-imports.test.js b/tests/indexing/imports/prose-skip-imports.test.js deleted file mode 100644 index afd82f420..000000000 --- a/tests/indexing/imports/prose-skip-imports.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const cacheRoot = resolveTestCachePath(root, 'prose-skip-imports'); - -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; - -const result = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--mode', 'prose', '--repo', fixtureRoot], - { cwd: fixtureRoot, env, encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error('Failed: build_index prose mode'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -const stderr = result.stderr || ''; -if (stderr.includes('Scanning for imports')) { - console.error('Prose mode should skip import scanning, but imports log was present.'); - process.exit(1); -} - -console.log('Prose import scan skip test passed'); - diff --git a/tests/indexing/imports/prose-skip.test.js b/tests/indexing/imports/prose-skip.test.js new file mode 100644 index 000000000..9071b906a --- /dev/null +++ b/tests/indexing/imports/prose-skip.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const cacheRoot = resolveTestCachePath(root, 'prose-skip-imports'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub' +}); + +const result = runNode( + [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'prose', '--repo', fixtureRoot], + 'build_index prose mode', + fixtureRoot, + env, + { stdio: 'pipe' } +); + +if (result.status !== 0) { + console.error('Failed: build_index prose mode'); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); +} + +const stderr = result.stderr || ''; +if (stderr.includes('Scanning for imports')) { + console.error('Prose mode should skip import scanning, but imports log was present.'); + process.exit(1); +} + +console.log('Prose import scan skip test passed'); + diff --git a/tests/indexing/imports/imports-proto-safe-module-keys.test.js b/tests/indexing/imports/proto-safe-module-keys.test.js similarity index 100% rename from tests/indexing/imports/imports-proto-safe-module-keys.test.js rename to tests/indexing/imports/proto-safe-module-keys.test.js diff --git a/tests/indexing/imports/replay-corpus.test.js b/tests/indexing/imports/replay-corpus.test.js new file mode 100644 index 000000000..d09898456 --- /dev/null +++ b/tests/indexing/imports/replay-corpus.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS, + aggregateImportResolutionGraphPayloads, + discoverImportResolutionGraphReports, + loadImportResolutionGraphReports +} from '../../../src/index/build/import-resolution.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'import-resolution', 'replay-corpus'); + +const discovered = await discoverImportResolutionGraphReports({ + rootDir: fixtureRoot, + scanRoots: ['.'], + maxReports: 16 +}); +assert.equal(discovered.length, 3, 'expected three replay corpus graph files'); + +const loaded = await loadImportResolutionGraphReports(discovered); +const aggregated = aggregateImportResolutionGraphPayloads(loaded, { + excludedImporterSegments: DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS +}); + +assert.equal(aggregated.totals.reportCount, 2, 'expected two valid replay reports'); +assert.equal(aggregated.invalidReports.length, 1, 'expected one invalid replay report'); +assert.equal(aggregated.totals.unresolved, 10, 'expected unresolved counts from warning/stats replay'); +assert.equal(aggregated.totals.actionable, 3, 'expected actionable counts from warning/stats replay'); +assert.equal(aggregated.totals.parserArtifact, 1, 'expected parser-artifact replay count'); +assert.equal(aggregated.totals.resolverGap, 3, 'expected resolver-gap replay count'); +assert.equal(aggregated.totals.resolverBudgetExhausted, 2, 'expected budget-exhausted replay count'); +assert.equal(aggregated.totals.resolverBudgetAdaptiveReports, 1, 'expected one adaptive budget report'); +assert.deepEqual( + aggregated.actionableByRepo, + { + 'repo-alpha': 1, + 'repo-beta': 2 + }, + 'expected actionable repo hotspot counts' +); +assert.deepEqual( + aggregated.actionableByLanguage, + { + ts: 2 + }, + 'expected actionable language hotspot counts' +); +assert.deepEqual( + aggregated.actionableHotspots, + [ + { importer: 'src/service.ts', count: 2 }, + { importer: 'src/main.ts', count: 1 } + ], + 'expected actionable importer hotspot aggregation' +); +assert.deepEqual( + aggregated.resolverBudgetPolicyProfiles, + { fd_pressure: 1, normal: 1 }, + 'expected replay budget profiles from mixed stats payloads' +); + +console.log('import-resolution replay corpus test passed'); diff --git a/tests/indexing/imports/replay-harness-gate-eligible-stats.test.js b/tests/indexing/imports/replay-harness-gate-eligible-stats.test.js new file mode 100644 index 000000000..a52e96245 --- /dev/null +++ b/tests/indexing/imports/replay-harness-gate-eligible-stats.test.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { aggregateImportResolutionGraphPayloads } from '../../../src/index/build/import-resolution.js'; + +const aggregated = aggregateImportResolutionGraphPayloads([ + { + reportPath: 'repo-gamma/import_resolution_graph.json', + payload: { + generatedAt: new Date().toISOString(), + stats: { + unresolved: 100, + unresolvedObserved: 120, + unresolvedActionable: 40, + unresolvedGateEligible: 4, + unresolvedActionableGateEligible: 1, + unresolvedByFailureCause: { + parser_artifact: 90, + resolver_gap: 30 + } + }, + warnings: [] + } + } +]); + +assert.equal(aggregated.totals.reportCount, 1, 'expected one replay report'); +assert.equal(aggregated.totals.unresolved, 4, 'expected gate-eligible unresolved stats precedence'); +assert.equal(aggregated.totals.actionable, 1, 'expected gate-eligible actionable stats precedence'); +assert.equal(aggregated.totals.observedUnresolved, 120, 'expected observed unresolved totals to honor unresolvedObserved'); +assert.equal(aggregated.totals.gateEligibleUnresolved, 4, 'expected gate-eligible unresolved totals'); +assert.equal(aggregated.totals.gateEligibleActionable, 1, 'expected gate-eligible actionable totals'); +assert.equal(aggregated.totals.parserArtifact, 0, 'expected parser artifact totals to stay gate-domain aligned'); +assert.equal(aggregated.totals.resolverGap, 0, 'expected resolver gap totals to stay gate-domain aligned'); +assert.deepEqual( + aggregated.actionableByRepo, + { 'repo-gamma': 1 }, + 'expected actionable repo rollup to align with gate-eligible actionable totals' +); + +const clamped = aggregateImportResolutionGraphPayloads([ + { + reportPath: 'repo-overflow/import_resolution_graph.json', + payload: { + generatedAt: new Date().toISOString(), + stats: { + unresolvedGateEligible: 2, + unresolvedActionableGateEligible: 5 + }, + warnings: [] + } + } +]); +assert.equal(clamped.totals.unresolved, 2, 'expected unresolved totals to preserve gate-eligible unresolved counts'); +assert.equal(clamped.totals.actionable, 2, 'expected actionable totals to clamp at unresolved counts'); +assert.equal(clamped.totals.gateEligibleUnresolved, 2, 'expected gate-eligible unresolved totals'); +assert.equal(clamped.totals.gateEligibleActionable, 2, 'expected clamped gate-eligible actionable totals'); + +console.log('import-resolution replay harness gate-eligible stats test passed'); diff --git a/tests/indexing/imports/replay-harness.test.js b/tests/indexing/imports/replay-harness.test.js new file mode 100644 index 000000000..28e1cf2ed --- /dev/null +++ b/tests/indexing/imports/replay-harness.test.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + aggregateImportResolutionGraphPayloads, + aggregateImportResolutionGraphReportPaths, + DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS, + discoverImportResolutionGraphReports, + loadImportResolutionGraphReports +} from '../../../src/index/build/import-resolution.js'; + +const reports = [ + { + reportPath: 'repo-a/import_resolution_graph.json', + payload: { + generatedAt: new Date().toISOString(), + stats: { + unresolvedByAdapter: { + 'bazel-label': 1 + }, + unresolvedByResolverStage: { + filesystem_probe: 2, + fake_stage: 5 + }, + resolverPipelineStages: { + fake_stage: { + attempts: 99, + hits: 99, + misses: 0, + elapsedMs: 99, + budgetExhausted: 99, + degraded: 99 + }, + language_resolver: { + attempts: 2, + hits: 1, + misses: 1, + elapsedMs: 1.25, + budgetExhausted: 1, + degraded: 2, + reasonCodes: { + IMP_U_RESOLVER_GAP: 2, + IMP_U_NOT_REAL: 3 + } + } + } + }, + warnings: [ + { + importer: 'src\\main.js', + specifier: './missing.js', + reason: 'missing', + resolutionState: 'unresolved', + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable', + resolverStage: 'filesystem_probe' + }, + { + importer: 'src/parser.js', + specifier: './fixture.txt', + reason: 'fixture', + resolutionState: 'unresolved', + reasonCode: 'IMP_U_FIXTURE_REFERENCE', + failureCause: 'parser_artifact', + disposition: 'suppress_live', + resolverStage: 'classify' + }, + { + importer: 'src/build.bzl', + specifier: '//pkg:generated_target', + reason: 'resolver gap', + resolutionState: 'unresolved', + reasonCode: 'IMP_U_RESOLVER_GAP', + failureCause: 'resolver_gap', + disposition: 'suppress_gate', + resolverStage: 'language_resolver', + resolverAdapter: 'bazel-label' + }, + { + importer: 'tests/integration.spec.js', + specifier: './missing-in-tests.js', + reason: 'test-only', + resolutionState: 'unresolved', + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable', + resolverStage: 'filesystem_probe' + } + ] + } + }, + { + reportPath: 'repo-c/import_resolution_graph.json', + payload: { + generatedAt: new Date().toISOString(), + stats: { + unresolved: 5, + unresolvedActionable: 3, + unresolvedActionableByLanguage: { + py: 3 + } + }, + warnings: [] + } + }, + { + reportPath: 'repo-b/import_resolution_graph.json', + payload: null + } +]; + +const aggregated = aggregateImportResolutionGraphPayloads(reports, { + excludedImporterSegments: DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS +}); + +assert.equal(aggregated.totals.reportCount, 2, 'expected two valid reports'); +assert.equal(aggregated.invalidReports.length, 1, 'expected one invalid report'); +assert.equal(aggregated.totals.unresolved, 8, 'expected excluded test importer warning to be removed from gate counts'); +assert.equal(aggregated.totals.actionable, 4, 'expected actionable unresolved totals from warnings + stats'); +assert.equal(aggregated.totals.parserArtifact, 1, 'expected parser artifact count to be replayed'); +assert.equal(aggregated.totals.resolverGap, 1, 'expected resolver gap count to be replayed'); +assert.equal(aggregated.reasonCodeCounts.IMP_U_MISSING_FILE_RELATIVE, 2, 'expected reason code counts to include excluded warning'); +assert.equal(aggregated.resolverAdapters['bazel-label'], 1, 'expected resolver adapter counts to aggregate'); +assert.equal(aggregated.resolverStages.filesystem_probe, 2, 'expected stage counts to include all observed warnings'); +assert.equal( + Object.prototype.hasOwnProperty.call(aggregated.resolverStages, 'fake_stage'), + false, + 'expected unknown resolver stages to be ignored' +); +assert.deepEqual( + aggregated.resolverPipelineStages, + Object.assign(Object.create(null), { + language_resolver: { + attempts: 2, + hits: 1, + misses: 1, + elapsedMs: 1.25, + budgetExhausted: 1, + degraded: 2, + reasonCodes: { + IMP_U_RESOLVER_GAP: 2 + } + } + }), + 'expected resolver stage pipeline metrics to aggregate with budget/degraded counters' +); +assert.deepEqual( + aggregated.resolverPipelineStagePercentiles, + { + language_resolver: { + samples: 1, + max: 1.25, + p50: 1.25, + p95: 1.25, + p99: 1.25 + } + }, + 'expected resolver stage pipeline elapsed percentiles to aggregate' +); +assert.deepEqual( + aggregated.actionableByRepo, + { 'repo-a': 1, 'repo-c': 3 }, + 'expected actionable repo hotspot rollup' +); +assert.deepEqual( + aggregated.actionableByLanguage, + { js: 1, py: 3 }, + 'expected actionable language hotspot rollup with stats override' +); +assert.deepEqual( + aggregated.actionableHotspots, + [{ importer: 'src/main.js', count: 1 }], + 'expected actionable hotspot importer path normalization during replay' +); + +const exclusionAwareFallback = aggregateImportResolutionGraphPayloads([ + { + reportPath: 'repo-exclusion/import_resolution_graph.json', + payload: { + stats: { + unresolved: 99, + unresolvedActionable: 99 + }, + warnings: [ + { + importer: 'src/feature.js', + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable' + }, + { + importer: 'tests/feature.test.js', + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable' + } + ] + } + } +], { + excludedImporterSegments: DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS +}); +assert.equal( + exclusionAwareFallback.totals.gateEligibleUnresolved, + 1, + 'expected gate-eligible unresolved totals to defer to warning-level eligibility when gate totals are absent' +); +assert.equal( + exclusionAwareFallback.totals.gateEligibleActionable, + 1, + 'expected gate-eligible actionable totals to defer to warning-level eligibility when gate totals are absent' +); + +const replayRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-import-replay-harness-')); +try { + const reportPathA = path.join(replayRoot, '.benchCache', 'repo-c', 'import_resolution_graph.json'); + const reportPathB = path.join(replayRoot, '.testCache', 'repo-d', 'import_resolution_graph.json'); + const reportPathIgnored = path.join(replayRoot, '.testCache', 'repo-d', 'not_import_graph.json'); + await fs.mkdir(path.dirname(reportPathA), { recursive: true }); + await fs.mkdir(path.dirname(reportPathB), { recursive: true }); + await fs.writeFile(reportPathA, JSON.stringify({ generatedAt: new Date().toISOString() }, null, 2)); + await fs.writeFile(reportPathB, '{'); + await fs.writeFile(reportPathIgnored, 'noop'); + + const discovered = await discoverImportResolutionGraphReports({ + rootDir: replayRoot, + maxReports: 8 + }); + assert.deepEqual( + discovered.map((entry) => entry.replace(/\\/g, '/')), + [reportPathA, reportPathB].map((entry) => entry.replace(/\\/g, '/')).sort(), + 'expected replay report discovery to only include import_resolution_graph.json files' + ); + + const loaded = await loadImportResolutionGraphReports(discovered); + assert.equal(loaded.length, 2, 'expected two loaded replay reports'); + assert.equal( + loaded.filter((entry) => entry.payload && typeof entry.payload === 'object').length, + 1, + 'expected one valid replay report payload' + ); + assert.equal( + loaded.filter((entry) => entry.payload == null).length, + 1, + 'expected one invalid replay report payload' + ); + + const streamedAggregated = await aggregateImportResolutionGraphReportPaths(discovered, { + excludedImporterSegments: DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS + }); + const loadedAggregated = aggregateImportResolutionGraphPayloads(loaded, { + excludedImporterSegments: DEFAULT_GATE_EXCLUDED_IMPORTER_SEGMENTS + }); + assert.deepEqual( + streamedAggregated, + loadedAggregated, + 'expected streaming replay aggregation to match loaded-report aggregation' + ); +} finally { + await fs.rm(replayRoot, { recursive: true, force: true }); +} + +console.log('import-resolution replay harness test passed'); diff --git a/tests/indexing/imports/replay-perf-budget.test.js b/tests/indexing/imports/replay-perf-budget.test.js new file mode 100644 index 000000000..333cd3e53 --- /dev/null +++ b/tests/indexing/imports/replay-perf-budget.test.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { aggregateImportResolutionGraphPayloads } from '../../../src/index/build/import-resolution.js'; + +const makeWarning = (index) => { + const id = index % 7; + if (id === 0) { + return { + importer: `src/feature${index % 13}.ts`, + specifier: `./missing-${index % 11}.ts`, + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable', + resolverStage: 'filesystem_probe', + resolutionState: 'unresolved' + }; + } + if (id === 1) { + return { + importer: `tests/fixtures/f${index % 9}.spec.ts`, + specifier: `./fixture-${index % 5}.ts`, + reasonCode: 'IMP_U_FIXTURE_REFERENCE', + failureCause: 'parser_artifact', + disposition: 'suppress_live', + resolverStage: 'classify', + resolutionState: 'unresolved' + }; + } + if (id === 2) { + return { + importer: `build/defs${index % 5}.bzl`, + specifier: `//pkg:target-${index % 6}.bzl`, + reasonCode: 'IMP_U_RESOLVER_GAP', + failureCause: 'resolver_gap', + disposition: 'suppress_gate', + resolverStage: 'language_resolver', + resolutionState: 'unresolved' + }; + } + return { + importer: `src/mod${index % 17}.js`, + specifier: `pkg-${index % 15}`, + reasonCode: 'IMP_U_MISSING_DEPENDENCY_PACKAGE', + failureCause: 'missing_dependency', + disposition: 'actionable', + resolverStage: 'language_resolver', + resolutionState: 'unresolved' + }; +}; + +const buildSyntheticReports = ({ reportCount = 320, warningsPerReport = 180 } = {}) => { + const reports = []; + for (let reportIndex = 0; reportIndex < reportCount; reportIndex += 1) { + const warnings = []; + for (let warningIndex = 0; warningIndex < warningsPerReport; warningIndex += 1) { + warnings.push(makeWarning(reportIndex * warningsPerReport + warningIndex)); + } + reports.push({ + reportPath: `.benchCache/replay-${reportIndex}/import_resolution_graph.json`, + payload: { + generatedAt: new Date(1700000000000 + reportIndex).toISOString(), + stats: { + unresolved: warnings.length, + unresolvedActionable: warnings.filter((entry) => entry.disposition === 'actionable').length, + unresolvedByFailureCause: { + missing_file: warnings.filter((entry) => entry.failureCause === 'missing_file').length, + missing_dependency: warnings.filter((entry) => entry.failureCause === 'missing_dependency').length, + parser_artifact: warnings.filter((entry) => entry.failureCause === 'parser_artifact').length, + resolver_gap: warnings.filter((entry) => entry.failureCause === 'resolver_gap').length + }, + unresolvedByResolverStage: { + filesystem_probe: warnings.filter((entry) => entry.resolverStage === 'filesystem_probe').length, + language_resolver: warnings.filter((entry) => entry.resolverStage === 'language_resolver').length, + classify: warnings.filter((entry) => entry.resolverStage === 'classify').length + }, + unresolvedByReasonCode: { + IMP_U_MISSING_FILE_RELATIVE: warnings.filter((entry) => entry.reasonCode === 'IMP_U_MISSING_FILE_RELATIVE').length, + IMP_U_MISSING_DEPENDENCY_PACKAGE: warnings.filter((entry) => entry.reasonCode === 'IMP_U_MISSING_DEPENDENCY_PACKAGE').length, + IMP_U_FIXTURE_REFERENCE: warnings.filter((entry) => entry.reasonCode === 'IMP_U_FIXTURE_REFERENCE').length, + IMP_U_RESOLVER_GAP: warnings.filter((entry) => entry.reasonCode === 'IMP_U_RESOLVER_GAP').length + }, + resolverPipelineStages: { + normalize: { + attempts: warnings.length, + hits: warnings.length, + misses: 0, + elapsedMs: 10 + (reportIndex % 3), + budgetExhausted: 0, + degraded: 0 + }, + language_resolver: { + attempts: warnings.length, + hits: Math.floor(warnings.length * 0.6), + misses: Math.ceil(warnings.length * 0.4), + elapsedMs: 15 + (reportIndex % 7), + budgetExhausted: reportIndex % 4 === 0 ? 1 : 0, + degraded: reportIndex % 5 === 0 ? 2 : 0 + } + }, + resolverBudgetPolicy: { + adaptiveEnabled: true, + adaptiveProfile: reportIndex % 2 === 0 ? 'capacity-headroom' : 'normal' + } + }, + warnings + } + }); + } + return reports; +}; + +const reports = buildSyntheticReports(); +const startedA = Date.now(); +const aggregatedA = aggregateImportResolutionGraphPayloads(reports); +const elapsedA = Date.now() - startedA; + +const startedB = Date.now(); +const aggregatedB = aggregateImportResolutionGraphPayloads(reports); +const elapsedB = Date.now() - startedB; + +assert.deepEqual(aggregatedB, aggregatedA, 'replay aggregation should be deterministic across repeated runs'); +assert.equal(aggregatedA.invalidReports.length, 0, 'expected no invalid synthetic reports'); +assert.equal(aggregatedA.totals.reportCount, reports.length, 'expected report count parity'); +assert.equal(aggregatedA.totals.unresolved > 0, true, 'expected unresolved totals from replay'); +assert.equal(aggregatedA.totals.actionable > 0, true, 'expected actionable totals from replay'); +assert.equal( + Number(aggregatedA?.resolverPipelineStagePercentiles?.language_resolver?.p95) > 0, + true, + 'expected resolver pipeline stage percentile aggregation' +); + +const maxAllowedElapsedMs = 8000; +assert.equal( + elapsedA <= maxAllowedElapsedMs, + true, + `expected replay aggregation to stay within ${maxAllowedElapsedMs}ms budget (first run took ${elapsedA}ms)` +); +assert.equal( + elapsedB <= maxAllowedElapsedMs, + true, + `expected replay aggregation to stay within ${maxAllowedElapsedMs}ms budget (second run took ${elapsedB}ms)` +); + +console.log( + `import-resolution replay perf budget test passed (first=${elapsedA}ms, second=${elapsedB}ms, reports=${reports.length})` +); diff --git a/tests/indexing/imports/replay-stage-percentiles.test.js b/tests/indexing/imports/replay-stage-percentiles.test.js new file mode 100644 index 000000000..4b303dc27 --- /dev/null +++ b/tests/indexing/imports/replay-stage-percentiles.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { aggregateImportResolutionGraphPayloads } from '../../../src/index/build/import-resolution.js'; + +const makeReport = (elapsedMs, reportIndex) => ({ + reportPath: `repo-${reportIndex}/import_resolution_graph.json`, + payload: { + generatedAt: new Date(1700000000000 + reportIndex).toISOString(), + stats: { + unresolved: 1, + unresolvedActionable: 1, + resolverPipelineStages: { + language_resolver: { + attempts: 1, + hits: 1, + misses: 0, + elapsedMs, + budgetExhausted: 0, + degraded: 0 + } + } + }, + warnings: [ + { + importer: 'src/main.js', + specifier: './missing.js', + reasonCode: 'IMP_U_MISSING_FILE_RELATIVE', + failureCause: 'missing_file', + disposition: 'actionable', + resolverStage: 'filesystem_probe', + resolutionState: 'unresolved' + } + ] + } +}); + +const aggregated = aggregateImportResolutionGraphPayloads([ + makeReport(10, 0), + makeReport(20, 1), + makeReport(30, 2), + makeReport(40, 3) +]); + +assert.deepEqual( + aggregated?.resolverPipelineStagePercentiles?.language_resolver, + { + samples: 4, + max: 40, + p50: 25, + p95: 38.5, + p99: 39.7 + }, + 'expected deterministic stage elapsed percentiles' +); + +console.log('import-resolution replay stage percentiles test passed'); diff --git a/tests/indexing/imports/imports-require-regex-fallback-on-lexer-failure.test.js b/tests/indexing/imports/require-regex-fallback-on-lexer-failure.test.js similarity index 100% rename from tests/indexing/imports/imports-require-regex-fallback-on-lexer-failure.test.js rename to tests/indexing/imports/require-regex-fallback-on-lexer-failure.test.js diff --git a/tests/indexing/imports/stage-pipeline-metrics.test.js b/tests/indexing/imports/stage-pipeline-metrics.test.js new file mode 100644 index 000000000..caf12b94c --- /dev/null +++ b/tests/indexing/imports/stage-pipeline-metrics.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + formatResolverPipelineStageSummary, + resolveResolverPipelineStageHighlights +} from '../../../src/index/build/import-resolution.js'; + +const stages = { + fake_stage: { attempts: 100, hits: 100, misses: 0, elapsedMs: 100, budgetExhausted: 100, degraded: 100 }, + normalize: { attempts: 4, hits: 4, misses: 0, elapsedMs: 3.25, budgetExhausted: 0, degraded: 0 }, + language_resolver: { attempts: 2, hits: 1, misses: 1, elapsedMs: 2.5, budgetExhausted: 0, degraded: 1 }, + filesystem_probe: { attempts: 1, hits: 0, misses: 1, elapsedMs: 1.5, budgetExhausted: 2, degraded: 1 }, + classify: { attempts: 1, hits: 1, misses: 0, elapsedMs: 0.5, budgetExhausted: 0, degraded: 0 } +}; + +assert.equal( + formatResolverPipelineStageSummary(stages), + [ + 'classify=a1/h1/m0/b0/d0/t0.500ms', + 'filesystem_probe=a1/h0/m1/b2/d1/t1.500ms', + 'language_resolver=a2/h1/m1/b0/d1/t2.500ms', + 'normalize=a4/h4/m0/b0/d0/t3.250ms' + ].join(', '), + 'expected stable resolver pipeline summary formatting' +); + +assert.deepEqual( + resolveResolverPipelineStageHighlights(stages), + { + topByElapsed: { stage: 'normalize', elapsedMs: 3.25 }, + topByBudgetExhausted: { stage: 'filesystem_probe', budgetExhausted: 2 }, + topByDegraded: { stage: 'filesystem_probe', degraded: 1 } + }, + 'expected resolver pipeline highlights to reflect elapsed/budget/degraded leaders' +); + +assert.equal( + formatResolverPipelineStageSummary({}), + 'none', + 'expected empty summary for empty stage pipeline map' +); +assert.deepEqual( + resolveResolverPipelineStageHighlights({}), + { + topByElapsed: null, + topByBudgetExhausted: null, + topByDegraded: null + }, + 'expected empty highlights for empty stage pipeline map' +); + +console.log('import resolution stage pipeline metrics test passed'); diff --git a/tests/indexing/imports/imports-swift-registry-bridge.test.js b/tests/indexing/imports/swift-registry-bridge.test.js similarity index 100% rename from tests/indexing/imports/imports-swift-registry-bridge.test.js rename to tests/indexing/imports/swift-registry-bridge.test.js diff --git a/tests/indexing/imports/warm-lookup-structure-reuse.test.js b/tests/indexing/imports/warm-lookup-structure-reuse.test.js index b6ccd9e6e..64c5821d6 100644 --- a/tests/indexing/imports/warm-lookup-structure-reuse.test.js +++ b/tests/indexing/imports/warm-lookup-structure-reuse.test.js @@ -25,7 +25,24 @@ const baseRelations = () => new Map([ const cache = { files: {} }; const normalizeGraph = (graph) => { if (!graph || typeof graph !== 'object') return graph; - return { ...graph, generatedAt: null }; + return { + ...graph, + generatedAt: null, + stats: normalizeStats(graph.stats) + }; +}; +const normalizeStats = (stats) => { + if (!stats || typeof stats !== 'object') return stats; + const pipeline = Object.fromEntries( + Object.entries(stats.resolverPipelineStages || {}).map(([stage, value]) => [ + stage, + { ...value, elapsedMs: 0 } + ]) + ); + return { + ...stats, + resolverPipelineStages: pipeline + }; }; const firstRelations = baseRelations(); const first = resolveImportLinks({ @@ -48,7 +65,11 @@ const warm = resolveImportLinks({ enableGraph: true }); assert.equal(warm.cacheStats.lookupReused, true, 'warm run should reuse persisted lookup snapshot'); -assert.deepEqual(warm.stats, first.stats, 'warm lookup reuse should preserve import resolution stats'); +assert.deepEqual( + normalizeStats(warm.stats), + normalizeStats(first.stats), + 'warm lookup reuse should preserve import resolution stats' +); assert.deepEqual( normalizeGraph(warm.graph), normalizeGraph(first.graph), diff --git a/tests/indexing/imports/warning-dispositions.test.js b/tests/indexing/imports/warning-dispositions.test.js new file mode 100644 index 000000000..8a3a5f4c3 --- /dev/null +++ b/tests/indexing/imports/warning-dispositions.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { + isActionableImportWarning, + isParserArtifactImportWarning, + isResolverGapImportWarning, + summarizeImportWarningDispositions +} from '../../../src/index/build/import-resolution/disposition.js'; + +assert.equal(isActionableImportWarning({ disposition: 'actionable' }), true); +assert.equal(isActionableImportWarning({ disposition: 'suppress_live' }), false); +assert.equal(isActionableImportWarning({ disposition: ' suppress_gate ' }), false); + +assert.equal(isParserArtifactImportWarning({ failureCause: 'parser_artifact' }), true); +assert.equal(isResolverGapImportWarning({ failureCause: 'resolver_gap' }), true); + +assert.equal( + isParserArtifactImportWarning({ category: 'parser_artifact' }), + false, + 'category-only payloads should not count in hard-cut failure-cause accounting' +); + +const summary = summarizeImportWarningDispositions([ + { disposition: 'actionable', failureCause: 'missing_file' }, + { disposition: 'suppress_live', failureCause: 'parser_artifact' }, + { disposition: 'suppress_gate', failureCause: 'resolver_gap' }, + { disposition: 'actionable', category: 'resolver_gap' } +]); + +assert.deepEqual(summary, { + actionable: 2, + parserArtifact: 1, + resolverGap: 1 +}); + +console.log('import warning disposition helpers test passed'); diff --git a/tests/indexing/incremental/bundle-checksum-schema-cutover.test.js b/tests/indexing/incremental/bundle-checksum-schema-cutover.test.js new file mode 100644 index 000000000..06c595323 --- /dev/null +++ b/tests/indexing/incremental/bundle-checksum-schema-cutover.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { BUNDLE_CHECKSUM_SCHEMA_VERSION } from '../../../src/shared/bundle-io-constants.js'; +import { SIGNATURE_VERSION } from '../../../src/index/build/indexer/signatures.js'; +import { loadIncrementalState } from '../../../src/index/build/incremental/planning.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `bundle-checksum-schema-cutover-${process.pid}-${Date.now()}`); +const repoCacheRoot = path.join(tempRoot, 'repo-cache'); +const incrementalDir = path.join(repoCacheRoot, 'incremental', 'code'); +const manifestPath = path.join(incrementalDir, 'manifest.json'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(incrementalDir, { recursive: true }); +await fs.writeFile(manifestPath, JSON.stringify({ + version: 5, + signatureVersion: SIGNATURE_VERSION, + mode: 'code', + tokenizationKey: null, + cacheSignature: null, + signatureSummary: null, + bundleFormat: 'json', + files: { + 'src/index.js': { + hash: 'abc', + mtimeMs: 1, + size: 1, + bundles: ['deadbeef.json'] + } + }, + shards: null +}, null, 2)); + +const logs = []; +try { + const incremental = await loadIncrementalState({ + repoCacheRoot, + mode: 'code', + enabled: true, + tokenizationKey: null, + cacheSignature: null, + cacheSignatureSummary: null, + bundleFormat: 'json', + log: (message) => logs.push(message) + }); + assert.equal(incremental.manifest.bundleChecksumSchemaVersion, BUNDLE_CHECKSUM_SCHEMA_VERSION); + assert.deepEqual( + incremental.manifest.files, + {}, + 'expected checksum-schema cutover to invalidate stale incremental bundle entries' + ); + assert.ok( + logs.some((line) => line.includes('bundle checksum schema mismatch')), + 'expected checksum schema reset log message' + ); + console.log('incremental bundle checksum schema cutover test passed'); +} finally { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} + diff --git a/tests/indexing/incremental/bundle-mapping-reasons.test.js b/tests/indexing/incremental/bundle-mapping-reasons.test.js new file mode 100644 index 000000000..351381c5f --- /dev/null +++ b/tests/indexing/incremental/bundle-mapping-reasons.test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + normalizeBundleFormat, + resolveBundleFormatFromName +} from '../../../src/shared/bundle-io-paths.js'; +import { + readBundleFile, + writeBundleFile +} from '../../../src/shared/bundle-io.js'; +import { getIncrementalPaths } from '../../../src/storage/sqlite/incremental.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { setupIncrementalRepo } from '../../helpers/sqlite-incremental.js'; + +const { root, repoRoot, env, userConfig, run, runCapture } = await setupIncrementalRepo({ + name: 'incremental-bundle-mapping-reasons' +}); + +const buildIndexPath = path.join(root, 'build_index.js'); + +run( + [ + buildIndexPath, + '--incremental', + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage2', + '--no-sqlite', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'stage2 build', + { cwd: repoRoot, env, stdio: 'inherit' } +); + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const incrementalPaths = getIncrementalPaths(repoCacheRoot, 'code'); +const manifestPath = incrementalPaths.manifestPath; +const bundleDir = incrementalPaths.bundleDir; +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const firstManifestFile = Object.keys(manifest.files || {})[0]; +assert.ok(firstManifestFile, 'expected at least one manifest file'); +const firstEntry = manifest.files[firstManifestFile]; +assert.ok(Array.isArray(firstEntry?.bundles) && firstEntry.bundles.length, 'expected manifest bundle entry'); + +const bundleFormat = normalizeBundleFormat(manifest.bundleFormat); +const sourceBundleName = firstEntry.bundles[0]; +const sourceBundlePath = path.join(bundleDir, sourceBundleName); +const sourceRead = await readBundleFile(sourceBundlePath, { + format: resolveBundleFormatFromName(sourceBundleName, bundleFormat) +}); +assert.equal(sourceRead.ok, true, 'expected source bundle read to succeed'); + +const brokenChunk = { + text: 'intentionally unmappable chunk', + kind: 'paragraph', + id: null, + start: null, + end: null, + embedding_u8: null, + embedding: null, + segment: null, + metaV2: null, + file: null, + docmeta: null +}; +const brokenBundle = { + ...(sourceRead.bundle || {}), + file: firstManifestFile, + chunks: [brokenChunk] +}; + +const sourceExt = path.extname(sourceBundleName) || '.json'; +const brokenBundleName = `broken-no-parent${sourceExt}`; +const brokenBundlePath = path.join(bundleDir, brokenBundleName); +await writeBundleFile({ + bundlePath: brokenBundlePath, + bundle: brokenBundle, + format: resolveBundleFormatFromName(brokenBundleName, bundleFormat) +}); + +manifest.files['phantom/no-parent.js'] = { + ...firstEntry, + bundles: [brokenBundleName] +}; +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + +const stage3Result = runCapture( + [ + buildIndexPath, + '--incremental', + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage3', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'stage3 build' +); + +const output = `${stage3Result.stdout || ''}\n${stage3Result.stderr || ''}`; +assert.match( + output, + /embedding coverage .*skipped noMapping=1, noMappingChunks=1, noMappingReasons=boundaryMismatch:0\|missingParent:1\|parserOmission:0\)\./i, + 'expected embedding refresh to report one missing-parent incremental bundle mapping' +); + +console.log('incremental bundle mapping reasons test passed'); diff --git a/tests/indexing/incremental/bundles-updated-after-metav2-finalize.test.js b/tests/indexing/incremental/bundles-updated-after-metav2-finalize.test.js new file mode 100644 index 000000000..0d946a815 --- /dev/null +++ b/tests/indexing/incremental/bundles-updated-after-metav2-finalize.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const orchestratorPath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline', 'orchestrator.js'); +const source = fs.readFileSync(orchestratorPath, 'utf8'); + +const writeCall = 'await writeIndexArtifactsForMode('; +const updateCall = 'await updateIncrementalBundles('; + +const writeIndex = source.indexOf(writeCall); +const updateIndex = source.indexOf(updateCall); + +if (writeIndex < 0) { + console.error(`Expected orchestrator to contain "${writeCall}".`); + process.exit(1); +} +if (updateIndex < 0) { + console.error(`Expected orchestrator to contain "${updateCall}".`); + process.exit(1); +} +if (updateIndex <= writeIndex) { + console.error( + 'Expected incremental bundle refresh to run after artifact write/finalization ' + + '(prevents stale metaV2 in incremental bundles).' + ); + process.exit(1); +} + +console.log('incremental bundle update ordering against metaV2 finalization ok.'); diff --git a/tests/indexing/incremental/cache-signature.test.js b/tests/indexing/incremental/cache-signature.test.js new file mode 100644 index 000000000..a7a3fc5ea --- /dev/null +++ b/tests/indexing/incremental/cache-signature.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getRepoId } from '../../../tools/shared/dict-utils.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-cache-signature'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +const filePath = path.join(repoRoot, 'src.js'); +await fsPromises.writeFile(filePath, 'function alpha() { return 1; }\n'); + +const buildTestEnv = (testConfig) => applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: testConfig ?? null, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +const runBuild = (label, testConfig) => { + const result = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage2', + '--mode', + 'code', + '--scm-provider', + 'none', + '--incremental', + '--repo', + repoRoot + ], + label, + repoRoot, + buildTestEnv(testConfig), + { stdio: 'inherit', allowFailure: true } + ); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +runBuild('initial build', { indexing: { lint: false } }); +const repoId = getRepoId(repoRoot); +const manifestPath = path.join(cacheRoot, 'repos', repoId, 'incremental', 'code', 'manifest.json'); +if (!fs.existsSync(manifestPath)) { + console.error('Missing incremental manifest after initial build'); + process.exit(1); +} +const manifestInitial = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); +runBuild('cache build', { indexing: { lint: false } }); +const manifestCached = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); +if (!manifestCached?.cacheSignature || manifestCached.cacheSignature !== manifestInitial?.cacheSignature) { + console.error('Expected unchanged incremental cache signature for identical config rebuild'); + process.exit(1); +} + +runBuild('config signature rebuild', { indexing: { lint: true } }); +const manifestChanged = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); +if (!manifestChanged?.cacheSignature || manifestChanged.cacheSignature === manifestCached.cacheSignature) { + console.error('Expected incremental cache signature change after config signature change'); + process.exit(1); +} + +console.log('incremental cache signature test passed'); diff --git a/tests/indexing/incremental/crossfile-bundle-metav2-rewrite.test.js b/tests/indexing/incremental/crossfile-bundle-metav2-rewrite.test.js new file mode 100644 index 000000000..81a8f4f72 --- /dev/null +++ b/tests/indexing/incremental/crossfile-bundle-metav2-rewrite.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { updateBundlesWithChunks, writeIncrementalBundle } from '../../../src/index/build/incremental.js'; +import { resolveBundleFormatFromName } from '../../../src/shared/bundle-io-paths.js'; +import { readBundleFile } from '../../../src/shared/bundle-io.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' + } +}); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-metav2-rewrite'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const relKey = 'src/meta-target.swift'; +const fileStat = { mtimeMs: 1700000000000, size: 321 }; +const chunkBase = { + file: relKey, + id: 0, + chunkId: 'chunk:0', + text: 'stable body' +}; +const oldMetaV2 = { + chunkId: 'chunk:0', + file: relKey, + range: { start: 1, end: 3 }, + lang: 'swift', + ext: '.swift', + relations: { + calls: [{ targetChunkId: 'chunk:old' }] + } +}; +const nextMetaV2 = { + chunkId: 'chunk:0', + file: relKey, + range: { start: 1, end: 3 }, + lang: 'swift', + ext: '.swift', + relations: { + calls: [{ targetChunkId: 'chunk:new' }] + } +}; + +const entry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat, + fileHash: 'hash:meta', + fileChunks: [{ ...chunkBase, metaV2: oldMetaV2 }], + fileRelations: { imports: ['./dep.swift'] }, + vfsManifestRows: [{ virtualPath: '/vfs/src/meta-target.swift', languageId: 'swift' }], + bundleFormat: 'json' +}); +assert.ok(entry, 'expected initial bundle write'); + +const manifest = { + bundleFormat: 'json', + files: { + [relKey]: entry + } +}; + +const logs = []; +await updateBundlesWithChunks({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + chunks: [{ ...chunkBase, metaV2: nextMetaV2 }], + fileRelations: new Map([[relKey, { imports: ['./dep.swift'] }]]), + existingVfsManifestRowsByFile: new Map([[ + relKey, + [{ virtualPath: '/vfs/src/meta-target.swift', languageId: 'swift' }] + ]]), + log: (line) => logs.push(String(line || '')) +}); + +assert.ok( + logs.some((line) => line.includes('updated 1 incremental bundle(s)')), + 'expected metaV2-only change to force bundle rewrite' +); +assert.ok( + !logs.some((line) => line.includes('reused 1')), + 'did not expect bundle reuse when metaV2 changed' +); + +const bundleName = entry.bundles?.[0]; +assert.ok(bundleName, 'expected rewritten shard name'); +const loaded = await readBundleFile(path.join(bundleDir, bundleName), { + format: resolveBundleFormatFromName(bundleName, 'json') +}); +assert.equal(loaded?.ok, true, 'expected rewritten bundle to load'); +const relations = loaded?.bundle?.chunks?.[0]?.metaV2?.relations; +assert.deepEqual( + relations, + nextMetaV2.relations, + 'expected rewritten bundle to persist updated metaV2 relations' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('incremental cross-file bundle metaV2 rewrite test passed'); diff --git a/tests/indexing/incremental/crossfile-bundle-patch-write.test.js b/tests/indexing/incremental/crossfile-bundle-patch-write.test.js new file mode 100644 index 000000000..a81496633 --- /dev/null +++ b/tests/indexing/incremental/crossfile-bundle-patch-write.test.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + updateBundlesWithChunks, + writeIncrementalBundle +} from '../../../src/index/build/incremental.js'; +import { resolveBundleFormatFromName } from '../../../src/shared/bundle-io-paths.js'; +import { readBundleFile } from '../../../src/shared/bundle-io.js'; +import { sleep } from '../../../src/shared/sleep.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' + } +}); + +const pathExists = async (targetPath) => { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +}; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-patch-write'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const relKey = 'src/patch-target.js'; +const fileStat = { mtimeMs: 1700000000000, size: 222 }; +const manifestRows = [{ virtualPath: '/vfs/src/patch-target.js', languageId: 'javascript' }]; +const seedChunks = [ + { file: relKey, chunkId: 'a', text: 'seed-a' }, + { file: relKey, chunkId: 'b', text: 'seed-b' } +]; +const entry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat, + fileHash: 'hash:seed', + fileChunks: seedChunks, + fileRelations: { imports: ['./dep-a.js'] }, + vfsManifestRows: manifestRows, + bundleFormat: 'json' +}); +assert.ok(entry, 'expected seeded incremental bundle entry'); + +const manifest = { + bundleFormat: 'json', + files: { + [relKey]: entry + } +}; + +const bundleName = entry.bundles?.[0]; +assert.ok(bundleName, 'expected bundle shard name'); +const bundlePath = path.join(bundleDir, bundleName); +const before = await fs.stat(bundlePath); +await sleep(25); + +await updateBundlesWithChunks({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + chunks: [ + { file: relKey, chunkId: 'a', text: 'updated-a' }, + { file: relKey, chunkId: 'b', text: 'seed-b' }, + { file: relKey, chunkId: 'c', text: 'added-c' } + ], + fileRelations: new Map([[relKey, { imports: ['./dep-b.js'] }]]), + log: () => {} +}); + +const after = await fs.stat(bundlePath); +assert.ok(after.mtimeMs > before.mtimeMs, 'expected bundle rewrite to update mtime'); + +const loaded = await readBundleFile(bundlePath, { + format: resolveBundleFormatFromName(bundleName, 'json') +}); +assert.equal(loaded?.ok, true, 'expected patched bundle to load'); +assert.equal(loaded.bundle?.chunks?.[0]?.text, 'updated-a', 'expected patched first chunk text'); +assert.equal(loaded.bundle?.chunks?.[2]?.chunkId, 'c', 'expected patched append chunk'); +assert.deepEqual( + loaded.bundle?.fileRelations || null, + { imports: ['./dep-b.js'] }, + 'expected patched bundle file relations' +); +assert.equal( + await pathExists(`${bundlePath}.patch.jsonl`), + false, + 'expected shard rewrite path to avoid json patch sidecars' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('incremental cross-file bundle patch write test passed'); diff --git a/tests/indexing/incremental/crossfile-bundle-reuse.test.js b/tests/indexing/incremental/crossfile-bundle-reuse.test.js new file mode 100644 index 000000000..e14575fc6 --- /dev/null +++ b/tests/indexing/incremental/crossfile-bundle-reuse.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { updateBundlesWithChunks, writeIncrementalBundle } from '../../../src/index/build/incremental.js'; +import { sleep } from '../../../src/shared/sleep.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' + } +}); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-reuse'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const relKey = 'src/a.js'; +const fileStat = { mtimeMs: Date.now(), size: 128 }; +const fileChunks = [{ file: relKey, chunkId: 'a:1', text: 'seed text' }]; +const fileRelations = { imports: ['./dep.js'] }; +const vfsManifestRows = [{ virtualPath: '/vfs/src/a.js', languageId: 'javascript' }]; + +const entry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat, + fileHash: 'hash:a', + fileChunks, + fileRelations, + vfsManifestRows, + bundleFormat: 'json' +}); +assert.ok(entry, 'expected initial bundle write'); + +const manifest = { + bundleFormat: 'json', + files: { + [relKey]: entry + } +}; + +const bundleName = entry.bundles?.[0]; +assert.ok(bundleName, 'expected bundle shard name'); +const bundlePath = path.join(bundleDir, bundleName); +const before = await fs.stat(bundlePath); +await sleep(25); + +const logs = []; +await updateBundlesWithChunks({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + chunks: [{ file: relKey, chunkId: 'a:1', text: 'seed text' }], + fileRelations: new Map([[relKey, { imports: ['./dep.js'] }]]), + existingVfsManifestRowsByFile: new Map([[relKey, vfsManifestRows]]), + log: (line) => logs.push(String(line || '')) +}); + +const after = await fs.stat(bundlePath); +assert.equal( + after.mtimeMs, + before.mtimeMs, + 'expected unchanged bundle to be reused without rewrite' +); +assert.ok( + logs.some((line) => line.includes('reused 1')), + 'expected update log to report bundle reuse' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('incremental cross-file bundle reuse test passed'); diff --git a/tests/indexing/incremental/crossfile-bundle-worker-transform.test.js b/tests/indexing/incremental/crossfile-bundle-worker-transform.test.js new file mode 100644 index 000000000..bd9e8768e --- /dev/null +++ b/tests/indexing/incremental/crossfile-bundle-worker-transform.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveBundlePatchPath } from '../../../src/shared/bundle-io-paths.js'; +import { + readBundleFile, + writeBundleFile, + writeBundlePatch +} from '../../../src/shared/bundle-io.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-worker-transform'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const hugeStableText = 'A'.repeat(5 * 1024 * 1024); +const previousBundle = { + file: 'src/huge-worker-transform.js', + hash: 'hash:prev', + mtimeMs: 1700000000000, + size: hugeStableText.length, + chunks: [ + { file: 'src/huge-worker-transform.js', chunkId: 'stable', text: hugeStableText }, + { file: 'src/huge-worker-transform.js', chunkId: 'tail', text: 'old-tail' } + ], + fileRelations: { imports: ['./dep-a.js'] } +}; +const nextBundle = { + ...previousBundle, + hash: 'hash:next', + chunks: [ + previousBundle.chunks[0], + { file: 'src/huge-worker-transform.js', chunkId: 'tail', text: 'new-tail' } + ], + fileRelations: { imports: ['./dep-b.js'] } +}; + +const jsonBundlePath = path.join(tempRoot, 'huge-worker-transform.json'); +await writeBundleFile({ + bundlePath: jsonBundlePath, + bundle: previousBundle, + format: 'json' +}); +const patchResult = await writeBundlePatch({ + bundlePath: jsonBundlePath, + previousBundle, + nextBundle, + format: 'json' +}); +assert.equal(patchResult.applied, true, 'expected bundle patch write to succeed'); +const patchPath = resolveBundlePatchPath(jsonBundlePath); +const patchStat = await fs.stat(patchPath); +assert.ok(patchStat.size > 0, 'expected non-empty patch sidecar after worker patch transform'); +const patchMetaPath = `${patchPath}.meta.json`; +const patchMeta = JSON.parse(await fs.readFile(patchMetaPath, 'utf8')); +assert.equal(Number.isFinite(Number(patchMeta.entries)), true, 'expected patch meta entries'); +assert.equal(Number(patchMeta.entries), 1, 'expected single patch entry recorded in metadata'); +const patched = await readBundleFile(jsonBundlePath, { format: 'json' }); +assert.equal(patched?.ok, true, 'expected patched JSON bundle to load'); +assert.equal(patched.bundle?.chunks?.[1]?.text, 'new-tail', 'expected patched tail chunk'); +assert.deepEqual( + patched.bundle?.fileRelations || null, + { imports: ['./dep-b.js'] }, + 'expected patched relations payload' +); + +const msgpackBundlePath = path.join(tempRoot, 'huge-worker-transform.mpk'); +const writeMsgpack = await writeBundleFile({ + bundlePath: msgpackBundlePath, + bundle: nextBundle, + format: 'msgpack' +}); +assert.equal(writeMsgpack.format, 'msgpack'); +assert.equal(typeof writeMsgpack.checksum, 'string', 'expected msgpack checksum'); +const loadedMsgpack = await readBundleFile(msgpackBundlePath, { format: 'msgpack' }); +assert.equal(loadedMsgpack?.ok, true, 'expected msgpack bundle to load'); +assert.equal(loadedMsgpack.bundle?.chunks?.[1]?.text, 'new-tail'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('incremental cross-file bundle worker transform test passed'); diff --git a/tests/indexing/incremental/crossfile-hot-cold-priority.test.js b/tests/indexing/incremental/crossfile-hot-cold-priority.test.js new file mode 100644 index 000000000..0b2090177 --- /dev/null +++ b/tests/indexing/incremental/crossfile-hot-cold-priority.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' + } +}); + +const { updateBundlesWithChunks } = await import('../../../src/index/build/incremental.js'); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-hot-cold-priority'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const now = Date.now(); +const manifest = { + bundleFormat: 'json', + files: { + 'src/cold.js': { + hash: 'hash:cold', + mtimeMs: now - (45 * 60 * 1000), + size: 10, + bundles: ['cold.json'] + }, + 'src/hot-older.js': { + hash: 'hash:hot-older', + mtimeMs: now - (2 * 60 * 1000), + size: 11, + bundles: ['hot-older.json'] + }, + 'src/hot-newer.js': { + hash: 'hash:hot-newer', + mtimeMs: now - 30_000, + size: 12, + bundles: ['hot-newer.json'] + } + } +}; + +const expectedProcessOrder = [ + 'src/hot-newer.js', + 'src/hot-older.js', + 'src/cold.js' +]; +const relationByFile = new Map(expectedProcessOrder.map((file) => [file, { imports: [] }])); +const observedProcessOrder = []; +const fileRelations = { + get(file) { + if (relationByFile.has(file)) observedProcessOrder.push(file); + return relationByFile.get(file) || null; + } +}; + +await updateBundlesWithChunks({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + chunks: [ + { file: 'src/cold.js', chunkId: 'cold:new', text: 'cold update' }, + { file: 'src/hot-older.js', chunkId: 'hot-older:new', text: 'hot older update' }, + { file: 'src/hot-newer.js', chunkId: 'hot-newer:new', text: 'hot newer update' } + ], + fileRelations, + log: () => {} +}); + +assert.deepEqual( + observedProcessOrder, + expectedProcessOrder, + 'expected cross-file bundle updates to process hot files before cold files' +); + +for (const [file, entry] of Object.entries(manifest.files)) { + const bundleName = entry.bundles?.[0]; + assert.ok(bundleName, `expected bundle name for ${file}`); + const bundlePath = path.join(bundleDir, bundleName); + const rawBundle = JSON.parse(await fs.readFile(bundlePath, 'utf8')); + assert.equal(rawBundle.file, file, `expected written bundle for ${file}`); +} + +console.log('incremental cross-file hot/cold update priority test passed'); diff --git a/tests/indexing/incremental/crossfile-prefetch-vfs.test.js b/tests/indexing/incremental/crossfile-prefetch-vfs.test.js new file mode 100644 index 000000000..d5bf0303b --- /dev/null +++ b/tests/indexing/incremental/crossfile-prefetch-vfs.test.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + preloadIncrementalBundleVfsRows, + updateBundlesWithChunks, + writeIncrementalBundle +} from '../../../src/index/build/incremental.js'; +import { resolveBundleFormatFromName } from '../../../src/shared/bundle-io-paths.js'; +import { readBundleFile } from '../../../src/shared/bundle-io.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-prefetch-vfs'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const sharedStat = { size: 123, mtimeMs: 1700000000000 }; +const manifest = { bundleFormat: 'json', files: {} }; +const sourceRowsByFile = new Map([ + ['src/a.js', [{ virtualPath: '/vfs/src/a.js', languageId: 'javascript' }]], + ['src/b.js', [{ virtualPath: '/vfs/src/b.js', languageId: 'javascript' }]] +]); + +const seedBundles = async () => { + for (const [relKey, vfsRows] of sourceRowsByFile.entries()) { + const entry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat: sharedStat, + fileHash: `hash:${relKey}`, + fileChunks: [{ file: relKey, chunkId: `${relKey}:seed`, text: 'seed' }], + fileRelations: { imports: [] }, + vfsManifestRows: vfsRows, + bundleFormat: 'json' + }); + assert.ok(entry, `expected manifest entry for ${relKey}`); + manifest.files[relKey] = entry; + } +}; + +await seedBundles(); + +const createUpdatedChunks = (suffix) => [ + { file: 'src/a.js', chunkId: `a:${suffix}`, text: `updated a ${suffix}` }, + { file: 'src/b.js', chunkId: `b:${suffix}`, text: `updated b ${suffix}` } +]; + +const createFileRelations = () => new Map([ + ['src/a.js', { imports: ['./dep-a.js'] }], + ['src/b.js', { imports: ['./dep-b.js'] }] +]); + +const assertBundlesPreserveVfsRows = async (message) => { + for (const [relKey, entry] of Object.entries(manifest.files)) { + const bundleName = entry.bundles?.[0]; + assert.ok(bundleName, `expected bundle name for ${relKey}`); + const bundlePath = path.join(bundleDir, bundleName); + const loaded = await readBundleFile(bundlePath, { + format: resolveBundleFormatFromName(bundleName, 'json') + }); + assert.ok(loaded?.ok, `expected ${message} bundle for ${relKey}`); + assert.deepEqual( + loaded.bundle?.vfsManifestRows || null, + sourceRowsByFile.get(relKey) || null, + `expected ${message} to preserve VFS rows for ${relKey}` + ); + } +}; + +const updateBundlesAndAssertVfsRows = async ({ + suffix, + existingVfsManifestRowsByFile, + message +}) => { + await updateBundlesWithChunks({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + chunks: createUpdatedChunks(suffix), + fileRelations: createFileRelations(), + existingVfsManifestRowsByFile, + log: () => {} + }); + + await assertBundlesPreserveVfsRows(message); +}; + +const prefetchedRowsByFile = await preloadIncrementalBundleVfsRows({ + enabled: true, + manifest, + bundleDir, + bundleFormat: 'json', + concurrency: 2 +}); +assert.ok(prefetchedRowsByFile instanceof Map, 'expected prefetched rows map'); + +await fs.rm(bundleDir, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +await updateBundlesAndAssertVfsRows({ + suffix: 'new', + existingVfsManifestRowsByFile: prefetchedRowsByFile, + message: 'updated' +}); + +await fs.rm(bundleDir, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); +await seedBundles(); + +const partialPrefetchedRows = new Map([ + ['src/a.js', prefetchedRowsByFile.get('src/a.js') || null] +]); + +await updateBundlesAndAssertVfsRows({ + suffix: 'new2', + existingVfsManifestRowsByFile: partialPrefetchedRows, + message: 'partial prefetch fallback' +}); + +console.log('incremental cross-file prefetch vfs rows test passed'); diff --git a/tests/indexing/incremental/incremental-bundle-mapping-reasons.test.js b/tests/indexing/incremental/incremental-bundle-mapping-reasons.test.js deleted file mode 100644 index c9edc8756..000000000 --- a/tests/indexing/incremental/incremental-bundle-mapping-reasons.test.js +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - normalizeBundleFormat, - readBundleFile, - resolveBundleFormatFromName, - writeBundleFile -} from '../../../src/shared/bundle-io.js'; -import { getIncrementalPaths } from '../../../src/storage/sqlite/incremental.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; -import { setupIncrementalRepo } from '../../helpers/sqlite-incremental.js'; - -const { root, repoRoot, env, userConfig, run, runCapture } = await setupIncrementalRepo({ - name: 'incremental-bundle-mapping-reasons' -}); - -const buildIndexPath = path.join(root, 'build_index.js'); - -run( - [ - buildIndexPath, - '--incremental', - '--stub-embeddings', - '--scm-provider', - 'none', - '--stage', - 'stage2', - '--no-sqlite', - '--mode', - 'code', - '--repo', - repoRoot - ], - 'stage2 build', - { cwd: repoRoot, env, stdio: 'inherit' } -); - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const incrementalPaths = getIncrementalPaths(repoCacheRoot, 'code'); -const manifestPath = incrementalPaths.manifestPath; -const bundleDir = incrementalPaths.bundleDir; -const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); -const firstManifestFile = Object.keys(manifest.files || {})[0]; -assert.ok(firstManifestFile, 'expected at least one manifest file'); -const firstEntry = manifest.files[firstManifestFile]; -assert.ok(firstEntry?.bundle, 'expected manifest bundle entry'); - -const bundleFormat = normalizeBundleFormat(manifest.bundleFormat); -const sourceBundleName = firstEntry.bundle; -const sourceBundlePath = path.join(bundleDir, sourceBundleName); -const sourceRead = await readBundleFile(sourceBundlePath, { - format: resolveBundleFormatFromName(sourceBundleName, bundleFormat) -}); -assert.equal(sourceRead.ok, true, 'expected source bundle read to succeed'); - -const brokenChunk = { - text: 'intentionally unmappable chunk', - kind: 'paragraph', - id: null, - start: null, - end: null, - embedding_u8: null, - embedding: null, - segment: null, - metaV2: null, - file: null, - docmeta: null -}; -const brokenBundle = { - ...(sourceRead.bundle || {}), - file: firstManifestFile, - chunks: [brokenChunk] -}; - -const sourceExt = path.extname(sourceBundleName) || '.json'; -const brokenBundleName = `broken-no-parent${sourceExt}`; -const brokenBundlePath = path.join(bundleDir, brokenBundleName); -await writeBundleFile({ - bundlePath: brokenBundlePath, - bundle: brokenBundle, - format: resolveBundleFormatFromName(brokenBundleName, bundleFormat) -}); - -manifest.files['phantom/no-parent.js'] = { - ...firstEntry, - bundle: brokenBundleName -}; -fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - -const stage3Result = runCapture( - [ - buildIndexPath, - '--incremental', - '--stub-embeddings', - '--scm-provider', - 'none', - '--stage', - 'stage3', - '--mode', - 'code', - '--repo', - repoRoot - ], - 'stage3 build' -); - -const output = `${stage3Result.stdout || ''}\n${stage3Result.stderr || ''}`; -assert.match(output, /noMappingReasons=/, 'expected noMappingReasons summary in embeddings logs'); -assert.match( - output, - /noMappingReasons=.*missingParent:[1-9]/, - 'expected missingParent reason count to be reported' -); - -console.log('incremental bundle mapping reasons test passed'); diff --git a/tests/indexing/incremental/incremental-bundles-updated-after-metav2-finalize.test.js b/tests/indexing/incremental/incremental-bundles-updated-after-metav2-finalize.test.js deleted file mode 100644 index ccc1cdb44..000000000 --- a/tests/indexing/incremental/incremental-bundles-updated-after-metav2-finalize.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; - -const root = process.cwd(); -const pipelinePath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline.js'); -const source = fs.readFileSync(pipelinePath, 'utf8'); - -const writeCall = 'await writeIndexArtifactsForMode('; -const updateCall = 'await updateIncrementalBundles('; - -const writeIndex = source.indexOf(writeCall); -const updateIndex = source.indexOf(updateCall); - -if (writeIndex < 0) { - console.error(`Expected pipeline to contain "${writeCall}".`); - process.exit(1); -} -if (updateIndex < 0) { - console.error(`Expected pipeline to contain "${updateCall}".`); - process.exit(1); -} -if (updateIndex <= writeIndex) { - console.error( - 'Expected incremental bundle refresh to run after artifact write/finalization ' + - '(prevents stale metaV2 in incremental bundles).' - ); - process.exit(1); -} - -console.log('incremental bundle update ordering against metaV2 finalization ok.'); diff --git a/tests/indexing/incremental/incremental-cache-signature.test.js b/tests/indexing/incremental/incremental-cache-signature.test.js deleted file mode 100644 index df9b95d44..000000000 --- a/tests/indexing/incremental/incremental-cache-signature.test.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-cache-signature'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -const filePath = path.join(repoRoot, 'src.js'); -await fsPromises.writeFile(filePath, 'function alpha() { return 1; }\n'); - -const buildTestEnv = (testConfig) => applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: testConfig ?? null, - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const runBuild = (label, testConfig) => { - const result = spawnSync( - process.execPath, - [ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--stage', - 'stage2', - '--mode', - 'code', - '--scm-provider', - 'none', - '--incremental', - '--repo', - repoRoot - ], - { - cwd: repoRoot, - env: buildTestEnv(testConfig), - stdio: 'inherit' - } - ); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -runBuild('initial build', { indexing: { lint: false } }); -runBuild('cache build', { indexing: { lint: false } }); - -applyTestEnv({ cacheRoot, embeddings: 'stub' }); -const userConfig = loadUserConfig(repoRoot); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -const fileListsPath = path.join(codeDir, '.filelists.json'); -if (!fs.existsSync(fileListsPath)) { - console.error('Missing .filelists.json'); - process.exit(1); -} -const fileLists = JSON.parse(await fsPromises.readFile(fileListsPath, 'utf8')); -const cachedEntry = fileLists?.scanned?.sample?.find((entry) => entry?.file?.endsWith('src.js')); -if (!cachedEntry || cachedEntry.cached !== true) { - console.error('Expected cached entry after incremental rebuild'); - process.exit(1); -} - -runBuild('config signature rebuild', { indexing: { lint: true } }); - -const userConfigAfter = loadUserConfig(repoRoot); -const codeDirAfter = getIndexDir(repoRoot, 'code', userConfigAfter); -const fileListsAfter = JSON.parse(await fsPromises.readFile(path.join(codeDirAfter, '.filelists.json'), 'utf8')); -const rebuildEntry = fileListsAfter?.scanned?.sample?.find((entry) => entry?.file?.endsWith('src.js')); -if (!rebuildEntry || rebuildEntry.cached === true) { - console.error('Expected cache invalidation after config signature change'); - process.exit(1); -} - -console.log('incremental cache signature test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-bundle-metav2-rewrite.test.js b/tests/indexing/incremental/incremental-crossfile-bundle-metav2-rewrite.test.js deleted file mode 100644 index e850d3096..000000000 --- a/tests/indexing/incremental/incremental-crossfile-bundle-metav2-rewrite.test.js +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; -import { updateBundlesWithChunks, writeIncrementalBundle } from '../../../src/index/build/incremental.js'; -import { readBundleFile, resolveBundleFormatFromName } from '../../../src/shared/bundle-io.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ - testing: '1', - extraEnv: { - PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' - } -}); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-metav2-rewrite'); -const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const relKey = 'src/meta-target.swift'; -const fileStat = { mtimeMs: 1700000000000, size: 321 }; -const chunkBase = { - file: relKey, - id: 0, - chunkId: 'chunk:0', - text: 'stable body' -}; -const oldMetaV2 = { - chunkId: 'chunk:0', - file: relKey, - range: { start: 1, end: 3 }, - lang: 'swift', - ext: '.swift', - relations: { - calls: [{ targetChunkId: 'chunk:old' }] - } -}; -const nextMetaV2 = { - chunkId: 'chunk:0', - file: relKey, - range: { start: 1, end: 3 }, - lang: 'swift', - ext: '.swift', - relations: { - calls: [{ targetChunkId: 'chunk:new' }] - } -}; - -const entry = await writeIncrementalBundle({ - enabled: true, - bundleDir, - relKey, - fileStat, - fileHash: 'hash:meta', - fileChunks: [{ ...chunkBase, metaV2: oldMetaV2 }], - fileRelations: { imports: ['./dep.swift'] }, - vfsManifestRows: [{ virtualPath: '/vfs/src/meta-target.swift', languageId: 'swift' }], - bundleFormat: 'json' -}); -assert.ok(entry, 'expected initial bundle write'); - -const manifest = { - bundleFormat: 'json', - files: { - [relKey]: entry - } -}; - -const logs = []; -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [{ ...chunkBase, metaV2: nextMetaV2 }], - fileRelations: new Map([[relKey, { imports: ['./dep.swift'] }]]), - existingVfsManifestRowsByFile: new Map([[ - relKey, - [{ virtualPath: '/vfs/src/meta-target.swift', languageId: 'swift' }] - ]]), - log: (line) => logs.push(String(line || '')) -}); - -assert.ok( - logs.some((line) => line.includes('updated 1 incremental bundle(s)')), - 'expected metaV2-only change to force bundle rewrite' -); -assert.ok( - !logs.some((line) => line.includes('reused 1')), - 'did not expect bundle reuse when metaV2 changed' -); - -const loaded = await readBundleFile(path.join(bundleDir, entry.bundle), { - format: resolveBundleFormatFromName(entry.bundle, 'json') -}); -assert.equal(loaded?.ok, true, 'expected rewritten bundle to load'); -const relations = loaded?.bundle?.chunks?.[0]?.metaV2?.relations; -assert.deepEqual( - relations, - nextMetaV2.relations, - 'expected rewritten bundle to persist updated metaV2 relations' -); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('incremental cross-file bundle metaV2 rewrite test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-bundle-patch-write.test.js b/tests/indexing/incremental/incremental-crossfile-bundle-patch-write.test.js deleted file mode 100644 index b6adcb4e3..000000000 --- a/tests/indexing/incremental/incremental-crossfile-bundle-patch-write.test.js +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; -import { - updateBundlesWithChunks, - writeIncrementalBundle -} from '../../../src/index/build/incremental.js'; -import { - readBundleFile, - resolveBundleFormatFromName, - resolveBundlePatchPath -} from '../../../src/shared/bundle-io.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ - testing: '1', - extraEnv: { - PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' - } -}); - -const pathExists = async (targetPath) => { - try { - await fs.stat(targetPath); - return true; - } catch { - return false; - } -}; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-patch-write'); -const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const relKey = 'src/patch-target.js'; -const fileStat = { mtimeMs: 1700000000000, size: 222 }; -const manifestRows = [{ virtualPath: '/vfs/src/patch-target.js', languageId: 'javascript' }]; -const seedChunks = [ - { file: relKey, chunkId: 'a', text: 'seed-a' }, - { file: relKey, chunkId: 'b', text: 'seed-b' } -]; -const entry = await writeIncrementalBundle({ - enabled: true, - bundleDir, - relKey, - fileStat, - fileHash: 'hash:seed', - fileChunks: seedChunks, - fileRelations: { imports: ['./dep-a.js'] }, - vfsManifestRows: manifestRows, - bundleFormat: 'json' -}); -assert.ok(entry, 'expected seeded incremental bundle entry'); - -const manifest = { - bundleFormat: 'json', - files: { - [relKey]: entry - } -}; - -const bundlePath = path.join(bundleDir, entry.bundle); -const before = await fs.stat(bundlePath); -await new Promise((resolve) => setTimeout(resolve, 25)); - -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [ - { file: relKey, chunkId: 'a', text: 'updated-a' }, - { file: relKey, chunkId: 'b', text: 'seed-b' }, - { file: relKey, chunkId: 'c', text: 'added-c' } - ], - fileRelations: new Map([[relKey, { imports: ['./dep-b.js'] }]]), - log: () => {} -}); - -const after = await fs.stat(bundlePath); -assert.equal( - after.mtimeMs, - before.mtimeMs, - 'expected JSON bundle base file to remain unchanged when patch write succeeds' -); - -const patchPath = resolveBundlePatchPath(bundlePath); -assert.equal(await pathExists(patchPath), true, 'expected patch sidecar to be created'); -const patchRaw = await fs.readFile(patchPath, 'utf8'); -const patchLines = patchRaw.split(/\r?\n/).filter((line) => line.trim().length > 0); -assert.ok(patchLines.length >= 1, 'expected at least one patch operation'); - -const loaded = await readBundleFile(bundlePath, { - format: resolveBundleFormatFromName(entry.bundle, 'json') -}); -assert.equal(loaded?.ok, true, 'expected patched bundle to load'); -assert.equal(loaded.bundle?.chunks?.[0]?.text, 'updated-a', 'expected patched first chunk text'); -assert.equal(loaded.bundle?.chunks?.[2]?.chunkId, 'c', 'expected patched append chunk'); -assert.deepEqual( - loaded.bundle?.fileRelations || null, - { imports: ['./dep-b.js'] }, - 'expected patched bundle file relations' -); - -await fs.appendFile(patchPath, '{"format":"broken"}\n', 'utf8'); -const invalid = await readBundleFile(bundlePath, { - format: resolveBundleFormatFromName(entry.bundle, 'json') -}); -assert.equal(invalid?.ok, false, 'expected invalid patch sidecar to fail strict bundle reads'); -assert.equal(invalid?.reason, 'invalid bundle patch', 'expected strict patch validation failure reason'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('incremental cross-file bundle patch write test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-bundle-reuse.test.js b/tests/indexing/incremental/incremental-crossfile-bundle-reuse.test.js deleted file mode 100644 index 547d6d61c..000000000 --- a/tests/indexing/incremental/incremental-crossfile-bundle-reuse.test.js +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; -import { updateBundlesWithChunks, writeIncrementalBundle } from '../../../src/index/build/incremental.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ - testing: '1', - extraEnv: { - PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' - } -}); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-reuse'); -const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const relKey = 'src/a.js'; -const fileStat = { mtimeMs: Date.now(), size: 128 }; -const fileChunks = [{ file: relKey, chunkId: 'a:1', text: 'seed text' }]; -const fileRelations = { imports: ['./dep.js'] }; -const vfsManifestRows = [{ virtualPath: '/vfs/src/a.js', languageId: 'javascript' }]; - -const entry = await writeIncrementalBundle({ - enabled: true, - bundleDir, - relKey, - fileStat, - fileHash: 'hash:a', - fileChunks, - fileRelations, - vfsManifestRows, - bundleFormat: 'json' -}); -assert.ok(entry, 'expected initial bundle write'); - -const manifest = { - bundleFormat: 'json', - files: { - [relKey]: entry - } -}; - -const bundlePath = path.join(bundleDir, entry.bundle); -const before = await fs.stat(bundlePath); -await new Promise((resolve) => setTimeout(resolve, 25)); - -const logs = []; -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [{ file: relKey, chunkId: 'a:1', text: 'seed text' }], - fileRelations: new Map([[relKey, { imports: ['./dep.js'] }]]), - existingVfsManifestRowsByFile: new Map([[relKey, vfsManifestRows]]), - log: (line) => logs.push(String(line || '')) -}); - -const after = await fs.stat(bundlePath); -assert.equal( - after.mtimeMs, - before.mtimeMs, - 'expected unchanged bundle to be reused without rewrite' -); -assert.ok( - logs.some((line) => line.includes('reused 1')), - 'expected update log to report bundle reuse' -); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('incremental cross-file bundle reuse test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-bundle-worker-transform.test.js b/tests/indexing/incremental/incremental-crossfile-bundle-worker-transform.test.js deleted file mode 100644 index 6d5e30d85..000000000 --- a/tests/indexing/incremental/incremental-crossfile-bundle-worker-transform.test.js +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; -import { - readBundleFile, - resolveBundlePatchPath, - writeBundleFile, - writeBundlePatch -} from '../../../src/shared/bundle-io.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-bundle-worker-transform'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const hugeStableText = 'A'.repeat(5 * 1024 * 1024); -const previousBundle = { - file: 'src/huge-worker-transform.js', - hash: 'hash:prev', - mtimeMs: 1700000000000, - size: hugeStableText.length, - chunks: [ - { file: 'src/huge-worker-transform.js', chunkId: 'stable', text: hugeStableText }, - { file: 'src/huge-worker-transform.js', chunkId: 'tail', text: 'old-tail' } - ], - fileRelations: { imports: ['./dep-a.js'] } -}; -const nextBundle = { - ...previousBundle, - hash: 'hash:next', - chunks: [ - previousBundle.chunks[0], - { file: 'src/huge-worker-transform.js', chunkId: 'tail', text: 'new-tail' } - ], - fileRelations: { imports: ['./dep-b.js'] } -}; - -const jsonBundlePath = path.join(tempRoot, 'huge-worker-transform.json'); -await writeBundleFile({ - bundlePath: jsonBundlePath, - bundle: previousBundle, - format: 'json' -}); -const patchResult = await writeBundlePatch({ - bundlePath: jsonBundlePath, - previousBundle, - nextBundle, - format: 'json' -}); -assert.equal(patchResult.applied, true, 'expected bundle patch write to succeed'); -const patchPath = resolveBundlePatchPath(jsonBundlePath); -const patchStat = await fs.stat(patchPath); -assert.ok(patchStat.size > 0, 'expected non-empty patch sidecar after worker patch transform'); -const patched = await readBundleFile(jsonBundlePath, { format: 'json' }); -assert.equal(patched?.ok, true, 'expected patched JSON bundle to load'); -assert.equal(patched.bundle?.chunks?.[1]?.text, 'new-tail', 'expected patched tail chunk'); -assert.deepEqual( - patched.bundle?.fileRelations || null, - { imports: ['./dep-b.js'] }, - 'expected patched relations payload' -); - -const msgpackBundlePath = path.join(tempRoot, 'huge-worker-transform.mpk'); -const writeMsgpack = await writeBundleFile({ - bundlePath: msgpackBundlePath, - bundle: nextBundle, - format: 'msgpack' -}); -assert.equal(writeMsgpack.format, 'msgpack'); -assert.equal(typeof writeMsgpack.checksum, 'string', 'expected msgpack checksum'); -const loadedMsgpack = await readBundleFile(msgpackBundlePath, { format: 'msgpack' }); -assert.equal(loadedMsgpack?.ok, true, 'expected msgpack bundle to load'); -assert.equal(loadedMsgpack.bundle?.chunks?.[1]?.text, 'new-tail'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('incremental cross-file bundle worker transform test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-hot-cold-priority.test.js b/tests/indexing/incremental/incremental-crossfile-hot-cold-priority.test.js deleted file mode 100644 index cc63194ee..000000000 --- a/tests/indexing/incremental/incremental-crossfile-hot-cold-priority.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ - testing: '1', - extraEnv: { - PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' - } -}); - -const { updateBundlesWithChunks } = await import('../../../src/index/build/incremental.js'); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-hot-cold-priority'); -const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const now = Date.now(); -const manifest = { - bundleFormat: 'json', - files: { - 'src/cold.js': { - hash: 'hash:cold', - mtimeMs: now - (45 * 60 * 1000), - size: 10, - bundle: 'cold.json' - }, - 'src/hot-older.js': { - hash: 'hash:hot-older', - mtimeMs: now - (2 * 60 * 1000), - size: 11, - bundle: 'hot-older.json' - }, - 'src/hot-newer.js': { - hash: 'hash:hot-newer', - mtimeMs: now - 30_000, - size: 12, - bundle: 'hot-newer.json' - } - } -}; - -const expectedProcessOrder = [ - 'src/hot-newer.js', - 'src/hot-older.js', - 'src/cold.js' -]; -const relationByFile = new Map(expectedProcessOrder.map((file) => [file, { imports: [] }])); -const observedProcessOrder = []; -const fileRelations = { - get(file) { - if (relationByFile.has(file)) observedProcessOrder.push(file); - return relationByFile.get(file) || null; - } -}; - -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [ - { file: 'src/cold.js', chunkId: 'cold:new', text: 'cold update' }, - { file: 'src/hot-older.js', chunkId: 'hot-older:new', text: 'hot older update' }, - { file: 'src/hot-newer.js', chunkId: 'hot-newer:new', text: 'hot newer update' } - ], - fileRelations, - log: () => {} -}); - -assert.deepEqual( - observedProcessOrder, - expectedProcessOrder, - 'expected cross-file bundle updates to process hot files before cold files' -); - -for (const [file, entry] of Object.entries(manifest.files)) { - const bundlePath = path.join(bundleDir, entry.bundle); - const rawBundle = JSON.parse(await fs.readFile(bundlePath, 'utf8')); - assert.equal(rawBundle.file, file, `expected written bundle for ${file}`); -} - -console.log('incremental cross-file hot/cold update priority test passed'); diff --git a/tests/indexing/incremental/incremental-crossfile-prefetch-vfs.test.js b/tests/indexing/incremental/incremental-crossfile-prefetch-vfs.test.js deleted file mode 100644 index ec710b7e1..000000000 --- a/tests/indexing/incremental/incremental-crossfile-prefetch-vfs.test.js +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { - preloadIncrementalBundleVfsRows, - updateBundlesWithChunks, - writeIncrementalBundle -} from '../../../src/index/build/incremental.js'; -import { - readBundleFile, - resolveBundleFormatFromName -} from '../../../src/shared/bundle-io.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-crossfile-prefetch-vfs'); -const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const sharedStat = { size: 123, mtimeMs: 1700000000000 }; -const manifest = { bundleFormat: 'json', files: {} }; -const sourceRowsByFile = new Map([ - ['src/a.js', [{ virtualPath: '/vfs/src/a.js', languageId: 'javascript' }]], - ['src/b.js', [{ virtualPath: '/vfs/src/b.js', languageId: 'javascript' }]] -]); - -const seedBundles = async () => { - for (const [relKey, vfsRows] of sourceRowsByFile.entries()) { - const entry = await writeIncrementalBundle({ - enabled: true, - bundleDir, - relKey, - fileStat: sharedStat, - fileHash: `hash:${relKey}`, - fileChunks: [{ file: relKey, chunkId: `${relKey}:seed`, text: 'seed' }], - fileRelations: { imports: [] }, - vfsManifestRows: vfsRows, - bundleFormat: 'json' - }); - assert.ok(entry, `expected manifest entry for ${relKey}`); - manifest.files[relKey] = entry; - } -}; - -await seedBundles(); - -const prefetchedRowsByFile = await preloadIncrementalBundleVfsRows({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - concurrency: 2 -}); -assert.ok(prefetchedRowsByFile instanceof Map, 'expected prefetched rows map'); - -await fs.rm(bundleDir, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [ - { file: 'src/a.js', chunkId: 'a:new', text: 'updated a' }, - { file: 'src/b.js', chunkId: 'b:new', text: 'updated b' } - ], - fileRelations: new Map([ - ['src/a.js', { imports: ['./dep-a.js'] }], - ['src/b.js', { imports: ['./dep-b.js'] }] - ]), - existingVfsManifestRowsByFile: prefetchedRowsByFile, - log: () => {} -}); - -for (const [relKey, entry] of Object.entries(manifest.files)) { - const bundlePath = path.join(bundleDir, entry.bundle); - const loaded = await readBundleFile(bundlePath, { - format: resolveBundleFormatFromName(entry.bundle, 'json') - }); - assert.ok(loaded?.ok, `expected updated bundle for ${relKey}`); - assert.deepEqual( - loaded.bundle?.vfsManifestRows || null, - sourceRowsByFile.get(relKey) || null, - `expected VFS rows to be preserved for ${relKey}` - ); -} - -await fs.rm(bundleDir, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); -await seedBundles(); - -const partialPrefetchedRows = new Map([ - ['src/a.js', prefetchedRowsByFile.get('src/a.js') || null] -]); - -await updateBundlesWithChunks({ - enabled: true, - manifest, - bundleDir, - bundleFormat: 'json', - chunks: [ - { file: 'src/a.js', chunkId: 'a:new2', text: 'updated a again' }, - { file: 'src/b.js', chunkId: 'b:new2', text: 'updated b again' } - ], - fileRelations: new Map([ - ['src/a.js', { imports: ['./dep-a.js'] }], - ['src/b.js', { imports: ['./dep-b.js'] }] - ]), - existingVfsManifestRowsByFile: partialPrefetchedRows, - log: () => {} -}); - -for (const [relKey, entry] of Object.entries(manifest.files)) { - const bundlePath = path.join(bundleDir, entry.bundle); - const loaded = await readBundleFile(bundlePath, { - format: resolveBundleFormatFromName(entry.bundle, 'json') - }); - assert.ok(loaded?.ok, `expected partially-prefetched bundle for ${relKey}`); - assert.deepEqual( - loaded.bundle?.vfsManifestRows || null, - sourceRowsByFile.get(relKey) || null, - `expected partial prefetch fallback to preserve VFS rows for ${relKey}` - ); -} - -console.log('incremental cross-file prefetch vfs rows test passed'); diff --git a/tests/indexing/incremental/incremental-manifest.test.js b/tests/indexing/incremental/incremental-manifest.test.js deleted file mode 100644 index e675745c9..000000000 --- a/tests/indexing/incremental/incremental-manifest.test.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const root = process.cwd(); -const tempRoot = await makeTempDir('pairofcleats-incremental-manifest-'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const buildIndexPath = path.join(root, 'build_index.js'); - -try { - await fsPromises.mkdir(repoRootRaw, { recursive: true }); - await fsPromises.mkdir(cacheRoot, { recursive: true }); - const repoRoot = toRealPathSync(repoRootRaw); - - const filePath = path.join(repoRoot, 'sample.js'); - await fsPromises.writeFile(filePath, 'export function hello() { return 1; }\n'); - - const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } - }); - - const run = (args, label) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - }; - - run([buildIndexPath, '--incremental', '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'initial build'); - - const userConfig = loadUserConfig(repoRoot); - const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); - const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); - if (!fs.existsSync(manifestPath)) { - console.error('Missing incremental manifest after initial build.'); - process.exit(1); - } - - const manifestBefore = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const entryBefore = manifestBefore.files?.['sample.js']; - if (!entryBefore) { - console.error('Missing manifest entry for sample.js.'); - process.exit(1); - } - - const newTime = new Date(Date.now() + 5000); - fs.utimesSync(filePath, newTime, newTime); - - run([buildIndexPath, '--incremental', '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'second build'); - - const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const entryAfter = manifestAfter.files?.['sample.js']; - if (!entryAfter) { - console.error('Missing manifest entry after rebuild.'); - process.exit(1); - } - - const statAfter = fs.statSync(filePath); - if (entryAfter.mtimeMs !== statAfter.mtimeMs) { - console.error(`Manifest mtimeMs not updated (${entryAfter.mtimeMs} vs ${statAfter.mtimeMs}).`); - process.exit(1); - } - - console.log('Incremental manifest refresh test passed'); -} finally { - await rmDirRecursive(tempRoot); -} - diff --git a/tests/indexing/incremental/incremental-signature-version.test.js b/tests/indexing/incremental/incremental-signature-version.test.js deleted file mode 100644 index 8f3d7dff0..000000000 --- a/tests/indexing/incremental/incremental-signature-version.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { shouldReuseIncrementalIndex } from '../../../src/index/build/incremental.js'; -import { SIGNATURE_VERSION } from '../../../src/index/build/indexer/signatures.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-signature-version'); -const repoRoot = path.join(tempRoot, 'repo'); -const outDir = path.join(tempRoot, 'out'); -const piecesDir = path.join(outDir, 'pieces'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fs.mkdir(piecesDir, { recursive: true }); - -const filePath = path.join(repoRoot, 'src', 'a.js'); -await fs.writeFile(filePath, 'export const a = 1;\n'); -const stat = await fs.stat(filePath); - -const indexState = { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: '0.0.1' -}; -await fs.writeFile(path.join(outDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); - -const pieceManifest = { - version: 1, - artifactSurfaceVersion: '0.0.1', - pieces: [ - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } - ] -}; -await fs.writeFile(path.join(piecesDir, 'manifest.json'), JSON.stringify(pieceManifest, null, 2)); - -const manifest = { - signatureVersion: SIGNATURE_VERSION - 1, - files: { - 'src/a.js': { - size: stat.size, - mtimeMs: stat.mtimeMs, - hash: 'deadbeef', - bundle: 'bundle.json' - } - } -}; - -const reuse = await shouldReuseIncrementalIndex({ - outDir, - entries: [{ rel: 'src/a.js', stat }], - manifest, - stage: null -}); - -assert.equal(reuse, false, 'expected signatureVersion mismatch to skip reuse'); - -console.log('incremental signature version test passed'); - diff --git a/tests/indexing/incremental/incremental-tokenization-cache.test.js b/tests/indexing/incremental/incremental-tokenization-cache.test.js deleted file mode 100644 index f7491544a..000000000 --- a/tests/indexing/incremental/incremental-tokenization-cache.test.js +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'incremental-token-cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const filePath = path.join(repoRoot, 'src.js'); -await fsPromises.writeFile(filePath, 'function alpha() { return 1; }\n'); - -const buildTestEnv = (testConfig) => applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: testConfig ?? null, - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const runBuild = (label, testConfig) => { - const result = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--incremental', '--repo', repoRoot], - { - cwd: repoRoot, - env: buildTestEnv(testConfig), - stdio: 'inherit' - } - ); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -const readCachedEntry = async () => { - applyTestEnv({ cacheRoot, embeddings: 'stub' }); - const userConfig = loadUserConfig(repoRoot); - const codeDir = getIndexDir(repoRoot, 'code', userConfig); - const fileListsPath = path.join(codeDir, '.filelists.json'); - if (!fs.existsSync(fileListsPath)) { - console.error('Missing .filelists.json'); - process.exit(1); - } - const fileLists = JSON.parse(await fsPromises.readFile(fileListsPath, 'utf8')); - const scannedSample = fileLists?.scanned?.sample; - if (!Array.isArray(scannedSample)) { - console.error('Scanned sample payload is not an array'); - process.exit(1); - } - const entry = scannedSample.find((entry) => entry?.file && entry.file.endsWith('src.js')); - if (!entry) { - console.error('Expected sample entry for src.js'); - process.exit(1); - } - return entry; -}; - -runBuild('initial build', { indexing: { postings: { enablePhraseNgrams: false } } }); -runBuild('cache build', { indexing: { postings: { enablePhraseNgrams: false } } }); - -const cachedEntry = await readCachedEntry(); -if (!cachedEntry || cachedEntry.cached !== true) { - console.error('Expected cached entry after incremental rebuild'); - process.exit(1); -} - -runBuild('config change rebuild', { indexing: { postings: { enablePhraseNgrams: true } } }); - -const rebuildEntry = await readCachedEntry(); -if (!rebuildEntry || rebuildEntry.cached === true) { - console.error('Expected cache invalidation after tokenization config change'); - process.exit(1); -} - -runBuild('cache build after config change', { indexing: { postings: { enablePhraseNgrams: true } } }); -const cachedAfterChange = await readCachedEntry(); -if (!cachedAfterChange || cachedAfterChange.cached !== true) { - console.error('Expected cached entry after config change rebuild'); - process.exit(1); -} - -runBuild('dict config change rebuild', { - indexing: { postings: { enablePhraseNgrams: true } }, - dictionary: { includeSlang: false } -}); -const dictEntry = await readCachedEntry(); -if (!dictEntry || dictEntry.cached === true) { - console.error('Expected cache invalidation after dictionary config change'); - process.exit(1); -} - -console.log('incremental tokenization cache test passed'); diff --git a/tests/indexing/incremental/manifest.test.js b/tests/indexing/incremental/manifest.test.js new file mode 100644 index 000000000..a9681caf2 --- /dev/null +++ b/tests/indexing/incremental/manifest.test.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getRepoCacheRoot, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +const root = process.cwd(); +const tempRoot = await makeTempDir('pairofcleats-incremental-manifest-'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const buildIndexPath = path.join(root, 'build_index.js'); + +try { + await fsPromises.mkdir(repoRootRaw, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + const repoRoot = toRealPathSync(repoRootRaw); + + const filePath = path.join(repoRoot, 'sample.js'); + await fsPromises.writeFile(filePath, 'export function hello() { return 1; }\n'); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + workerPool: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } + }); + + const run = (args, label) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'pipe', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); + } + }; + + run([ + buildIndexPath, + '--incremental', + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot + ], 'initial build'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + console.error('Missing incremental manifest after initial build.'); + process.exit(1); + } + + const manifestBefore = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entryBefore = manifestBefore.files?.['sample.js']; + if (!entryBefore) { + console.error('Missing manifest entry for sample.js.'); + process.exit(1); + } + + const newTime = new Date(Date.now() + 5000); + fs.utimesSync(filePath, newTime, newTime); + + run([ + buildIndexPath, + '--incremental', + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot + ], 'second build'); + + const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entryAfter = manifestAfter.files?.['sample.js']; + if (!entryAfter) { + console.error('Missing manifest entry after rebuild.'); + process.exit(1); + } + + const statAfter = fs.statSync(filePath); + if (entryAfter.mtimeMs !== statAfter.mtimeMs) { + console.error(`Manifest mtimeMs not updated (${entryAfter.mtimeMs} vs ${statAfter.mtimeMs}).`); + process.exit(1); + } + + console.log('Incremental manifest refresh test passed'); +} finally { + await rmDirRecursive(tempRoot); +} + diff --git a/tests/indexing/incremental/incremental-reuse.test.js b/tests/indexing/incremental/reuse.test.js similarity index 100% rename from tests/indexing/incremental/incremental-reuse.test.js rename to tests/indexing/incremental/reuse.test.js diff --git a/tests/indexing/incremental/signature-version.test.js b/tests/indexing/incremental/signature-version.test.js new file mode 100644 index 000000000..89c10e9d7 --- /dev/null +++ b/tests/indexing/incremental/signature-version.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { shouldReuseIncrementalIndex } from '../../../src/index/build/incremental.js'; +import { SIGNATURE_VERSION } from '../../../src/index/build/indexer/signatures.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-signature-version'); +const repoRoot = path.join(tempRoot, 'repo'); +const outDir = path.join(tempRoot, 'out'); +const piecesDir = path.join(outDir, 'pieces'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.mkdir(piecesDir, { recursive: true }); + +const filePath = path.join(repoRoot, 'src', 'a.js'); +await fs.writeFile(filePath, 'export const a = 1;\n'); +const stat = await fs.stat(filePath); + +const indexState = { + generatedAt: new Date().toISOString(), + mode: 'code', + artifactSurfaceVersion: '0.0.1' +}; +await fs.writeFile(path.join(outDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); + +const pieceManifest = { + version: 1, + artifactSurfaceVersion: '0.0.1', + pieces: [ + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } + ] +}; +await fs.writeFile(path.join(piecesDir, 'manifest.json'), JSON.stringify(pieceManifest, null, 2)); + +const manifest = { + signatureVersion: SIGNATURE_VERSION - 1, + files: { + 'src/a.js': { + size: stat.size, + mtimeMs: stat.mtimeMs, + hash: 'deadbeef', + bundles: ['bundle.json'] + } + } +}; + +const reuse = await shouldReuseIncrementalIndex({ + outDir, + entries: [{ rel: 'src/a.js', stat }], + manifest, + stage: null +}); + +assert.equal(reuse, false, 'expected signatureVersion mismatch to skip reuse'); + +console.log('incremental signature version test passed'); + diff --git a/tests/indexing/incremental/tokenization-cache.test.js b/tests/indexing/incremental/tokenization-cache.test.js new file mode 100644 index 000000000..cd42c8ea2 --- /dev/null +++ b/tests/indexing/incremental/tokenization-cache.test.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getRepoId } from '../../../tools/shared/dict-utils.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'incremental-token-cache'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +const filePath = path.join(repoRoot, 'src.js'); +await fsPromises.writeFile(filePath, 'function alpha() { return 1; }\n'); + +const BASE_TEST_CONFIG = Object.freeze({ + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } +}); + +const mergeTestConfig = (testConfig) => { + if (!testConfig || typeof testConfig !== 'object') return BASE_TEST_CONFIG; + return { + ...BASE_TEST_CONFIG, + ...testConfig, + indexing: { + ...(BASE_TEST_CONFIG.indexing || {}), + ...(testConfig.indexing || {}) + } + }; +}; + +const buildTestEnv = (testConfig) => applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: mergeTestConfig(testConfig), + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +const runBuild = (label, testConfig) => { + const result = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--scm-provider', + 'none', + '--incremental', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot + ], + label, + repoRoot, + buildTestEnv(testConfig), + { stdio: 'inherit', allowFailure: true } + ); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +const repoId = getRepoId(repoRoot); +const manifestPath = path.join(cacheRoot, 'repos', repoId, 'incremental', 'code', 'manifest.json'); + +const readManifest = async () => { + try { + return JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); + } catch (error) { + console.error(`Missing or invalid incremental manifest: ${manifestPath}`); + console.error(error?.message || String(error)); + process.exit(1); + } +}; + +runBuild('initial build', { indexing: { postings: { enablePhraseNgrams: false } } }); +const manifestInitial = await readManifest(); +runBuild('cache build', { indexing: { postings: { enablePhraseNgrams: false } } }); +const manifestCached = await readManifest(); +if (manifestCached.cacheSignature !== manifestInitial.cacheSignature) { + console.error('Expected stable cache signature for identical tokenization config'); + process.exit(1); +} + +runBuild('config change rebuild', { indexing: { postings: { enablePhraseNgrams: true } } }); +const manifestTokenChanged = await readManifest(); +if (manifestTokenChanged.tokenizationKey === manifestCached.tokenizationKey) { + console.error('Expected tokenization key change after phrase n-gram config change'); + process.exit(1); +} + +runBuild('dict config change rebuild', { + indexing: { postings: { enablePhraseNgrams: true } }, + dictionary: { includeSlang: false } +}); +const manifestDictChanged = await readManifest(); +if (manifestDictChanged.cacheSignature === manifestTokenChanged.cacheSignature) { + console.error('Expected cache signature change after dictionary config change'); + process.exit(1); +} + +console.log('incremental tokenization cache test passed'); diff --git a/tests/indexing/incremental/writeback-manifest-durability.test.js b/tests/indexing/incremental/writeback-manifest-durability.test.js new file mode 100644 index 000000000..46ba368d0 --- /dev/null +++ b/tests/indexing/incremental/writeback-manifest-durability.test.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + pruneIncrementalManifest, + updateBundlesWithChunks, + writeIncrementalBundle +} from '../../../src/index/build/incremental.js'; +import { resolveBundleShardFilename } from '../../../src/shared/bundle-io-paths.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_INCREMENTAL_BUNDLE_UPDATE_CONCURRENCY: '1' + } +}); + +const pathExists = async (targetPath) => { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +}; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', 'incremental-writeback-manifest-durability'); +const bundleDir = path.join(tempRoot, 'incremental', 'code', 'files'); +const manifestPath = path.join(tempRoot, 'incremental', 'code', 'manifest.json'); +const invalidManifestPath = path.join(tempRoot, 'invalid-manifest-dir'); + +const baseStat = { size: 321, mtimeMs: 1_700_000_000_000 }; + +const writeSeedEntry = async ({ relKey, fileHash, fileChunks }) => writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat: baseStat, + fileHash, + fileChunks, + fileRelations: { imports: [] }, + vfsManifestRows: null, + bundleFormat: 'json' +}); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); +await fs.mkdir(invalidManifestPath, { recursive: true }); + +const pruneRel = 'src/prune-target.js'; +const pruneEntryFail = await writeSeedEntry({ + relKey: pruneRel, + fileHash: 'hash:prune:fail', + fileChunks: [{ file: pruneRel, chunkId: 'prune-fail', text: 'seed' }] +}); +assert.ok(pruneEntryFail?.bundles?.length, 'expected prune seed bundle'); +const pruneBundleFail = path.join(bundleDir, pruneEntryFail.bundles[0]); +const pruneManifestFail = { + bundleFormat: 'json', + files: { + [pruneRel]: pruneEntryFail + } +}; + +await pruneIncrementalManifest({ + enabled: true, + manifest: pruneManifestFail, + manifestPath: invalidManifestPath, + bundleDir, + seenFiles: new Set() +}); +assert.equal( + await pathExists(pruneBundleFail), + true, + 'expected failed manifest commit to skip shard GC' +); + +const pruneEntryOk = await writeSeedEntry({ + relKey: pruneRel, + fileHash: 'hash:prune:ok', + fileChunks: [{ file: pruneRel, chunkId: 'prune-ok', text: 'seed' }] +}); +assert.ok(pruneEntryOk?.bundles?.length, 'expected prune success seed bundle'); +const pruneBundleOk = path.join(bundleDir, pruneEntryOk.bundles[0]); +const pruneManifestOk = { + bundleFormat: 'json', + files: { + [pruneRel]: pruneEntryOk + } +}; + +await pruneIncrementalManifest({ + enabled: true, + manifest: pruneManifestOk, + manifestPath, + bundleDir, + seenFiles: new Set() +}); +assert.equal( + await pathExists(pruneBundleOk), + false, + 'expected successful manifest commit to allow shard GC' +); + +const persistedAfterPrune = JSON.parse(await fs.readFile(manifestPath, 'utf8')); +assert.equal( + Object.prototype.hasOwnProperty.call(persistedAfterPrune?.files || {}, pruneRel), + false, + 'expected persisted manifest to drop pruned file entry' +); + +const updateRel = 'src/update-target.js'; +const largeText = 'x'.repeat(6 * 1024 * 1024); +const updateSeedEntry = await writeSeedEntry({ + relKey: updateRel, + fileHash: 'hash:update:seed', + fileChunks: [ + { file: updateRel, chunkId: 'a', text: largeText }, + { file: updateRel, chunkId: 'b', text: largeText }, + { file: updateRel, chunkId: 'c', text: largeText } + ] +}); +assert.ok((updateSeedEntry?.bundles?.length || 0) > 1, 'expected multi-shard seed bundle'); + +const updateManifest = { + bundleFormat: 'json', + files: { + [updateRel]: updateSeedEntry + } +}; +const previousBundleNames = updateSeedEntry.bundles.slice(); +const updateChunks = [ + { file: updateRel, chunkId: 'small', text: 'small update payload' } +]; +const updateRelations = new Map([[updateRel, { imports: ['./dep.js'] }]]); + +await updateBundlesWithChunks({ + enabled: true, + manifest: updateManifest, + manifestPath: invalidManifestPath, + bundleDir, + bundleFormat: 'json', + chunks: updateChunks, + fileRelations: updateRelations, + log: () => {} +}); + +const currentBundleNames = updateManifest.files[updateRel]?.bundles || []; +const staleBundleNames = previousBundleNames.filter((name) => !currentBundleNames.includes(name)); +assert.ok(staleBundleNames.length > 0, 'expected shard downsize to create stale bundle candidates'); +for (const staleName of staleBundleNames) { + assert.equal( + await pathExists(path.join(bundleDir, staleName)), + true, + `expected stale shard ${staleName} to remain when manifest write fails` + ); +} + +await updateBundlesWithChunks({ + enabled: true, + manifest: updateManifest, + manifestPath, + bundleDir, + bundleFormat: 'json', + chunks: updateChunks, + fileRelations: updateRelations, + log: () => {} +}); + +for (const staleName of staleBundleNames) { + assert.equal( + await pathExists(path.join(bundleDir, staleName)), + false, + `expected stale shard ${staleName} to be GC'd after successful manifest commit` + ); +} + +const persistedAfterUpdate = JSON.parse(await fs.readFile(manifestPath, 'utf8')); +const persistedBundles = persistedAfterUpdate?.files?.[updateRel]?.bundles || []; +assert.deepEqual( + persistedBundles.slice().sort(), + currentBundleNames.slice().sort(), + 'expected persisted manifest to reference active shard set after update' +); + +const partialFailureRel = 'src/partial-failure-target.js'; +const partialFailureShardOne = resolveBundleShardFilename(partialFailureRel, 'json', 0); +const partialFailureShardTwo = resolveBundleShardFilename(partialFailureRel, 'json', 1); +await fs.mkdir(path.join(bundleDir, partialFailureShardTwo), { recursive: true }); +const partialFailureEntry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey: partialFailureRel, + fileStat: baseStat, + fileHash: 'hash:partial-failure', + fileChunks: [ + { file: partialFailureRel, chunkId: 'pf-a', text: largeText }, + { file: partialFailureRel, chunkId: 'pf-b', text: largeText }, + { file: partialFailureRel, chunkId: 'pf-c', text: largeText } + ], + fileRelations: { imports: [] }, + vfsManifestRows: null, + bundleFormat: 'json' +}); +assert.equal(partialFailureEntry, null, 'expected partial shard write failure to fail closed'); +assert.equal( + await pathExists(path.join(bundleDir, partialFailureShardOne)), + false, + 'expected already-written shard artifacts to be cleaned up after partial write failure' +); +await fs.rm(path.join(bundleDir, partialFailureShardTwo), { recursive: true, force: true }); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('incremental writeback manifest durability test passed'); diff --git a/tests/indexing/language-fixture/chunk-meta-exists.test.js b/tests/indexing/language-fixture/chunk-meta-exists.test.js deleted file mode 100644 index 94c044b07..000000000 --- a/tests/indexing/language-fixture/chunk-meta-exists.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); - -const { chunkMeta, fileMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -if (!Array.isArray(chunkMeta) || chunkMeta.length === 0) { - console.error('Language fixture chunk_meta.json missing or empty.'); - process.exit(1); -} - -const sampleChunk = chunkMeta.find((chunk) => chunk && (chunk.file || chunk.fileId)); -const resolvedFile = sampleChunk ? resolveChunkFile(sampleChunk) : null; -if (!resolvedFile) { - console.error('Language fixture chunk_meta entries missing file references.'); - process.exit(1); -} - -if (fileMeta && !Array.isArray(fileMeta)) { - console.error('Language fixture file_meta.json should be an array.'); - process.exit(1); -} - -console.log('Language fixture chunk metadata present.'); diff --git a/tests/indexing/language-registry/registry-wiring.test.js b/tests/indexing/language-registry/registry-wiring.test.js index ff501ced6..9d0fd11cd 100644 --- a/tests/indexing/language-registry/registry-wiring.test.js +++ b/tests/indexing/language-registry/registry-wiring.test.js @@ -75,6 +75,7 @@ assert.equal(getLanguageForFile('.xml', 'config/app.xml')?.id, 'xml'); assert.equal(getLanguageForFile('.props', 'Directory.Build.props')?.id, 'xml'); assert.equal(getLanguageForFile('.targets', 'Directory.Build.targets')?.id, 'xml'); assert.equal(getLanguageForFile('.csproj', 'src/service/service.csproj')?.id, 'xml'); +assert.equal(getLanguageForFile('.projitems', 'src/service/service.projitems')?.id, 'xml'); assert.equal(getLanguageForFile('.config', 'src/service/app.config')?.id, 'xml'); assert.equal(getLanguageForFile('.yaml', 'config/app.yaml')?.id, 'yaml'); assert.equal(getLanguageForFile('.yml', 'config/app.yml')?.id, 'yaml'); diff --git a/tests/indexing/lifecycle/build-entry-failure-no-unsettled-warning.test.js b/tests/indexing/lifecycle/build-entry-failure-no-unsettled-warning.test.js new file mode 100644 index 000000000..2d104e06f --- /dev/null +++ b/tests/indexing/lifecycle/build-entry-failure-no-unsettled-warning.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import path from 'node:path'; + +import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = repoRoot(); +const buildIndexPath = path.join(root, 'build_index.js'); +const env = applyTestEnv({ + embeddings: 'stub', + syncProcess: false +}); + +const result = runNode( + [buildIndexPath, '--not-a-real-flag'], + 'build entry failure no-unsettled warning', + root, + env, + { stdio: 'pipe', timeoutMs: 15000, allowFailure: true } +); + +assert.equal(result.status, 1, `expected build entry failure exit 1, stdout=${result.stdout || ''}`); +assert.match(result.stderr || '', /Index build failed:/, 'expected failure banner on stderr'); +assert.match(result.stderr || '', /unknown options: not-a-real-flag/, 'expected unknown-option detail on stderr'); +assert.doesNotMatch( + `${result.stderr || ''}${result.stdout || ''}`, + /Detected unsettled top-level await/, + 'expected failing build entry to avoid unsettled top-level await warning' +); + +console.log('build entry failure no-unsettled-warning test passed'); diff --git a/tests/indexing/lifecycle/build-entry-main-contract.test.js b/tests/indexing/lifecycle/build-entry-main-contract.test.js new file mode 100644 index 000000000..cf55ed5c0 --- /dev/null +++ b/tests/indexing/lifecycle/build-entry-main-contract.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { main } from '../../../build_index.js'; + +ensureTestingEnv(process.env); + +const stdout = new PassThrough(); +let stdoutText = ''; +stdout.on('data', (chunk) => { + stdoutText += String(chunk); +}); + +const stderr = new PassThrough(); +let stderrText = ''; +stderr.on('data', (chunk) => { + stderrText += String(chunk); +}); + +const exitCode = await main({ + rawArgs: ['--config-dump', '--json'], + rawArgv: ['node', 'build_index.js', '--config-dump', '--json'], + env: process.env, + stdout, + stderr +}); + +assert.equal(exitCode, 0, `expected config-dump main() exit 0, stderr=${stderrText}`); +assert.equal(stderrText, '', 'expected config-dump main() to avoid stderr output'); +const payload = JSON.parse(stdoutText || '{}'); +assert.equal(typeof payload, 'object', 'expected config-dump JSON payload'); +assert.equal(payload.schemaVersion, 1, 'expected config-dump payload schemaVersion'); +assert.equal(typeof payload.runtime, 'object', 'expected config-dump payload to include runtime'); +assert.equal(typeof payload.concurrency, 'object', 'expected config-dump payload to include concurrency'); +assert.equal(typeof payload.queues, 'object', 'expected config-dump payload to include queues'); + +console.log('build entry main contract test passed'); diff --git a/tests/indexing/lifecycle/build-entry-success-no-unsettled-warning.test.js b/tests/indexing/lifecycle/build-entry-success-no-unsettled-warning.test.js new file mode 100644 index 000000000..a2b127665 --- /dev/null +++ b/tests/indexing/lifecycle/build-entry-success-no-unsettled-warning.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const root = repoRoot(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('build-entry-success', { root }); +const repoDir = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const buildIndexPath = path.join(root, 'build_index.js'); + +try { + await fsPromises.mkdir(repoDir, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile(path.join(repoDir, 'alpha.js'), 'export const alpha = () => "alpha";\n'); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false, + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } + }); + + const result = runNode( + [buildIndexPath, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoDir], + 'build entry success no-unsettled warning', + repoDir, + env, + { stdio: 'pipe', timeoutMs: 30000 } + ); + + assert.equal(result.status, 0, `expected build entry success exit 0, stderr=${result.stderr || ''}`); + assert.doesNotMatch( + `${result.stderr || ''}${result.stdout || ''}`, + /Detected unsettled top-level await/, + 'expected successful build entry to avoid unsettled top-level await warning' + ); + + console.log('build entry success no-unsettled-warning test passed'); +} finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/indexing/lifecycle/index-contract.test.js b/tests/indexing/lifecycle/index-contract.test.js new file mode 100644 index 000000000..0cc2da948 --- /dev/null +++ b/tests/indexing/lifecycle/index-contract.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { repoRoot } from '../../helpers/root.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = repoRoot(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('index-lifecycle', { root }); +const repoDir = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const buildIndexPath = path.join(root, 'build_index.js'); +const validatePath = path.join(root, 'tools', 'index', 'validate.js'); + +try { + await fsPromises.mkdir(repoDir, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + + await fsPromises.writeFile( + path.join(repoDir, 'alpha.js'), + 'export const alpha = () => "alpha";\n' + ); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false, + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } + }); + + runNode( + [buildIndexPath, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoDir], + 'index build stage2 for lifecycle contract', + repoDir, + env + ); + + runNode( + [buildIndexPath, '--stub-embeddings', '--stage', 'stage4', '--mode', 'code', '--repo', repoDir], + 'index build for lifecycle contract', + repoDir, + env + ); + + const validateResult = runNode( + [validatePath, '--json', '--mode', 'code', '--repo', repoDir], + 'index validate for lifecycle contract', + repoDir, + env, + { stdio: 'pipe', encoding: 'utf8' } + ); + + let payload = null; + try { + payload = JSON.parse(validateResult.stdout || '{}'); + } catch { + console.error('Failed: index validate returned invalid JSON'); + process.exit(1); + } + + if (!payload || typeof payload !== 'object') { + console.error('Failed: index validate payload missing'); + process.exit(1); + } + + if (!payload.ok) { + console.error('Failed: index validate reported issues'); + if (Array.isArray(payload.issues)) { + payload.issues.forEach((issue) => console.error(`- ${issue}`)); + } + process.exit(1); + } + + if (!payload.modes || !payload.modes.code) { + console.error('Failed: index validate missing code mode'); + process.exit(1); + } + + console.log('index lifecycle contract tests passed'); +} finally { + try { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } catch {} +} + diff --git a/tests/indexing/lifecycle/index-lifecycle-contract.test.js b/tests/indexing/lifecycle/index-lifecycle-contract.test.js deleted file mode 100644 index 7314a413f..000000000 --- a/tests/indexing/lifecycle/index-lifecycle-contract.test.js +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { repoRoot } from '../../helpers/root.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = repoRoot(); -const tempRoot = resolveTestCachePath(root, 'index-lifecycle'); -const repoDir = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoDir, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile( - path.join(repoDir, 'alpha.js'), - 'export const alpha = () => "alpha";\n' -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoDir], - { cwd: repoDir, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: index build for lifecycle contract'); - process.exit(buildResult.status ?? 1); -} - -const validateResult = spawnSync( - process.execPath, - [path.join(root, 'tools', 'index', 'validate.js'), '--json', '--mode', 'code', '--repo', repoDir], - { cwd: repoDir, env, encoding: 'utf8' } -); - -if (validateResult.status !== 0) { - console.error('Failed: index validate for lifecycle contract'); - if (validateResult.stderr) console.error(validateResult.stderr.trim()); - process.exit(validateResult.status ?? 1); -} - -let payload = null; -try { - payload = JSON.parse(validateResult.stdout || '{}'); -} catch { - console.error('Failed: index validate returned invalid JSON'); - process.exit(1); -} - -if (!payload || typeof payload !== 'object') { - console.error('Failed: index validate payload missing'); - process.exit(1); -} - -if (!payload.ok) { - console.error('Failed: index validate reported issues'); - if (Array.isArray(payload.issues)) { - payload.issues.forEach((issue) => console.error(`- ${issue}`)); - } - process.exit(1); -} - -if (!payload.modes || !payload.modes.code) { - console.error('Failed: index validate missing code mode'); - process.exit(1); -} - -console.log('index lifecycle contract tests passed'); - diff --git a/tests/indexing/logging/lexicon-filter-counts.test.js b/tests/indexing/logging/lexicon-filter-counts.test.js index 3ed886dba..0c18cac44 100644 --- a/tests/indexing/logging/lexicon-filter-counts.test.js +++ b/tests/indexing/logging/lexicon-filter-counts.test.js @@ -25,24 +25,31 @@ const rawRelations = { ] }; -const logs = []; -const filtered = filterRawRelationsWithLexicon(rawRelations, { - languageId: 'python', - config: { +const pythonDropKeywordsAndLiteralsConfig = { + enabled: true, + relations: { enabled: true, - relations: { - enabled: true, - drop: { - keywords: true, - literals: true, - builtins: false, - types: false - } + drop: { + keywords: true, + literals: true, + builtins: false, + types: false } - }, - relKey: 'src/a.py', - log: (line) => logs.push(String(line)) -}); + } +}; + +const filterPythonRelationsWithLogs = (relations, relKey) => { + const logs = []; + const filtered = filterRawRelationsWithLexicon(relations, { + languageId: 'python', + config: pythonDropKeywordsAndLiteralsConfig, + relKey, + log: (line) => logs.push(String(line)) + }); + return { filtered, logs }; +}; + +const { filtered, logs } = filterPythonRelationsWithLogs(rawRelations, 'src/a.py'); assert.equal(logs.length, 1, 'expected one deterministic filter log line'); assert.match(logs[0], /language=python/, 'expected language id in filter log line'); @@ -53,29 +60,12 @@ assert.match(logs[0], /callDetailsDropped=1/, 'expected callDetailsDropped count assert.match(logs[0], /callDetailsRangeDropped=1/, 'expected callDetailsRangeDropped count'); assert.match(logs[0], /totalDropped=5/, 'expected totalDropped count'); -const sparseLogs = []; -filterRawRelationsWithLexicon({ +const { logs: sparseLogs } = filterPythonRelationsWithLogs({ usages: ['if', 'value'], calls: [['run', 'obj.value']], callDetails: [{ caller: 'run', callee: 'obj.value', line: 1, col: 1 }], callDetailsWithRange: [{ caller: 'run', callee: 'obj.value', range: { start: 0, end: 2 } }] -}, { - languageId: 'python', - config: { - enabled: true, - relations: { - enabled: true, - drop: { - keywords: true, - literals: true, - builtins: false, - types: false - } - } - }, - relKey: 'src/b.py', - log: (line) => sparseLogs.push(String(line)) -}); +}, 'src/b.py'); assert.equal(sparseLogs.length, 1, 'expected sparse filter log line'); assert.match(sparseLogs[0], /usagesDropped=1/, 'expected usagesDropped count in sparse log'); assert.match(sparseLogs[0], /totalDropped=1/, 'expected totalDropped count in sparse log'); @@ -83,29 +73,12 @@ assert.doesNotMatch(sparseLogs[0], /callsDropped=/, 'did not expect callsDropped assert.doesNotMatch(sparseLogs[0], /callDetailsDropped=/, 'did not expect callDetailsDropped=0 in sparse log'); assert.doesNotMatch(sparseLogs[0], /callDetailsRangeDropped=/, 'did not expect callDetailsRangeDropped=0 in sparse log'); -const zeroLogs = []; -filterRawRelationsWithLexicon({ +const { logs: zeroLogs } = filterPythonRelationsWithLogs({ usages: ['value'], calls: [['run', 'obj.value']], callDetails: [{ caller: 'run', callee: 'obj.value', line: 1, col: 1 }], callDetailsWithRange: [{ caller: 'run', callee: 'obj.value', range: { start: 0, end: 2 } }] -}, { - languageId: 'python', - config: { - enabled: true, - relations: { - enabled: true, - drop: { - keywords: true, - literals: true, - builtins: false, - types: false - } - } - }, - relKey: 'src/c.py', - log: (line) => zeroLogs.push(String(line)) -}); +}, 'src/c.py'); assert.equal(zeroLogs.length, 0, 'did not expect filter log line when all dropped counters are zero'); const stats = getLexiconRelationFilterStats(filtered); diff --git a/tests/indexing/map/map-chunkuid-join.test.js b/tests/indexing/map/chunkuid-join.test.js similarity index 100% rename from tests/indexing/map/map-chunkuid-join.test.js rename to tests/indexing/map/chunkuid-join.test.js diff --git a/tests/indexing/map/code-map-basic.test.js b/tests/indexing/map/code-map-basic.test.js deleted file mode 100644 index 698cabd1a..000000000 --- a/tests/indexing/map/code-map-basic.test.js +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-basic'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'util.js'), - 'export function add(a, b) { return a + b; }\n' + - 'export function mutate(obj) { obj.count = obj.count + 1; return obj; }\n' -); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'main.js'), - 'import { add, mutate } from "./util.js";\n' + - 'function run(x) {\n' + - ' if (x > 0) { return add(x, 1); }\n' + - ' return add(x, 2);\n' + - '}\n' + - 'async function go(items) {\n' + - ' for (const item of items) {\n' + - ' await Promise.resolve(item);\n' + - ' mutate(item);\n' + - ' }\n' + - '}\n' + - 'export default function main(items) { return go(items); }\n' -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for code map basic test'); - process.exit(buildResult.status ?? 1); -} - -const mapResult = spawnSync( - process.execPath, - [path.join(root, 'tools', 'reports/report-code-map.js'), '--format', 'json', '--repo', repoRoot], - { cwd: repoRoot, env, encoding: 'utf8' } -); - -if (mapResult.status !== 0) { - console.error('Failed: map generator'); - if (mapResult.stderr) console.error(mapResult.stderr.trim()); - process.exit(mapResult.status ?? 1); -} - -let payload = null; -try { - payload = JSON.parse(mapResult.stdout || '{}'); -} catch { - console.error('Failed: map output invalid JSON'); - process.exit(1); -} - -if (!Array.isArray(payload.nodes) || payload.nodes.length === 0) { - console.error('Failed: map nodes missing'); - process.exit(1); -} - -const members = payload.nodes.flatMap((node) => node.members || []); -if (!members.length) { - console.error('Failed: map members missing'); - process.exit(1); -} - -const hasControlFlow = members.some((member) => member.controlFlow); -const hasDataflow = members.some((member) => member.dataflow); -const warnings = new Set(payload.warnings || []); -const missingDataflowWarning = 'dataflow metadata missing; map is limited'; -const missingControlWarning = 'controlFlow metadata missing; map is limited'; -if (!hasDataflow && !warnings.has(missingDataflowWarning)) { - console.error('Failed: expected dataflow metadata or warning'); - process.exit(1); -} -if (!hasControlFlow && !warnings.has(missingControlWarning)) { - console.error('Failed: expected controlFlow metadata or warning'); - process.exit(1); -} - -const edgeTypes = new Set(payload.edges.map((edge) => edge.type)); -if (!edgeTypes.has('import') || !edgeTypes.has('call')) { - console.error('Failed: expected import + call edges'); - process.exit(1); -} - -console.log('code map basic tests passed'); - diff --git a/tests/indexing/map/code-map-contract-matrix.test.js b/tests/indexing/map/code-map-contract-matrix.test.js new file mode 100644 index 000000000..e626d140b --- /dev/null +++ b/tests/indexing/map/code-map-contract-matrix.test.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); + +const createMapFixture = async ({ + tempName, + files, + testConfig, + extraEnv +}) => { + const tempRoot = resolveTestCachePath(root, tempName); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(repoRoot, relativePath); + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, contents); + } + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig, + extraEnv + }); + + return { repoRoot, env }; +}; + +const createBuiltMapFixture = async () => { + const fixture = await createMapFixture({ + tempName: 'code-map-contract-shared', + files: { + 'src/util.js': 'export function add(a, b) { return a + b; }\nexport function mutate(obj) { obj.count = obj.count + 1; return obj; }\n', + 'src/main.js': 'import { add, mutate } from "./util.js";\nexport function run(x) {\n if (x > 0) { return add(x, 1); }\n return add(x, 2);\n}\nexport async function go(items) {\n for (const item of items) {\n await Promise.resolve(item);\n mutate(item);\n }\n}\nexport default function main(items) { return go(items); }\n' + }, + testConfig: defaultConfig + }); + buildCodeIndex(fixture); + return fixture; +}; + +const buildCodeIndex = ({ repoRoot, env, extraArgs = [] }) => { + const buildResult = runNode( + [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', repoRoot, ...extraArgs], + 'build code-map fixture', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } + ); + assert.equal(buildResult.status, 0, 'expected code-map fixture build to succeed'); +}; + +const runCodeMap = ({ repoRoot, env, args = [] }) => runNode( + [path.join(root, 'tools', 'reports/report-code-map.js'), ...args, '--repo', repoRoot], + 'report-code-map contract', + repoRoot, + env, + { stdio: 'pipe', allowFailure: true } +); + +const defaultConfig = { + indexing: { + scm: { provider: 'none' }, + embeddings: { enabled: false }, + typeInference: false, + typeInferenceCrossFile: false, + treeSitter: { + deferMissing: false + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } +}; + +const sharedFixture = await createBuiltMapFixture(); + +const cases = [ + { + name: 'basic JSON output includes nodes, members, and contract warnings when metadata is absent', + async run() { + const { repoRoot, env } = sharedFixture; + const mapResult = runCodeMap({ repoRoot, env, args: ['--format', 'json'] }); + assert.equal(mapResult.status, 0); + const payload = JSON.parse(mapResult.stdout || '{}'); + assert.ok(Array.isArray(payload.nodes) && payload.nodes.length > 0); + const members = payload.nodes.flatMap((node) => node.members || []); + assert.ok(members.length > 0); + const hasControlFlow = members.some((member) => member.controlFlow); + const hasDataflow = members.some((member) => member.dataflow); + const warnings = new Set(payload.warnings || []); + if (!hasDataflow) { + assert.ok(warnings.has('dataflow metadata missing; map is limited')); + } + if (!hasControlFlow) { + assert.ok(warnings.has('controlFlow metadata missing; map is limited')); + } + } + }, + { + name: 'JSON output is deterministic across repeated runs', + async run() { + const { repoRoot, env } = sharedFixture; + const first = runCodeMap({ repoRoot, env, args: ['--format', 'json'] }); + const second = runCodeMap({ repoRoot, env, args: ['--format', 'json'] }); + assert.equal(first.status, 0); + assert.equal(second.status, 0); + + const strip = (payload) => { + const clone = JSON.parse(JSON.stringify(payload)); + clone.generatedAt = null; + if (clone.summary) clone.summary.generatedAt = null; + if (clone.buildMetrics) clone.buildMetrics = null; + return clone; + }; + + assert.deepEqual( + strip(JSON.parse(first.stdout || '{}')), + strip(JSON.parse(second.stdout || '{}')) + ); + } + }, + { + name: 'dot output emits graph header and ports', + async run() { + const { repoRoot, env } = sharedFixture; + const mapResult = runCodeMap({ + repoRoot, + env, + args: ['--format', 'dot', '--include', 'imports,calls'] + }); + assert.equal(mapResult.status, 0); + const output = getCombinedOutput(mapResult); + assert.ok(output.includes('PORT=')); + assert.ok(output.includes('digraph')); + } + }, + { + name: 'svg requests fall back to dot when graphviz is unavailable', + async run() { + const { repoRoot, env } = sharedFixture; + const outPath = path.join(path.dirname(repoRoot), 'map.svg'); + const mapResult = runCodeMap({ + repoRoot, + env: { ...env, PATH: '', Path: '' }, + args: ['--format', 'svg', '--out', outPath, '--json'] + }); + assert.equal(mapResult.status, 0); + const payload = JSON.parse(mapResult.stdout || '{}'); + assert.equal(payload.format, 'dot'); + assert.ok(String(payload.outPath || '').endsWith('.dot')); + } + }, + { + name: 'map builder prefers symbolId over chunkUid for member identity', + async run() { + const tempRoot = resolveTestCachePath(root, 'code-map-contract-symbol-identity'); + const indexRoot = path.join(tempRoot, 'index'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(indexRoot, { recursive: true }); + + const chunkMeta = [ + { + id: 1, + start: 0, + end: 10, + file: 'src/alpha.js', + name: 'alpha', + kind: 'function', + chunkUid: 'uid-alpha', + metaV2: { + chunkUid: 'uid-alpha', + file: 'src/alpha.js', + name: 'alpha', + kind: 'function', + symbol: { + v: 1, + scheme: 'heur', + kindGroup: 'function', + qualifiedName: 'alpha', + symbolKey: 'src/alpha.js::alpha::function', + signatureKey: null, + scopedId: 'function|src/alpha.js::alpha::function|uid-alpha', + symbolId: 'sym1:heur:alpha' + } + } + } + ]; + + const { writePiecesManifest } = await import('../../helpers/artifact-io-fixture.js'); + const { buildCodeMap } = await import('../../../src/map/build-map.js'); + await fsPromises.writeFile(path.join(indexRoot, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); + await writePiecesManifest(indexRoot, [ + { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' } + ]); + + const mapModel = await buildCodeMap({ + repoRoot: root, + indexDir: indexRoot, + options: { include: [], strict: false } + }); + + const member = mapModel.nodes?.[0]?.members?.[0]; + assert.ok(member, 'expected member in map'); + assert.equal(member.id, 'sym1:heur:alpha', 'expected member id to prefer symbolId'); + assert.notEqual(member.id, 'uid-alpha', 'expected member id to differ from chunkUid'); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('code map contract matrix test passed'); diff --git a/tests/indexing/map/code-map-determinism.test.js b/tests/indexing/map/code-map-determinism.test.js deleted file mode 100644 index abddd6172..000000000 --- a/tests/indexing/map/code-map-determinism.test.js +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-determinism'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'one.js'), - 'export function alpha() { return 1; }\n' -); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'two.js'), - 'import { alpha } from "./one.js";\nexport function beta() { return alpha(); }\n' -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' }, - embeddings: { enabled: false }, - typeInference: false, - typeInferenceCrossFile: false, - treeSitter: { - deferMissing: false - } - }, - tooling: { - autoEnableOnDetect: false - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--mode', 'code', '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for determinism test'); - process.exit(buildResult.status ?? 1); -} - -const runMap = () => spawnSync( - process.execPath, - [path.join(root, 'tools', 'reports/report-code-map.js'), '--format', 'json', '--repo', repoRoot], - { cwd: repoRoot, env, encoding: 'utf8' } -); - -const first = runMap(); -const second = runMap(); - -if (first.status !== 0 || second.status !== 0) { - console.error('Failed: map generator runs'); - process.exit(1); -} - -const strip = (payload) => { - const clone = JSON.parse(JSON.stringify(payload)); - clone.generatedAt = null; - if (clone.summary) clone.summary.generatedAt = null; - if (clone.buildMetrics) clone.buildMetrics = null; - return clone; -}; - -const firstPayload = strip(JSON.parse(first.stdout || '{}')); -const secondPayload = strip(JSON.parse(second.stdout || '{}')); - -if (JSON.stringify(firstPayload) !== JSON.stringify(secondPayload)) { - console.error('Failed: map output not deterministic'); - process.exit(1); -} - -console.log('code map determinism tests passed'); - diff --git a/tests/indexing/map/code-map-dot.test.js b/tests/indexing/map/code-map-dot.test.js deleted file mode 100644 index cb0bc6509..000000000 --- a/tests/indexing/map/code-map-dot.test.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCombinedOutput } from '../../helpers/stdio.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-dot'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'a.js'), - 'import { add } from "./b.js";\n' + - 'export function run(x) { return add(x, 1); }\n' -); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'b.js'), - 'export function add(a, b) { return a + b; }\n' -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' }, - embeddings: { enabled: false }, - typeInference: false, - typeInferenceCrossFile: false, - treeSitter: { - deferMissing: false - } - }, - tooling: { - autoEnableOnDetect: false - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for code map dot test'); - process.exit(buildResult.status ?? 1); -} - -const mapResult = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', - 'dot', - '--include', - 'imports,calls', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, encoding: 'utf8' } -); - -if (mapResult.status !== 0) { - console.error('Failed: map dot output'); - process.exit(mapResult.status ?? 1); -} - -const output = getCombinedOutput(mapResult); -if (!output.includes('PORT=')) { - console.error('Failed: dot output missing ports'); - process.exit(1); -} -if (!output.includes('->')) { - console.error('Failed: dot output missing edges'); - process.exit(1); -} -if (!output.includes('style="dashed"')) { - console.error('Failed: dot output missing import style'); - process.exit(1); -} - -console.log('code map dot tests passed'); - diff --git a/tests/indexing/map/code-map-graphviz-fallback.test.js b/tests/indexing/map/code-map-graphviz-fallback.test.js deleted file mode 100644 index 98748d039..000000000 --- a/tests/indexing/map/code-map-graphviz-fallback.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-graphviz'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); - -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'a.js'), - 'export function alpha() { return 1; }\n' -); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for graphviz fallback test'); - process.exit(buildResult.status ?? 1); -} - -const outPath = path.join(tempRoot, 'map.svg'); -const mapResult = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', 'svg', - '--repo', repoRoot, - '--out', outPath, - '--json' - ], - { - cwd: repoRoot, - env: { - ...env, - PATH: '', - Path: '' - }, - encoding: 'utf8' - } -); - -if (mapResult.status !== 0) { - console.error('Failed: graphviz fallback map output'); - process.exit(mapResult.status ?? 1); -} - -const payload = JSON.parse(mapResult.stdout || '{}'); -if (payload.format !== 'dot') { - console.error('Failed: expected dot fallback'); - process.exit(1); -} -if (!payload.outPath || !payload.outPath.endsWith('.dot')) { - console.error('Failed: expected .dot output path'); - process.exit(1); -} - -console.log('code map graphviz fallback tests passed'); - diff --git a/tests/indexing/map/code-map-guardrail-matrix.test.js b/tests/indexing/map/code-map-guardrail-matrix.test.js new file mode 100644 index 000000000..ce81c680d --- /dev/null +++ b/tests/indexing/map/code-map-guardrail-matrix.test.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); + +const createLargeMapFixture = async ({ tempName, functionCount }) => { + const tempRoot = resolveTestCachePath(root, tempName); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + + const funcs = []; + for (let index = 0; index < functionCount; index += 1) { + funcs.push(`export function fn${index}() { return ${index}; }`); + } + await fsPromises.writeFile(path.join(repoRoot, 'src', 'many.js'), funcs.join('\n')); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } + }); + + const buildResult = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--scm-provider', + 'none', + '--repo', + repoRoot + ], + 'build code-map guardrail fixture', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } + ); + assert.equal(buildResult.status, 0, 'expected code-map guardrail fixture build to succeed'); + + return { repoRoot, env }; +}; + +const cases = [ + { + name: 'map guardrails truncate oversized outputs and record dropped members', + async run() { + const { repoRoot, env } = await createLargeMapFixture({ + tempName: 'code-map-guardrails-matrix-guardrails', + functionCount: 120 + }); + const mapResult = runNode( + [ + path.join(root, 'tools', 'reports/report-code-map.js'), + '--format', + 'json', + '--repo', + repoRoot, + '--max-members-per-file', + '5', + '--max-files', + '1', + '--max-edges', + '2' + ], + 'report-code-map guardrail truncation', + repoRoot, + env, + { stdio: 'pipe', allowFailure: true } + ); + assert.equal(mapResult.status, 0); + const payload = JSON.parse(mapResult.stdout || '{}'); + const summary = payload.summary || {}; + const dropped = summary.dropped || {}; + assert.equal(summary.truncated, true); + assert.equal((dropped.members || 0) >= 1, true); + } + }, + { + name: 'map generation stays within the configured performance budget', + async run() { + const { repoRoot, env } = await createLargeMapFixture({ + tempName: 'code-map-guardrails-matrix-performance', + functionCount: 180 + }); + const budgetMs = Number(process.env.PAIROFCLEATS_TEST_CODE_MAP_BUDGET_MS); + const maxMs = Number.isFinite(budgetMs) ? budgetMs : 8000; + const startedAt = performance.now(); + const mapResult = runNode( + [path.join(root, 'tools', 'reports/report-code-map.js'), '--format', 'json', '--repo', repoRoot], + 'report-code-map performance budget', + repoRoot, + env, + { stdio: 'pipe', allowFailure: true } + ); + const elapsedMs = performance.now() - startedAt; + assert.equal(mapResult.status, 0); + assert.doesNotThrow(() => JSON.parse(mapResult.stdout || '{}')); + assert.equal(elapsedMs <= maxMs, true, `expected ${Math.round(elapsedMs)}ms <= ${maxMs}ms`); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('code map guardrail matrix test passed'); diff --git a/tests/indexing/map/code-map-guardrails.test.js b/tests/indexing/map/code-map-guardrails.test.js deleted file mode 100644 index c522b1a6a..000000000 --- a/tests/indexing/map/code-map-guardrails.test.js +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-guardrails'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); - -const funcs = []; -for (let i = 0; i < 120; i += 1) { - funcs.push(`export function fn${i}() { return ${i}; }`); -} -await fsPromises.writeFile(path.join(repoRoot, 'src', 'many.js'), funcs.join('\n')); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for guardrails test'); - process.exit(buildResult.status ?? 1); -} - -const mapResult = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', 'json', - '--repo', repoRoot, - '--max-members-per-file', '5', - '--max-files', '1', - '--max-edges', '2' - ], - { cwd: repoRoot, env, encoding: 'utf8' } -); - -if (mapResult.status !== 0) { - console.error('Failed: guardrails map output'); - process.exit(mapResult.status ?? 1); -} - -const payload = JSON.parse(mapResult.stdout || '{}'); -const summary = payload.summary || {}; -const dropped = summary.dropped || {}; -if (!summary.truncated) { - console.error('Failed: guardrails did not truncate'); - process.exit(1); -} -if (!dropped.members || dropped.members < 1) { - console.error('Failed: guardrails did not drop members'); - process.exit(1); -} - -console.log('code map guardrails tests passed'); diff --git a/tests/indexing/map/code-map-performance.test.js b/tests/indexing/map/code-map-performance.test.js deleted file mode 100644 index 444268d2a..000000000 --- a/tests/indexing/map/code-map-performance.test.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { performance } from 'node:perf_hooks'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'code-map-performance'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const funcs = []; -for (let i = 0; i < 180; i += 1) { - funcs.push(`export function fn${i}() { return ${i}; }`); -} -await fsPromises.writeFile(path.join(repoRoot, 'src', 'many.js'), funcs.join('\n')); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); - -if (buildResult.status !== 0) { - console.error('Failed: build index for performance test'); - process.exit(buildResult.status ?? 1); -} - -const budgetMs = Number(process.env.PAIROFCLEATS_TEST_CODE_MAP_BUDGET_MS); -const maxMs = Number.isFinite(budgetMs) ? budgetMs : 8000; - -const mapStart = performance.now(); -const mapResult = spawnSync( - process.execPath, - [path.join(root, 'tools', 'reports/report-code-map.js'), '--format', 'json', '--repo', repoRoot], - { cwd: repoRoot, env, encoding: 'utf8' } -); -const mapElapsed = performance.now() - mapStart; - -if (mapResult.status !== 0) { - console.error('Failed: map generator'); - process.exit(mapResult.status ?? 1); -} - -try { - JSON.parse(mapResult.stdout || '{}'); -} catch { - console.error('Failed: map output invalid JSON'); - process.exit(1); -} - -if (mapElapsed > maxMs) { - console.error(`Failed: map generation exceeded budget (${Math.round(mapElapsed)}ms > ${maxMs}ms).`); - process.exit(1); -} - -console.log(`code map performance ok (${Math.round(mapElapsed)}ms <= ${maxMs}ms)`); diff --git a/tests/indexing/map/map-build-symbol-identity.test.js b/tests/indexing/map/map-build-symbol-identity.test.js deleted file mode 100644 index 801ff28a9..000000000 --- a/tests/indexing/map/map-build-symbol-identity.test.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildCodeMap } from '../../../src/map/build-map.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'map-build-symbol-identity'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const chunkMeta = [ - { - id: 1, - start: 0, - end: 10, - file: 'src/alpha.js', - name: 'alpha', - kind: 'function', - chunkUid: 'uid-alpha', - metaV2: { - chunkUid: 'uid-alpha', - file: 'src/alpha.js', - name: 'alpha', - kind: 'function', - symbol: { - v: 1, - scheme: 'heur', - kindGroup: 'function', - qualifiedName: 'alpha', - symbolKey: 'src/alpha.js::alpha::function', - signatureKey: null, - scopedId: 'function|src/alpha.js::alpha::function|uid-alpha', - symbolId: 'sym1:heur:alpha' - } - } - } -]; - -await fs.writeFile(path.join(tempRoot, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); -await writePiecesManifest(tempRoot, [ - { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' } -]); - -const mapModel = await buildCodeMap({ - repoRoot: root, - indexDir: tempRoot, - options: { include: [], strict: false } -}); - -const member = mapModel.nodes?.[0]?.members?.[0]; -assert.ok(member, 'expected member in map'); -assert.equal(member.id, 'sym1:heur:alpha', 'expected member id to prefer symbolId'); -assert.notEqual(member.id, 'uid-alpha', 'expected member id to differ from chunkUid'); - -console.log('map build symbol identity test passed'); diff --git a/tests/indexing/metadata/external-docs.test.js b/tests/indexing/metadata/external-docs.test.js index 97277e78c..96292536e 100644 --- a/tests/indexing/metadata/external-docs.test.js +++ b/tests/indexing/metadata/external-docs.test.js @@ -2,9 +2,9 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -32,15 +32,23 @@ const env = applyTestEnv({ embeddings: 'stub', testConfig: { indexing: { - scm: { provider: 'none' } + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } } } }); -const buildResult = spawnSync( - process.execPath, +const buildResult = runNode( [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } + 'external docs build index', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } ); if (buildResult.status !== 0) { console.error('external docs test failed: build_index failed'); diff --git a/tests/indexing/metav2/metaV2-extracted-doc.test.js b/tests/indexing/metav2/metaV2-extracted-doc.test.js index 457fa0a00..17aa8e3d4 100644 --- a/tests/indexing/metav2/metaV2-extracted-doc.test.js +++ b/tests/indexing/metav2/metaV2-extracted-doc.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { buildMetaV2 } from '../../../src/index/metadata-v2.js'; -import { META_V2_SCHEMA_VERSION } from '../../../src/shared/meta-v2.js'; +import { META_V2_SCHEMA_VERSION } from '../../../src/index/metadata/meta-v2.js'; const chunk = { file: 'docs/sample.pdf', diff --git a/tests/indexing/metav2/metaV2-unknown-fields-ignored.test.js b/tests/indexing/metav2/metaV2-unknown-fields-ignored.test.js index 17dd9dad3..ae84b0db2 100644 --- a/tests/indexing/metav2/metaV2-unknown-fields-ignored.test.js +++ b/tests/indexing/metav2/metaV2-unknown-fields-ignored.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { normalizeMetaV2ForRead } from '../../../src/shared/meta-v2.js'; +import { normalizeMetaV2ForRead } from '../../../src/index/metadata/meta-v2.js'; const input = { schemaVersion: 99, diff --git a/tests/indexing/metrics/index-metrics-options.test.js b/tests/indexing/metrics/index-metrics-options.test.js deleted file mode 100644 index 60026dd99..000000000 --- a/tests/indexing/metrics/index-metrics-options.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getMetricsDir, toRealPathSync } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-metrics-options'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRootRaw, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -const repoRoot = toRealPathSync(repoRootRaw); - -await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'export const alpha = 1;\n'); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub' -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--mode', 'code', '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - console.error('Failed: build index for metrics options test'); - process.exit(buildResult.status ?? 1); -} - -const metricsDir = getMetricsDir(repoRoot); -const metricsPath = path.join(metricsDir, 'index-code.json'); -if (!fs.existsSync(metricsPath)) { - console.error(`Expected metrics file at ${metricsPath}`); - process.exit(1); -} - -const metrics = JSON.parse(await fsPromises.readFile(metricsPath, 'utf8')); -const compression = metrics?.artifacts?.compression || {}; -const extraction = metrics?.artifacts?.documentExtraction || {}; - -assert.equal(compression.enabled, false, 'expected compression.enabled to be false by default'); -if (!['gzip', 'zstd', null].includes(compression.mode ?? null)) { - console.error(`Expected compression.mode to be gzip or zstd, got ${compression.mode}`); - process.exit(1); -} -assert.equal(compression.keepRaw, false, 'expected compression.keepRaw=false by default'); -assert.equal(extraction.enabled, false, 'expected documentExtraction.enabled=false by default'); - -console.log('index metrics options test passed'); diff --git a/tests/indexing/metrics/index-options.test.js b/tests/indexing/metrics/index-options.test.js new file mode 100644 index 000000000..1dc5ce425 --- /dev/null +++ b/tests/indexing/metrics/index-options.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getMetricsDir, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'index-metrics-options'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRootRaw, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +const repoRoot = toRealPathSync(repoRootRaw); + +await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'export const alpha = 1;\n'); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const buildResult = runNode( + [path.join(root, 'build_index.js'), '--mode', 'code', '--stage', 'stage1', '--stub-embeddings', '--repo', repoRoot], + 'index metrics options build index', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (buildResult.status !== 0) { + console.error('Failed: build index for metrics options test'); + console.error(`Command: ${process.execPath} ${path.join(root, 'build_index.js')} --mode code --stage stage2 --stub-embeddings --repo ${repoRoot}`); + console.error(`Exit status: ${buildResult.status ?? 'null'} signal: ${buildResult.signal ?? 'null'}`); + if (buildResult.error) console.error(buildResult.error); + if (buildResult.stdout) console.error(buildResult.stdout.trim()); + if (buildResult.stderr) console.error(buildResult.stderr.trim()); + process.exit(buildResult.status ?? 1); +} + +const metricsDir = getMetricsDir(repoRoot); +const metricsPath = path.join(metricsDir, 'index-code.json'); +if (!fs.existsSync(metricsPath)) { + console.error(`Expected metrics file at ${metricsPath}`); + process.exit(1); +} + +const metrics = JSON.parse(await fsPromises.readFile(metricsPath, 'utf8')); +const compression = metrics?.artifacts?.compression || {}; +const extraction = metrics?.artifacts?.documentExtraction || {}; + +assert.equal(compression.enabled, false, 'expected compression.enabled to be false by default'); +if (!['gzip', 'zstd', null].includes(compression.mode ?? null)) { + console.error(`Expected compression.mode to be gzip or zstd, got ${compression.mode}`); + process.exit(1); +} +assert.equal(compression.keepRaw, false, 'expected compression.keepRaw=false by default'); +assert.equal(extraction.enabled, false, 'expected documentExtraction.enabled=false by default'); + +console.log('index metrics options test passed'); diff --git a/tests/indexing/ordering/appender-post-abort-settle.test.js b/tests/indexing/ordering/appender-post-abort-settle.test.js new file mode 100644 index 000000000..5c26b021c --- /dev/null +++ b/tests/indexing/ordering/appender-post-abort-settle.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; +import { STAGE1_SEQ_STATE } from '../../../src/index/build/indexer/steps/process-files/ordering.js'; + +ensureTestingEnv(process.env); + +const appender = buildOrderedAppender( + async () => {}, + {}, + { + expectedCount: 2, + startIndex: 0 + } +); + +appender.abort(new Error('forced abort')); + +const enqueueState = await Promise.race([ + appender.enqueue(0, { id: 0 }, null) + .then(() => ({ state: 'resolved', error: null })) + .catch((error) => ({ state: 'rejected', error })), + new Promise((resolve) => setTimeout(() => resolve({ state: 'timeout', error: null }), 100)) +]); + +assert.equal(enqueueState.state, 'rejected', 'expected post-abort enqueue to reject instead of hanging'); +assert.match(String(enqueueState.error?.message || ''), /forced abort/i, 'expected abort error to propagate'); +assert.equal(appender.snapshot().pendingCount, 0, 'expected no pending envelopes after post-abort enqueue'); +assert.equal( + appender.snapshot().headState, + STAGE1_SEQ_STATE.TERMINAL_CANCEL, + 'expected abort to terminalize outstanding ordered seqs instead of leaving the head non-terminal' +); + +console.log('ordered appender post-abort settle test passed'); diff --git a/tests/indexing/ordering/contract-matrix.test.js b/tests/indexing/ordering/contract-matrix.test.js new file mode 100644 index 000000000..c55ea5df5 --- /dev/null +++ b/tests/indexing/ordering/contract-matrix.test.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; + +import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; +import { STAGE1_SEQ_STATE } from '../../../src/index/build/indexer/steps/process-files/ordering.js'; + +{ + const runScenario = async ({ bucketSize }) => { + const processed = []; + const appender = buildOrderedAppender( + async (result) => { + processed.push(result.id); + }, + {}, + { + expectedCount: 6, + startIndex: 0, + bucketSize + } + ); + await Promise.all([ + appender.enqueue(0, { id: 0 }), + appender.enqueue(1, { id: 1 }), + appender.enqueue(2, { id: 2 }), + appender.enqueue(3, { id: 3 }), + appender.enqueue(4, { id: 4 }), + appender.enqueue(5, { id: 5 }) + ]); + return processed; + }; + + const bucketed = await runScenario({ bucketSize: 2 }); + const unbucketed = await runScenario({ bucketSize: 0 }); + assert.deepEqual(bucketed, [0, 1, 2, 3, 4, 5]); + assert.deepEqual(unbucketed, [0, 1, 2, 3, 4, 5]); +} + +{ + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const processed = []; + const appender = buildOrderedAppender( + async (result) => { + processed.push(result.id); + }, + {}, + { + expectedCount: 2, + startIndex: 0 + } + ); + + await appender.enqueue(0, { id: 0 }); + await appender.enqueue(1, { id: 1 }); + const lateReplay = appender.enqueue(0, { id: 'late-0' }); + const lateReplayState = await Promise.race([ + lateReplay.then(() => 'resolved', () => 'rejected'), + sleep(20).then(() => 'pending') + ]); + assert.equal(lateReplayState, 'resolved'); + assert.deepEqual(processed, [0, 1]); + appender.assertCompletion(); +} + +{ + ensureTestingEnv(process.env); + const childScript = [ + "import { buildOrderedAppender } from './src/index/build/indexer/steps/process-files/ordered.js';", + 'const appender = buildOrderedAppender(async () => {}, {}, {', + ' expectedCount: 6,', + ' startIndex: 0,', + ' maxPendingBeforeBackpressure: 2', + '});', + 'void appender.enqueue(1, { id: 1 }).catch(() => {});', + 'void appender.enqueue(2, { id: 2 }).catch(() => {});', + 'void appender.enqueue(3, { id: 3 }).catch(() => {});', + 'await appender.waitForCapacity({ orderIndex: 20, bypassWindow: 0 });' + ].join('\n'); + + const child = spawn( + process.execPath, + ['--input-type=module', '-e', childScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(child.exitCode, null, `expected child to remain alive while blocked; stderr=${stderr || ''}`); + child.kill(); + const closeResult = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => resolve({ exitCode, signal })); + }); + assert.notEqual(closeResult.exitCode, 13, `expected keepalive to avoid exit 13; stderr=${stderr || ''}`); +} + +{ + applyTestEnv(); + const committed = []; + const appender = buildOrderedAppender( + async (result) => { + committed.push(result.id); + }, + {}, + { + expectedIndices: [0, 1] + } + ); + + appender.noteInFlight(0, 100); + appender.noteInFlight(1, 101); + const seq1Done = appender.enqueue(1, { id: 1 }, null); + const resetCount = appender.resetForRetry([0, 1]); + assert.equal(resetCount, 1); + const snapshotAfterReset = appender.snapshot(); + assert.equal(snapshotAfterReset.nextIndex, 0); + assert.equal(snapshotAfterReset.inFlightCount, 0); + assert.equal(snapshotAfterReset.headState, STAGE1_SEQ_STATE.UNSEEN); + const seq0Done = appender.enqueue(0, { id: 0 }, null); + await Promise.all([seq0Done, seq1Done]); + await appender.drain(); + appender.assertCompletion(); + assert.deepEqual(committed, [0, 1]); +} + +console.log('indexing ordering contract matrix test passed'); diff --git a/tests/indexing/ordering/ordered-appender-bucketed-watermark.test.js b/tests/indexing/ordering/ordered-appender-bucketed-watermark.test.js deleted file mode 100644 index c98fb54fd..000000000 --- a/tests/indexing/ordering/ordered-appender-bucketed-watermark.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; - -const runScenario = async ({ bucketSize }) => { - const processed = []; - const appender = buildOrderedAppender( - async (result) => { - processed.push(result.id); - }, - {}, - { - expectedCount: 6, - startIndex: 0, - bucketSize - } - ); - const tasks = [ - appender.enqueue(0, { id: 0 }), - appender.enqueue(1, { id: 1 }), - appender.enqueue(2, { id: 2 }), - appender.enqueue(3, { id: 3 }), - appender.enqueue(4, { id: 4 }), - appender.enqueue(5, { id: 5 }) - ]; - await Promise.all(tasks); - return { processed }; -}; - -const bucketed = await runScenario({ bucketSize: 2 }); -assert.deepEqual(bucketed.processed, [0, 1, 2, 3, 4, 5], 'expected deterministic flush order with bucketing'); - -const unbucketed = await runScenario({ bucketSize: 0 }); -assert.deepEqual(unbucketed.processed, [0, 1, 2, 3, 4, 5], 'expected deterministic flush order without bucketing'); - -console.log('ordered appender bucketed watermark test passed'); diff --git a/tests/indexing/ordering/ordered-appender-flush-timeout.test.js b/tests/indexing/ordering/ordered-appender-flush-timeout.test.js index 41dfa9cdc..a106bb532 100644 --- a/tests/indexing/ordering/ordered-appender-flush-timeout.test.js +++ b/tests/indexing/ordering/ordered-appender-flush-timeout.test.js @@ -1,5 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; @@ -54,4 +55,34 @@ if (typeof releaseBlockedWrite === 'function') { releaseBlockedWrite(); } +const childScript = [ + "import { buildOrderedAppender } from './src/index/build/indexer/steps/process-files/ordered.js';", + 'const blockedWrite = new Promise(() => {});', + 'const appender = buildOrderedAppender(async () => blockedWrite, {}, { expectedCount: 1, startIndex: 0, flushTimeoutMs: 25, stallMs: 0 });', + 'try {', + " await appender.enqueue(0, { id: 0 });", + '} catch {}' +].join('\n'); +const child = spawn( + process.execPath, + ['--input-type=module', '-e', childScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let childStderr = ''; +child.stderr.on('data', (chunk) => { + childStderr += String(chunk); +}); +const childClose = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => resolve({ exitCode, signal })); +}); +assert.notEqual( + childClose.exitCode, + 13, + `expected ordered flush timeout to exit cleanly without leaving apply wait alive; stderr=${childStderr || ''}` +); + console.log('ordered appender flush-timeout test passed'); diff --git a/tests/indexing/ordering/ordered-appender-note-inflight-without-dispatch.test.js b/tests/indexing/ordering/ordered-appender-note-inflight-without-dispatch.test.js new file mode 100644 index 000000000..c05d4d7e8 --- /dev/null +++ b/tests/indexing/ordering/ordered-appender-note-inflight-without-dispatch.test.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; + +ensureTestingEnv(process.env); + +const committed = []; +const appender = buildOrderedAppender( + async (_result, _state, _shardMeta, context = {}) => { + committed.push(context.orderIndex); + }, + {}, + { + expectedCount: 2, + startIndex: 0 + } +); + +appender.noteInFlight(0, 101); +appender.noteInFlight(1, 102); + +await Promise.all([ + appender.enqueue(1, { id: 1 }, null), + appender.enqueue(0, { id: 0 }, null) +]); + +assert.deepEqual(committed, [0, 1], 'expected noteInFlight-only terminalization to commit in order'); +assert.equal(appender.snapshot().pendingCount, 0, 'expected no pending envelopes after commit'); +appender.assertCompletion(); + +console.log('ordered appender note-inflight without dispatch test passed'); diff --git a/tests/indexing/ordering/ordered-appender-stale-drop.test.js b/tests/indexing/ordering/ordered-appender-stale-drop.test.js deleted file mode 100644 index ec55cad5d..000000000 --- a/tests/indexing/ordering/ordered-appender-stale-drop.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const processed = []; - -const appender = buildOrderedAppender( - async (result) => { - processed.push(result.id); - }, - {}, - { - expectedCount: 2, - startIndex: 0 - } -); - -await appender.enqueue(0, { id: 0 }); -await appender.enqueue(1, { id: 1 }); -const lateReplay = appender.enqueue(0, { id: 'late-0' }); -const lateReplayState = await Promise.race([ - lateReplay.then(() => 'resolved', () => 'rejected'), - sleep(20).then(() => 'pending') -]); -assert.equal( - lateReplayState, - 'pending', - 'expected stale replay enqueue to remain unresolved once cursor has advanced past the seq' -); -appender.abort(new Error('test cleanup')); -await assert.rejects( - lateReplay, - (error) => (error?.message || '').includes('test cleanup'), - 'expected stale replay to reject after appender abort' -); - -assert.deepEqual(processed, [0, 1], 'stale result should not be appended once index advanced'); - -console.log('ordered appender stale-drop test passed'); diff --git a/tests/indexing/ordering/ordered-completion-tracker.test.js b/tests/indexing/ordering/ordered-completion-tracker.test.js index 48d5c06af..8e50f7e61 100644 --- a/tests/indexing/ordering/ordered-completion-tracker.test.js +++ b/tests/indexing/ordering/ordered-completion-tracker.test.js @@ -1,7 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { waitForChildExit } from '../../helpers/process-lifecycle.js'; import { createOrderedCompletionTracker } from '../../../src/index/build/indexer/steps/process-files.js'; +import { runWithTimeout } from '../../../src/shared/promise-timeout.js'; ensureTestingEnv(process.env); @@ -83,6 +86,24 @@ await assert.rejects( 'expected wait timeout to produce deterministic timeout error' ); +const nestedTimeoutTracker = createOrderedCompletionTracker(); +nestedTimeoutTracker.track(new Promise(() => {})); +await assert.rejects( + () => runWithTimeout( + (signal) => nestedTimeoutTracker.wait({ signal }), + { + timeoutMs: 25, + errorFactory: () => { + const error = new Error('outer ordered completion timeout'); + error.code = 'ORDERED_COMPLETION_OUTER_TIMEOUT'; + return error; + } + } + ), + (err) => err?.code === 'ORDERED_COMPLETION_OUTER_TIMEOUT', + 'expected outer timeout to abort inner ordered completion wait' +); + const abortTracker = createOrderedCompletionTracker(); abortTracker.track(new Promise(() => {})); const abortController = new AbortController(); @@ -93,4 +114,108 @@ await assert.rejects( 'expected wait abort signal to reject with abort reason' ); +const keepaliveScript = [ + "import { createOrderedCompletionTracker } from './src/shared/concurrency/ordered-completion.js';", + 'const tracker = createOrderedCompletionTracker();', + 'tracker.track(new Promise(() => {}));', + 'await tracker.wait();' +].join('\n'); +const keepaliveChild = spawn( + process.execPath, + ['--input-type=module', '-e', keepaliveScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let keepaliveStderr = ''; +keepaliveChild.stderr.on('data', (chunk) => { + keepaliveStderr += String(chunk); +}); +await new Promise((resolve) => setTimeout(resolve, 200)); +assert.equal( + keepaliveChild.exitCode, + null, + `expected ordered completion wait child to remain alive while completion is pending; stderr=${keepaliveStderr || ''}` +); +keepaliveChild.kill(); +const keepaliveExitCode = await waitForChildExit(keepaliveChild, { + timeoutMs: 5000, + forceSignal: 'SIGKILL' +}); +assert.notEqual( + keepaliveExitCode, + 13, + `expected ordered completion keepalive to avoid unsettled top-level await exit 13; stderr=${keepaliveStderr || ''}` +); + +const preWaitKeepaliveScript = [ + "import { createOrderedCompletionTracker } from './src/shared/concurrency/ordered-completion.js';", + 'const tracker = createOrderedCompletionTracker();', + 'tracker.track(new Promise(() => {}));', + "await new Promise((resolve) => setTimeout(resolve, 500));" +].join('\n'); +const preWaitKeepaliveChild = spawn( + process.execPath, + ['--input-type=module', '-e', preWaitKeepaliveScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let preWaitKeepaliveStderr = ''; +preWaitKeepaliveChild.stderr.on('data', (chunk) => { + preWaitKeepaliveStderr += String(chunk); +}); +await new Promise((resolve) => setTimeout(resolve, 200)); +assert.equal( + preWaitKeepaliveChild.exitCode, + null, + `expected tracked completion to keep process alive before wait() is called; stderr=${preWaitKeepaliveStderr || ''}` +); +preWaitKeepaliveChild.kill(); +const preWaitKeepaliveExitCode = await waitForChildExit(preWaitKeepaliveChild, { + timeoutMs: 5000, + forceSignal: 'SIGKILL' +}); +assert.notEqual( + preWaitKeepaliveExitCode, + 13, + `expected tracked completion keepalive to avoid unsettled top-level await exit 13 before wait(); stderr=${preWaitKeepaliveStderr || ''}` +); + +const nestedTimeoutKeepaliveScript = [ + "import { createOrderedCompletionTracker } from './src/shared/concurrency/ordered-completion.js';", + "import { runWithTimeout } from './src/shared/promise-timeout.js';", + 'const tracker = createOrderedCompletionTracker();', + 'tracker.track(new Promise(() => {}));', + 'try {', + ' await runWithTimeout((signal) => tracker.wait({ signal }), {', + ' timeoutMs: 25,', + ' errorFactory: () => { const error = new Error(\"outer timeout\"); error.code = \"ORDERED_COMPLETION_OUTER_TIMEOUT\"; return error; }', + ' });', + '} catch {}' +].join('\n'); +const nestedTimeoutKeepaliveChild = spawn( + process.execPath, + ['--input-type=module', '-e', nestedTimeoutKeepaliveScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let nestedTimeoutKeepaliveStderr = ''; +nestedTimeoutKeepaliveChild.stderr.on('data', (chunk) => { + nestedTimeoutKeepaliveStderr += String(chunk); +}); +const nestedTimeoutKeepaliveExitCode = await waitForChildExit(nestedTimeoutKeepaliveChild, { + timeoutMs: 5000, + forceSignal: 'SIGKILL' +}); +assert.notEqual( + nestedTimeoutKeepaliveExitCode, + 13, + `expected outer timeout to terminate cleanly instead of leaving ordered completion wait alive; stderr=${nestedTimeoutKeepaliveStderr || ''}` +); + console.log('ordered completion tracker test passed'); diff --git a/tests/indexing/perf-event-log/perf-event-log-file-write.test.js b/tests/indexing/perf-event-log/file-write.test.js similarity index 100% rename from tests/indexing/perf-event-log/perf-event-log-file-write.test.js rename to tests/indexing/perf-event-log/file-write.test.js diff --git a/tests/indexing/perf-event-log/perf-event-log-heavy-file-aggregate.test.js b/tests/indexing/perf-event-log/heavy-file-aggregate.test.js similarity index 100% rename from tests/indexing/perf-event-log/perf-event-log-heavy-file-aggregate.test.js rename to tests/indexing/perf-event-log/heavy-file-aggregate.test.js diff --git a/tests/indexing/piece-assembly/assemble-pieces-no-guess.test.js b/tests/indexing/piece-assembly/assemble-pieces-no-guess.test.js index 49e82327d..025f209f3 100644 --- a/tests/indexing/piece-assembly/assemble-pieces-no-guess.test.js +++ b/tests/indexing/piece-assembly/assemble-pieces-no-guess.test.js @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -31,8 +31,7 @@ await fs.writeFile( const outDir = path.join(cacheRoot, 'out', 'index-code'); const assemblePath = path.join(root, 'tools', 'index', 'assemble-pieces.js'); -const result = spawnSync( - process.execPath, +const result = runNode( [ assemblePath, '--repo', @@ -45,7 +44,10 @@ const result = spawnSync( inputDir, '--force' ], - { encoding: 'utf8' } + 'assemble-pieces missing manifest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); assert.notEqual(result.status, 0, 'expected assemble-pieces to fail without manifest'); diff --git a/tests/indexing/piece-assembly/core.test.js b/tests/indexing/piece-assembly/core.test.js new file mode 100644 index 000000000..9ac16d83f --- /dev/null +++ b/tests/indexing/piece-assembly/core.test.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv, DEFAULT_TEST_ENV_KEYS, syncProcessEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; +import { loadChunkMeta, loadGraphRelationsSync, loadTokenPostings } from '../../../src/shared/artifact-io.js'; +import { assembleIndexPieces } from '../../../src/index/build/piece-assembly.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { loadPiecesManifestPieces, resolvePiecesManifestPath } from '../../helpers/pieces-manifest.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const buildIndexPath = path.join(root, 'build_index.js'); +const assemblePath = path.join(root, 'tools', 'index', 'assemble-pieces.js'); + +const cacheRoot = resolveTestCachePath(root, 'piece-assembly'); +const fixtureRoot = path.join(cacheRoot, 'source-repo'); +const cacheA = path.join(cacheRoot, 'a'); +const cacheB = path.join(cacheRoot, 'b'); +const outputMono = path.join(cacheRoot, 'assembled-single', 'index-code'); +const outputDir = path.join(cacheRoot, 'assembled', 'index-code'); + +await rmDirRecursive(cacheRoot, { retries: 8, delayMs: 150 }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.mkdir(path.join(fixtureRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(fixtureRoot, 'src', 'alpha.js'), + [ + 'export function alpha(value = 1) {', + ' return value + 1;', + '}', + '' + ].join('\n'), + 'utf8' +); +await fsPromises.writeFile( + path.join(fixtureRoot, 'src', 'beta.js'), + [ + 'import { alpha } from "./alpha.js";', + 'export function beta() {', + ' return alpha(2);', + '}', + '' + ].join('\n'), + 'utf8' +); +await fsPromises.writeFile( + path.join(fixtureRoot, 'src', 'gamma.js'), + [ + 'import { beta } from "./beta.js";', + 'export const gamma = () => beta();', + '' + ].join('\n'), + 'utf8' +); +await fsPromises.writeFile( + path.join(fixtureRoot, 'README.md'), + '# Piece assembly fixture\n\nsmall synthetic fixture\n', + 'utf8' +); + +const baseEnv = { + ...process.env, PAIROFCLEATS_EMBEDDINGS: 'stub', + PAIROFCLEATS_TEST_CONFIG: JSON.stringify({ + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }), + PAIROFCLEATS_WORKER_POOL: 'off' +}; +syncProcessEnv(baseEnv, [...DEFAULT_TEST_ENV_KEYS]); + +const logChunkMetaDiff = (label, left, right) => { + if (!left || !right) return; + const id = left.chunkId || left.metaV2?.chunkId || null; + const file = left.file || right.file || left.metaV2?.file || right.metaV2?.file || null; + const name = left.name || right.name || left.metaV2?.name || right.metaV2?.name || null; + console.error(`[piece-assembly] ${label} mismatch for ${file || 'unknown'} (${name || 'unknown'}, ${id || 'unknown'}).`); + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of Array.from(keys).sort()) { + const a = left[key]; + const b = right[key]; + if (JSON.stringify(a) === JSON.stringify(b)) continue; + if (key === 'metaV2') { + const metaKeys = new Set([ + ...Object.keys(a || {}), + ...Object.keys(b || {}) + ]); + for (const metaKey of Array.from(metaKeys).sort()) { + const av = a?.[metaKey]; + const bv = b?.[metaKey]; + if (JSON.stringify(av) === JSON.stringify(bv)) continue; + console.error(`[piece-assembly] metaV2.${metaKey} diff`, { a: av, b: bv }); + } + continue; + } + console.error(`[piece-assembly] ${key} diff`, { a, b }); + } +}; + +const run = (label, args, env, cwd = fixtureRoot) => { + const result = runNode(args, label, cwd, env, { + stdio: 'inherit', + allowFailure: true + }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +const assembleDirect = async ({ inputs, outDir, repoRoot, userConfig, env, label }) => { + await rmDirRecursive(outDir, { retries: 8, delayMs: 150 }); + await fsPromises.mkdir(outDir, { recursive: true }); + syncProcessEnv(env, [...DEFAULT_TEST_ENV_KEYS]); + try { + await assembleIndexPieces({ + inputs, + outDir, + root: repoRoot, + mode: 'code', + userConfig, + strict: true, + log: () => {} + }); + } catch (err) { + console.error(`Failed: ${label}`); + console.error(err?.stack || err?.message || err); + process.exit(1); + } +}; + +const buildCodeArgs = (repoRoot) => [ + buildIndexPath, + '--stub-embeddings', + '--stage', + 'stage1', + '--scm-provider', + 'none', + '--mode', + 'code', + '--repo', + repoRoot +]; + +run('build_index (A)', buildCodeArgs(fixtureRoot), { + ...baseEnv, + PAIROFCLEATS_CACHE_ROOT: cacheA +}); +await fsPromises.cp(cacheA, cacheB, { recursive: true }); + +const userConfig = loadUserConfig(fixtureRoot); +process.env.PAIROFCLEATS_CACHE_ROOT = cacheA; +const indexA = getIndexDir(fixtureRoot, 'code', userConfig); +process.env.PAIROFCLEATS_CACHE_ROOT = cacheB; +const indexB = getIndexDir(fixtureRoot, 'code', userConfig); + +await assembleDirect({ + inputs: [indexA], + outDir: outputMono, + repoRoot: fixtureRoot, + userConfig, + env: { + ...baseEnv, + PAIROFCLEATS_CACHE_ROOT: cacheRoot + }, + label: 'assemble-pieces direct (single)' +}); + +const assembleStart = Date.now(); +run('assemble-pieces (merge)', [ + assemblePath, + '--repo', + fixtureRoot, + '--mode', + 'code', + '--out', + outputDir, + '--input', + indexA, + '--input', + indexB, + '--force' +], { + ...baseEnv, + PAIROFCLEATS_CACHE_ROOT: cacheRoot +}); +const assembleDuration = Date.now() - assembleStart; +if (assembleDuration > 30000) { + console.error(`assemble-pieces took too long (${assembleDuration}ms).`); + process.exit(1); +} + +const serializeTokenIndex = (tokenIndex) => JSON.stringify({ + vocab: tokenIndex?.vocab || [], + postings: tokenIndex?.postings || [], + docLengths: tokenIndex?.docLengths || [] +}); + +const chunksAList = await loadChunkMeta(indexA); +const chunksA = chunksAList.length; +const chunksB = (await loadChunkMeta(indexB)).length; +const chunksOutList = await loadChunkMeta(outputDir); +const chunksOut = chunksOutList.length; +if (chunksOut !== chunksA + chunksB) { + console.error(`Expected merged chunk count ${chunksA + chunksB}, got ${chunksOut}`); + process.exit(1); +} + +const chunksMonoList = await loadChunkMeta(outputMono); +const normalizeChunks = (chunks) => ( + Array.isArray(chunks) + ? chunks.map((chunk) => { + if (!chunk || typeof chunk !== 'object') return chunk; + if (!chunk.metaV2 || typeof chunk.metaV2 !== 'object') return chunk; + const metaV2 = { ...chunk.metaV2 }; + delete metaV2.relations; + delete metaV2.usages; + return { ...chunk, metaV2 }; + }) + : chunks +); +const normalizedA = normalizeChunks(chunksAList); +const normalizedMono = normalizeChunks(chunksMonoList); +if (stableStringify(normalizedMono) !== stableStringify(normalizedA)) { + const limit = Math.min(normalizedA.length, normalizedMono.length); + for (let i = 0; i < limit; i += 1) { + if (stableStringify(normalizedA[i]) !== stableStringify(normalizedMono[i])) { + logChunkMetaDiff('chunk_meta', normalizedA[i], normalizedMono[i]); + break; + } + } + console.error('Assembled single index does not match monolithic chunk_meta.'); + process.exit(1); +} + +const tokenMono = loadTokenPostings(indexA); +const tokenSingle = loadTokenPostings(outputMono); +if (serializeTokenIndex(tokenMono) !== serializeTokenIndex(tokenSingle)) { + console.error('Assembled single index does not match monolithic token_postings.'); + process.exit(1); +} + +const tokenIndex = loadTokenPostings(outputDir); +if (!Array.isArray(tokenIndex?.docLengths) || tokenIndex.docLengths.length !== chunksOut) { + console.error('Merged token_postings docLengths mismatch.'); + process.exit(1); +} +if (!Array.isArray(tokenIndex?.vocab) || !Array.isArray(tokenIndex?.postings)) { + console.error('Merged token_postings missing vocab/postings.'); + process.exit(1); +} +if (tokenIndex.vocab.length !== tokenIndex.postings.length) { + console.error('Merged token_postings vocab/postings length mismatch.'); + process.exit(1); +} +let minDocId = Number.POSITIVE_INFINITY; +let maxDocId = -1; +for (const posting of tokenIndex.postings) { + if (!Array.isArray(posting)) continue; + for (const entry of posting) { + if (!Array.isArray(entry)) continue; + const docId = entry[0]; + if (!Number.isFinite(docId)) continue; + if (docId < minDocId) minDocId = docId; + if (docId > maxDocId) maxDocId = docId; + } +} +if (maxDocId < chunksA || maxDocId >= chunksOut) { + console.error('Merged token_postings docIds not offset correctly.'); + process.exit(1); +} +if (minDocId < 0) { + console.error('Merged token_postings docIds should be non-negative.'); + process.exit(1); +} + +const manifestPath = resolvePiecesManifestPath(outputDir); +if (!fs.existsSync(manifestPath)) { + console.error(`Missing pieces manifest: ${manifestPath}`); + process.exit(1); +} + +const piecesAll = loadPiecesManifestPieces(indexA); +const piecesOut = loadPiecesManifestPieces(outputDir); +const normalizePiece = (entry) => { + if (!entry || typeof entry !== 'object') return entry; + const normalized = { ...entry }; + delete normalized.tier; + delete normalized.layout; + if (normalized.statError == null) delete normalized.statError; + if (normalized.checksumError == null) delete normalized.checksumError; + if (normalized.bytes == null || Number.isFinite(normalized.bytes)) delete normalized.bytes; + if (normalized.checksum == null || typeof normalized.checksum === 'string') delete normalized.checksum; + if (normalized.mtime == null || Number.isFinite(normalized.mtime)) delete normalized.mtime; + return normalized; +}; +const sortPieces = (pieces) => pieces.slice().sort((a, b) => { + const nameA = `${a?.name || ''}`; + const nameB = `${b?.name || ''}`; + if (nameA !== nameB) return nameA.localeCompare(nameB); + const pathA = `${a?.path || ''}`; + const pathB = `${b?.path || ''}`; + if (pathA !== pathB) return pathA.localeCompare(pathB); + const typeA = `${a?.type || ''}`; + const typeB = `${b?.type || ''}`; + return typeA.localeCompare(typeB); +}); +const stripManifestEntries = (pieces) => pieces.filter((entry) => !( + (entry?.type === 'stats' && entry?.name === 'filelists') + || (entry?.type === 'stats' && entry?.name === 'index_state') + || (entry?.type === 'relations' && entry?.name === 'graph_relations') + || (entry?.type === 'relations' && entry?.name === 'graph_relations_meta') + || (entry?.type === 'relations' && entry?.name === 'graph_relations_offsets') + || (entry?.type === 'relations' && entry?.name === 'graph_relations_csr') + || entry?.name === 'import_resolution_graph' + || entry?.name === 'dense_vectors_hnsw_meta' + || entry?.name === 'dense_vectors_lancedb_meta' + || entry?.name === 'dense_vectors_code_hnsw_meta' + || entry?.name === 'dense_vectors_doc_hnsw_meta' + || entry?.name === 'dense_vectors_code_lancedb_meta' + || entry?.name === 'dense_vectors_doc_lancedb_meta' + || entry?.name === 'dense_vectors_hnsw' + || entry?.name === 'dense_vectors_lancedb' + || entry?.name === 'dense_vectors_code_hnsw' + || entry?.name === 'dense_vectors_doc_hnsw' + || entry?.name === 'dense_vectors_code_lancedb' + || entry?.name === 'dense_vectors_doc_lancedb' + || entry?.name === 'vfs_manifest' + || entry?.name === 'vfs_manifest_bloom' + || entry?.name === 'vfs_manifest_index' + || entry?.name === 'lexicon_relation_filter_report' + || entry?.name === 'risk_interprocedural_stats' +)); +const normalizedAll = sortPieces(stripManifestEntries(piecesAll).map(normalizePiece)); +const normalizedOut = sortPieces(stripManifestEntries(piecesOut).map(normalizePiece)); +if (!normalizedAll.length || !normalizedOut.length) { + console.error('Piece assembly produced an empty comparable pieces manifest.'); + process.exit(1); +} + +const graphAll = loadGraphRelationsSync(indexA); +const graphOut = loadGraphRelationsSync(outputDir); +delete graphAll.generatedAt; +delete graphOut.generatedAt; +if (!graphOut || typeof graphOut !== 'object') { + console.error('Piece assembly merge produced missing graph_relations output.'); + process.exit(1); +} + +console.log('Piece assembly tests passed'); + diff --git a/tests/indexing/piece-assembly/piece-assembly.test.js b/tests/indexing/piece-assembly/piece-assembly.test.js deleted file mode 100644 index bee1549e2..000000000 --- a/tests/indexing/piece-assembly/piece-assembly.test.js +++ /dev/null @@ -1,432 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv, DEFAULT_TEST_ENV_KEYS, syncProcessEnv } from '../../helpers/test-env.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; -import { loadChunkMeta, loadGraphRelationsSync, loadTokenPostings } from '../../../src/shared/artifact-io.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { loadPiecesManifestPieces, resolvePiecesManifestPath } from '../../helpers/pieces-manifest.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const buildIndexPath = path.join(root, 'build_index.js'); -const assemblePath = path.join(root, 'tools', 'index', 'assemble-pieces.js'); - -if (!fs.existsSync(fixtureRoot)) { - console.error(`Missing fixture: ${fixtureRoot}`); - process.exit(1); -} - -const cacheRoot = resolveTestCachePath(root, 'piece-assembly'); -const cacheA = path.join(cacheRoot, 'a'); -const cacheB = path.join(cacheRoot, 'b'); -const outputMono = path.join(cacheRoot, 'assembled-single', 'index-code'); -const outputDir = path.join(cacheRoot, 'assembled', 'index-code'); -const outputDir2 = path.join(cacheRoot, 'assembled-repeat', 'index-code'); - -await rmDirRecursive(cacheRoot, { retries: 8, delayMs: 150 }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const baseEnv = { - ...process.env, PAIROFCLEATS_EMBEDDINGS: 'stub', - PAIROFCLEATS_TEST_CONFIG: JSON.stringify({ - tooling: { autoEnableOnDetect: false } - }) -}; -syncProcessEnv(baseEnv, [...DEFAULT_TEST_ENV_KEYS]); - -const logChunkMetaDiff = (label, left, right) => { - if (!left || !right) return; - const id = left.chunkId || left.metaV2?.chunkId || null; - const file = left.file || right.file || left.metaV2?.file || right.metaV2?.file || null; - const name = left.name || right.name || left.metaV2?.name || right.metaV2?.name || null; - console.error(`[piece-assembly] ${label} mismatch for ${file || 'unknown'} (${name || 'unknown'}, ${id || 'unknown'}).`); - const keys = new Set([...Object.keys(left), ...Object.keys(right)]); - for (const key of Array.from(keys).sort()) { - const a = left[key]; - const b = right[key]; - if (JSON.stringify(a) === JSON.stringify(b)) continue; - if (key === 'metaV2') { - const metaKeys = new Set([ - ...Object.keys(a || {}), - ...Object.keys(b || {}) - ]); - for (const metaKey of Array.from(metaKeys).sort()) { - const av = a?.[metaKey]; - const bv = b?.[metaKey]; - if (JSON.stringify(av) === JSON.stringify(bv)) continue; - console.error(`[piece-assembly] metaV2.${metaKey} diff`, { a: av, b: bv }); - } - continue; - } - console.error(`[piece-assembly] ${key} diff`, { a, b }); - } -}; - -const run = (label, args, env) => { - const result = spawnSync(process.execPath, args, { - cwd: fixtureRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run('build_index (A)', [buildIndexPath, '--stub-embeddings', '--scm-provider', 'none', '--mode', 'code', '--repo', fixtureRoot], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheA -}); -run('build_index (B)', [buildIndexPath, '--stub-embeddings', '--scm-provider', 'none', '--mode', 'code', '--repo', fixtureRoot], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheB -}); - -const userConfig = loadUserConfig(fixtureRoot); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheA; -const indexA = getIndexDir(fixtureRoot, 'code', userConfig); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheB; -const indexB = getIndexDir(fixtureRoot, 'code', userConfig); - -run('assemble-pieces (single)', [ - assemblePath, - '--repo', - fixtureRoot, - '--mode', - 'code', - '--out', - outputMono, - '--input', - indexA, - '--force' -], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheRoot -}); - -const assembleStart = Date.now(); -run('assemble-pieces (merge)', [ - assemblePath, - '--repo', - fixtureRoot, - '--mode', - 'code', - '--out', - outputDir, - '--input', - indexA, - '--input', - indexB, - '--force' -], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheRoot -}); -const assembleDuration = Date.now() - assembleStart; -if (assembleDuration > 30000) { - console.error(`assemble-pieces took too long (${assembleDuration}ms).`); - process.exit(1); -} - -const serializeTokenIndex = (tokenIndex) => JSON.stringify({ - vocab: tokenIndex?.vocab || [], - postings: tokenIndex?.postings || [], - docLengths: tokenIndex?.docLengths || [] -}); - -const chunksAList = await loadChunkMeta(indexA); -const chunksA = chunksAList.length; -const chunksB = (await loadChunkMeta(indexB)).length; -const chunksOutList = await loadChunkMeta(outputDir); -const chunksOut = chunksOutList.length; -if (chunksOut !== chunksA + chunksB) { - console.error(`Expected merged chunk count ${chunksA + chunksB}, got ${chunksOut}`); - process.exit(1); -} - -const chunksMonoList = await loadChunkMeta(outputMono); -const normalizeChunks = (chunks) => ( - Array.isArray(chunks) - ? chunks.map((chunk) => { - if (!chunk || typeof chunk !== 'object') return chunk; - if (!chunk.metaV2 || typeof chunk.metaV2 !== 'object') return chunk; - const metaV2 = { ...chunk.metaV2 }; - delete metaV2.relations; - delete metaV2.usages; - return { ...chunk, metaV2 }; - }) - : chunks -); -const normalizedA = normalizeChunks(chunksAList); -const normalizedMono = normalizeChunks(chunksMonoList); -if (stableStringify(normalizedMono) !== stableStringify(normalizedA)) { - const limit = Math.min(normalizedA.length, normalizedMono.length); - for (let i = 0; i < limit; i += 1) { - if (stableStringify(normalizedA[i]) !== stableStringify(normalizedMono[i])) { - logChunkMetaDiff('chunk_meta', normalizedA[i], normalizedMono[i]); - break; - } - } - console.error('Assembled single index does not match monolithic chunk_meta.'); - process.exit(1); -} - -const tokenMono = loadTokenPostings(indexA); -const tokenSingle = loadTokenPostings(outputMono); -if (serializeTokenIndex(tokenMono) !== serializeTokenIndex(tokenSingle)) { - console.error('Assembled single index does not match monolithic token_postings.'); - process.exit(1); -} - -const tokenIndex = loadTokenPostings(outputDir); -if (!Array.isArray(tokenIndex?.docLengths) || tokenIndex.docLengths.length !== chunksOut) { - console.error('Merged token_postings docLengths mismatch.'); - process.exit(1); -} -if (!Array.isArray(tokenIndex?.vocab) || !Array.isArray(tokenIndex?.postings)) { - console.error('Merged token_postings missing vocab/postings.'); - process.exit(1); -} -if (tokenIndex.vocab.length !== tokenIndex.postings.length) { - console.error('Merged token_postings vocab/postings length mismatch.'); - process.exit(1); -} -let minDocId = Number.POSITIVE_INFINITY; -let maxDocId = -1; -for (const posting of tokenIndex.postings) { - if (!Array.isArray(posting)) continue; - for (const entry of posting) { - if (!Array.isArray(entry)) continue; - const docId = entry[0]; - if (!Number.isFinite(docId)) continue; - if (docId < minDocId) minDocId = docId; - if (docId > maxDocId) maxDocId = docId; - } -} -if (maxDocId < chunksA || maxDocId >= chunksOut) { - console.error('Merged token_postings docIds not offset correctly.'); - process.exit(1); -} -if (minDocId < 0) { - console.error('Merged token_postings docIds should be non-negative.'); - process.exit(1); -} - -run('assemble-pieces (repeat)', [ - assemblePath, - '--repo', - fixtureRoot, - '--mode', - 'code', - '--out', - outputDir2, - '--input', - indexA, - '--input', - indexB, - '--force' -], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheRoot -}); - -const chunksOutRepeat = await loadChunkMeta(outputDir2); -if (stableStringify(chunksOutRepeat) !== stableStringify(chunksOutList)) { - console.error('Repeat assembly produced different chunk_meta output.'); - process.exit(1); -} -const tokenIndexRepeat = loadTokenPostings(outputDir2); -if (serializeTokenIndex(tokenIndexRepeat) !== serializeTokenIndex(tokenIndex)) { - console.error('Repeat assembly produced different token_postings output.'); - process.exit(1); -} - -const manifestPath = resolvePiecesManifestPath(outputDir); -if (!fs.existsSync(manifestPath)) { - console.error(`Missing pieces manifest: ${manifestPath}`); - process.exit(1); -} - -const equivalenceRoot = path.join(cacheRoot, 'equivalence'); -const repoAll = path.join(equivalenceRoot, 'repo-all'); -const repoA = path.join(equivalenceRoot, 'repo-a'); -const repoB = path.join(equivalenceRoot, 'repo-b'); -const cacheAll = path.join(equivalenceRoot, 'cache-all'); -const cacheA2 = path.join(equivalenceRoot, 'cache-a'); -const cacheB2 = path.join(equivalenceRoot, 'cache-b'); -const assembledEquiv = path.join(equivalenceRoot, 'assembled', 'index-code'); - -await rmDirRecursive(equivalenceRoot, { retries: 8, delayMs: 150 }); -await fsPromises.mkdir(equivalenceRoot, { recursive: true }); - -const sampleSrc = path.join(fixtureRoot, 'src'); -const sampleFiles = (await fsPromises.readdir(sampleSrc)) - .filter((file) => file.endsWith('.js')) - .sort(); -if (sampleFiles.length < 2) { - console.error('Piece assembly equivalence test requires at least two sample files.'); - process.exit(1); -} -const splitIndex = Math.max(1, Math.floor(sampleFiles.length / 2)); -const filesA = sampleFiles.slice(0, splitIndex); -const filesB = sampleFiles.slice(splitIndex); - -const copyRepoFiles = async (destRoot, files) => { - const destSrc = path.join(destRoot, 'src'); - await fsPromises.mkdir(destSrc, { recursive: true }); - for (const file of files) { - const sourcePath = path.join(sampleSrc, file); - const destPath = path.join(destSrc, file); - await fsPromises.copyFile(sourcePath, destPath); - } -}; - -await copyRepoFiles(repoAll, sampleFiles); -await copyRepoFiles(repoA, filesA); -await copyRepoFiles(repoB, filesB); - -run('build_index (monolithic)', [buildIndexPath, '--stub-embeddings', '--scm-provider', 'none', '--mode', 'code', '--repo', repoAll], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheAll -}); -run('build_index (part A)', [buildIndexPath, '--stub-embeddings', '--scm-provider', 'none', '--mode', 'code', '--repo', repoA], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheA2 -}); -run('build_index (part B)', [buildIndexPath, '--stub-embeddings', '--scm-provider', 'none', '--mode', 'code', '--repo', repoB], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheB2 -}); - -const userConfigAll = loadUserConfig(repoAll); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheAll; -const indexAll = getIndexDir(repoAll, 'code', userConfigAll); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheA2; -const indexA2 = getIndexDir(repoA, 'code', loadUserConfig(repoA)); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheB2; -const indexB2 = getIndexDir(repoB, 'code', loadUserConfig(repoB)); - -const assembleEquivStart = Date.now(); -run('assemble-pieces (equivalence)', [ - assemblePath, - '--repo', - repoAll, - '--mode', - 'code', - '--out', - assembledEquiv, - '--input', - indexA2, - '--input', - indexB2, - '--force' -], { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: equivalenceRoot -}); -const assembleEquivDuration = Date.now() - assembleEquivStart; -if (assembleEquivDuration > 30000) { - console.error(`assemble-pieces (equivalence) took too long (${assembleEquivDuration}ms).`); - process.exit(1); -} - -const chunksAll = await loadChunkMeta(indexAll); -const chunksEquiv = await loadChunkMeta(assembledEquiv); -if (stableStringify(normalizeChunks(chunksAll)) !== stableStringify(normalizeChunks(chunksEquiv))) { - const normalizedAll = normalizeChunks(chunksAll); - const normalizedEquiv = normalizeChunks(chunksEquiv); - const limit = Math.min(normalizedAll.length, normalizedEquiv.length); - for (let i = 0; i < limit; i += 1) { - if (stableStringify(normalizedAll[i]) !== stableStringify(normalizedEquiv[i])) { - logChunkMetaDiff('equivalence chunk_meta', normalizedAll[i], normalizedEquiv[i]); - break; - } - } - console.error('Piece assembly equivalence failed: chunk_meta mismatch.'); - process.exit(1); -} - -const postingsAll = loadTokenPostings(indexAll); -const postingsEquiv = loadTokenPostings(assembledEquiv); -if (stableStringify(postingsAll) !== stableStringify(postingsEquiv)) { - console.error('Piece assembly equivalence failed: token_postings mismatch.'); - process.exit(1); -} - -const piecesAll = loadPiecesManifestPieces(indexAll); -const piecesEquiv = loadPiecesManifestPieces(assembledEquiv); -const normalizePiece = (entry) => { - if (!entry || typeof entry !== 'object') return entry; - const normalized = { ...entry }; - delete normalized.tier; - delete normalized.layout; - if (normalized.statError == null) delete normalized.statError; - if (normalized.checksumError == null) delete normalized.checksumError; - if (normalized.bytes == null || Number.isFinite(normalized.bytes)) delete normalized.bytes; - if (normalized.checksum == null || typeof normalized.checksum === 'string') delete normalized.checksum; - if (normalized.mtime == null || Number.isFinite(normalized.mtime)) delete normalized.mtime; - return normalized; -}; -const sortPieces = (pieces) => pieces.slice().sort((a, b) => { - const nameA = `${a?.name || ''}`; - const nameB = `${b?.name || ''}`; - if (nameA !== nameB) return nameA.localeCompare(nameB); - const pathA = `${a?.path || ''}`; - const pathB = `${b?.path || ''}`; - if (pathA !== pathB) return pathA.localeCompare(pathB); - const typeA = `${a?.type || ''}`; - const typeB = `${b?.type || ''}`; - return typeA.localeCompare(typeB); -}); -const stripManifestEntries = (pieces) => pieces.filter((entry) => !( - (entry?.type === 'stats' && entry?.name === 'filelists') - || (entry?.type === 'stats' && entry?.name === 'index_state') - || (entry?.type === 'relations' && entry?.name === 'graph_relations') - || (entry?.type === 'relations' && entry?.name === 'graph_relations_meta') - || (entry?.type === 'relations' && entry?.name === 'graph_relations_offsets') - || (entry?.type === 'relations' && entry?.name === 'graph_relations_csr') - || entry?.name === 'import_resolution_graph' - || entry?.name === 'dense_vectors_hnsw_meta' - || entry?.name === 'dense_vectors_lancedb_meta' - || entry?.name === 'dense_vectors_code_hnsw_meta' - || entry?.name === 'dense_vectors_doc_hnsw_meta' - || entry?.name === 'dense_vectors_code_lancedb_meta' - || entry?.name === 'dense_vectors_doc_lancedb_meta' - || entry?.name === 'dense_vectors_hnsw' - || entry?.name === 'dense_vectors_lancedb' - || entry?.name === 'dense_vectors_code_hnsw' - || entry?.name === 'dense_vectors_doc_hnsw' - || entry?.name === 'dense_vectors_code_lancedb' - || entry?.name === 'dense_vectors_doc_lancedb' - || entry?.name === 'vfs_manifest' - || entry?.name === 'vfs_manifest_bloom' - || entry?.name === 'vfs_manifest_index' - || entry?.name === 'lexicon_relation_filter_report' - || entry?.name === 'risk_interprocedural_stats' -)); -const normalizedAll = sortPieces(stripManifestEntries(piecesAll).map(normalizePiece)); -const normalizedEquiv = sortPieces(stripManifestEntries(piecesEquiv).map(normalizePiece)); -if (stableStringify(normalizedAll) !== stableStringify(normalizedEquiv)) { - console.error('Piece assembly equivalence failed: pieces manifest mismatch.'); - process.exit(1); -} - -const graphAll = loadGraphRelationsSync(indexAll); -const graphEquiv = loadGraphRelationsSync(assembledEquiv); -delete graphAll.generatedAt; -delete graphEquiv.generatedAt; -if (JSON.stringify(graphAll) !== JSON.stringify(graphEquiv)) { - console.error('Piece assembly equivalence failed: graph_relations mismatch.'); - process.exit(1); -} - -console.log('Piece assembly tests passed'); - diff --git a/tests/indexing/policy/analysis-gating.test.js b/tests/indexing/policy/analysis-gating.test.js new file mode 100644 index 000000000..15515b030 --- /dev/null +++ b/tests/indexing/policy/analysis-gating.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createProcessChunksFixtureContext, + processFixtureChunks +} from '../file-processor/process-chunks-fixture.js'; + +const { context: baseContext } = createProcessChunksFixtureContext(); + +const disabled = await processFixtureChunks(baseContext, { + analysisPolicy: { + metadata: { enabled: false }, + risk: { enabled: false }, + typeInference: { local: { enabled: false } } + } +}); + +assert.ok(disabled.chunks.length === 1, 'expected chunk output'); +assert.equal(disabled.chunks[0].metaV2, null, 'metadata should be disabled'); +assert.ok(!disabled.chunks[0].docmeta?.risk, 'risk metadata should be disabled'); +assert.ok(!disabled.chunks[0].docmeta?.inferredTypes, 'type inference should be disabled'); + +const enabled = await processFixtureChunks(baseContext, { + analysisPolicy: { + metadata: { enabled: true }, + risk: { enabled: true }, + typeInference: { local: { enabled: true } } + } +}); + +assert.ok(enabled.chunks[0].metaV2, 'metadata should be present'); +assert.ok(enabled.chunks[0].docmeta?.risk, 'risk metadata should be present'); +assert.ok(enabled.chunks[0].docmeta?.inferredTypes, 'type inference should be present'); + +console.log('analysis policy gating test passed'); diff --git a/tests/indexing/policy/analysis-policy-gating.test.js b/tests/indexing/policy/analysis-policy-gating.test.js deleted file mode 100644 index 84d3b2dd4..000000000 --- a/tests/indexing/policy/analysis-policy-gating.test.js +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { processChunks } from '../../../src/index/build/file-processor/process-chunks.js'; -import { createTokenizationContext } from '../../../src/index/build/tokenization.js'; -import { buildLineIndex } from '../../../src/shared/lines.js'; -import { normalizeRiskConfig } from '../../../src/index/risk.js'; - -const text = 'const token = "SECRET";\n'; -const sc = [{ - start: 0, - end: text.length, - segment: { languageId: 'javascript', segmentUid: 'seg-test' }, - kind: 'code', - name: 'example' -}]; -const lineIndex = buildLineIndex(text); -const riskConfig = normalizeRiskConfig({ - enabled: true, - rules: { - includeDefaults: false, - rules: { - sources: [{ name: 'secret', patterns: ['SECRET'] }], - sinks: [], - sanitizers: [] - } - } -}, { rootDir: process.cwd() }); - -const tokenContext = createTokenizationContext({ - dictWords: new Set(), - dictConfig: { dpMaxTokenLength: 16 }, - postingsConfig: {} -}); - -const baseContext = { - sc, - text, - ext: '.js', - rel: 'src/example.js', - relKey: 'src/example.js', - fileStat: { size: Buffer.byteLength(text) }, - fileHash: null, - fileHashAlgo: null, - fileLineCount: 1, - fileLanguageId: 'javascript', - lang: { - id: 'javascript', - extractDocMeta: () => ({ paramTypes: { token: 'string' } }) - }, - languageContext: {}, - languageOptions: {}, - mode: 'code', - relationsEnabled: false, - fileRelations: null, - callIndex: null, - fileStructural: null, - commentEntries: [], - commentRanges: [], - normalizedCommentsConfig: { extract: 'off', maxBytesPerChunk: 0, maxPerChunk: 0 }, - tokenDictWords: new Set(), - dictConfig: { dpMaxTokenLength: 16 }, - tokenContext, - postingsConfig: {}, - contextWin: 0, - tokenMode: 'code', - embeddingEnabled: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: async () => null, - workerPool: null, - workerDictOverride: null, - workerState: { tokenWorkerDisabled: true, workerTokenizeFailed: false }, - tokenizationStats: { chunks: 0, tokens: 0, seq: 0 }, - complexityEnabled: false, - lintEnabled: false, - complexityCache: new Map(), - lintCache: new Map(), - log: () => {}, - logLine: () => {}, - crashLogger: null, - riskAnalysisEnabled: true, - riskConfig, - typeInferenceEnabled: true, - astDataflowEnabled: false, - controlFlowEnabled: false, - toolInfo: { version: 'test' }, - lineIndex, - lineAuthors: null, - fileGitMeta: {}, - addLineSpan: () => {}, - addSettingMetric: () => {}, - addEnrichDuration: () => {}, - addTokenizeDuration: () => {}, - addComplexityDuration: () => {}, - addLintDuration: () => {}, - addEmbeddingDuration: () => {}, - showLineProgress: false, - totalLines: 1, - failFile: () => ({ chunks: [], fileRelations: null, skip: { reason: 'fail' } }) -}; - -const disabled = await processChunks({ - ...baseContext, - analysisPolicy: { - metadata: { enabled: false }, - risk: { enabled: false }, - typeInference: { local: { enabled: false } } - } -}); - -assert.ok(disabled.chunks.length === 1, 'expected chunk output'); -assert.equal(disabled.chunks[0].metaV2, null, 'metadata should be disabled'); -assert.ok(!disabled.chunks[0].docmeta?.risk, 'risk metadata should be disabled'); -assert.ok(!disabled.chunks[0].docmeta?.inferredTypes, 'type inference should be disabled'); - -const enabled = await processChunks({ - ...baseContext, - analysisPolicy: { - metadata: { enabled: true }, - risk: { enabled: true }, - typeInference: { local: { enabled: true } } - } -}); - -assert.ok(enabled.chunks[0].metaV2, 'metadata should be present'); -assert.ok(enabled.chunks[0].docmeta?.risk, 'risk metadata should be present'); -assert.ok(enabled.chunks[0].docmeta?.inferredTypes, 'type inference should be present'); - -console.log('analysis policy gating test passed'); diff --git a/tests/indexing/policy/analysis-policy-schema.test.js b/tests/indexing/policy/analysis-schema.test.js similarity index 100% rename from tests/indexing/policy/analysis-policy-schema.test.js rename to tests/indexing/policy/analysis-schema.test.js diff --git a/tests/indexing/policy/generated-policy-matrix.test.js b/tests/indexing/policy/generated-matrix.test.js similarity index 100% rename from tests/indexing/policy/generated-policy-matrix.test.js rename to tests/indexing/policy/generated-matrix.test.js diff --git a/tests/indexing/policy/optional-deps-policy.test.js b/tests/indexing/policy/optional-deps-policy.test.js deleted file mode 100644 index 1b3d9b095..000000000 --- a/tests/indexing/policy/optional-deps-policy.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { repoRoot } from '../../helpers/root.js'; - -const ROOT = repoRoot(); - -const runSnippet = (envOverrides) => spawnSync( - process.execPath, - [ - '--input-type=module', - '-e', - "import('./tests/helpers/require-or-skip.js').then(({ requireOrSkip }) => { requireOrSkip({ capability: 'missing-cap', reason: 'missing-capability', requiredInCi: true }); });" - ], - { - cwd: ROOT, - encoding: 'utf8', - env: { ...process.env, ...envOverrides } - } -); - -const optionalResult = spawnSync( - process.execPath, - [ - '--input-type=module', - '-e', - "import('./tests/helpers/require-or-skip.js').then(({ requireOrSkip }) => { requireOrSkip({ capability: 'missing-cap', reason: 'missing-capability' }); });" - ], - { - cwd: ROOT, - encoding: 'utf8', - env: { ...process.env } - } -); - -if (optionalResult.status !== 77) { - console.error('optional deps policy failed: expected skip exit code'); - process.exit(1); -} - -const requiredResult = runSnippet({ CI: 'true' }); -if (requiredResult.status === 0 || requiredResult.status === 77) { - console.error('optional deps policy failed: required capability should fail in CI'); - process.exit(1); -} - -console.log('optional deps policy test passed'); diff --git a/tests/indexing/policy/optional-deps.test.js b/tests/indexing/policy/optional-deps.test.js new file mode 100644 index 000000000..709337735 --- /dev/null +++ b/tests/indexing/policy/optional-deps.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { repoRoot } from '../../helpers/root.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const ROOT = repoRoot(); + +const runSnippet = (envOverrides) => runNode( + [ + '--input-type=module', + '-e', + "import('./tests/helpers/require-or-skip.js').then(({ requireOrSkip }) => { requireOrSkip({ capability: 'missing-cap', reason: 'missing-capability', requiredInCi: true }); });" + ], + 'required optional dependency policy snippet', + ROOT, + applyTestEnv({ syncProcess: false, extraEnv: envOverrides }), + { stdio: 'pipe', allowFailure: true } +); + +const optionalResult = runNode( + [ + '--input-type=module', + '-e', + "import('./tests/helpers/require-or-skip.js').then(({ requireOrSkip }) => { requireOrSkip({ capability: 'missing-cap', reason: 'missing-capability' }); });" + ], + 'optional dependency policy snippet', + ROOT, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', allowFailure: true } +); + +if (optionalResult.status !== 77) { + console.error('optional deps policy failed: expected skip exit code'); + process.exit(1); +} + +const requiredResult = runSnippet({ CI: 'true' }); +if (requiredResult.status === 0 || requiredResult.status === 77) { + console.error('optional deps policy failed: required capability should fail in CI'); + process.exit(1); +} + +console.log('optional deps policy test passed'); diff --git a/tests/indexing/policy/shared-module-boundary-guard.test.js b/tests/indexing/policy/shared-module-boundary-guard.test.js new file mode 100644 index 000000000..b5fa45f44 --- /dev/null +++ b/tests/indexing/policy/shared-module-boundary-guard.test.js @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const ledgerPath = path.join(repoRoot, 'docs', 'tooling', 'shared-module-ledger.json'); +const waiverPath = path.join(repoRoot, 'docs', 'tooling', 'shared-module-boundary-waivers.json'); + +const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8')); +const waivers = JSON.parse(fs.readFileSync(waiverPath, 'utf8')); + +const sharedFiles = Array.isArray(ledger?.census?.sharedFiles) ? ledger.census.sharedFiles : []; +const bySharedFile = ledger?.consumerMap?.bySharedFile || {}; +const byConsumer = ledger?.consumerMap?.byConsumer || {}; +const duplicateOwnership = Array.isArray(ledger?.gaps?.duplicateSharedOwnership) + ? ledger.gaps.duplicateSharedOwnership + : []; + +assert.deepEqual(duplicateOwnership, [], 'expected no duplicate shared-module ownership entries'); + +const sharedPathSet = new Set(); +let computedEdgeCount = 0; + +for (const entry of sharedFiles) { + assert.ok(typeof entry?.path === 'string' && entry.path.length > 0, 'shared-module census entry missing path'); + assert.ok(!sharedPathSet.has(entry.path), `duplicate census entry for ${entry.path}`); + sharedPathSet.add(entry.path); + + assert.ok(entry?.primaryIssue && typeof entry.primaryIssue === 'object', `${entry.path}: missing primaryIssue`); + assert.ok(String(entry.primaryIssue.issueId || '').trim().length > 0, `${entry.path}: missing primaryIssue.issueId`); + assert.ok(typeof entry.primaryIssue.title === 'string' && entry.primaryIssue.title.trim().length > 0, `${entry.path}: missing primaryIssue.title`); + assert.ok(typeof entry.primaryIssue.umbrella === 'string' && entry.primaryIssue.umbrella.trim().length > 0, `${entry.path}: missing primaryIssue.umbrella`); + + const importers = Array.isArray(entry.importers) ? entry.importers.slice().sort() : []; + assert.equal(importers.length, entry.consumerCount, `${entry.path}: consumerCount must match importers length`); + + const consumerEntry = bySharedFile[entry.path]; + assert.ok(consumerEntry && typeof consumerEntry === 'object', `${entry.path}: missing consumerMap.bySharedFile entry`); + const mappedImporters = Array.isArray(consumerEntry.importers) ? consumerEntry.importers.slice().sort() : []; + assert.equal(consumerEntry.importerCount, importers.length, `${entry.path}: importerCount mismatch`); + assert.deepEqual(mappedImporters, importers, `${entry.path}: consumerMap.bySharedFile importer set mismatch`); + + computedEdgeCount += importers.length; +} + +assert.equal( + ledger?.consumerMap?.edgeCount, + computedEdgeCount, + 'consumerMap.edgeCount must match the sum of census importer edges' +); + +for (const [consumerPath, consumerEntry] of Object.entries(byConsumer)) { + const sharedImports = Array.isArray(consumerEntry?.sharedImports) ? consumerEntry.sharedImports.slice().sort() : []; + assert.equal( + consumerEntry.sharedImportCount, + sharedImports.length, + `${consumerPath}: sharedImportCount must match sharedImports length` + ); + for (const sharedPath of sharedImports) { + assert.ok(sharedPathSet.has(sharedPath), `${consumerPath}: sharedImports references missing shared file ${sharedPath}`); + const ownerEntry = bySharedFile[sharedPath]; + assert.ok( + Array.isArray(ownerEntry?.importers) && ownerEntry.importers.includes(consumerPath), + `${consumerPath}: consumerMap backlink missing for ${sharedPath}` + ); + } +} + +const rule = Array.isArray(waivers?.rules) + ? waivers.rules.find((entry) => entry?.ruleId === 'src-imports-tools-shared') + : null; + +assert.ok(rule, 'expected src-imports-tools-shared waiver rule'); + +const waivedEdges = new Map(); +for (const waiver of Array.isArray(rule?.waivers) ? rule.waivers : []) { + assert.ok(typeof waiver?.sharedPath === 'string' && waiver.sharedPath.length > 0, 'waiver missing sharedPath'); + assert.ok(typeof waiver?.importer === 'string' && waiver.importer.length > 0, 'waiver missing importer'); + assert.ok(typeof waiver?.reason === 'string' && waiver.reason.trim().length > 0, 'waiver missing reason'); + const key = `${waiver.sharedPath} -> ${waiver.importer}`; + assert.ok(!waivedEdges.has(key), `duplicate waiver for ${key}`); + waivedEdges.set(key, waiver.reason.trim()); +} + +const activeViolations = []; +for (const entry of sharedFiles) { + if (!entry.path.startsWith('tools/shared/')) continue; + for (const importer of entry.importers || []) { + if (!importer.startsWith('src/')) continue; + const key = `${entry.path} -> ${importer}`; + if (!waivedEdges.has(key)) { + activeViolations.push(key); + } + } +} + +assert.deepEqual( + activeViolations, + [], + `unwaived src/** -> tools/shared/** imports found: ${activeViolations.join(', ')}` +); + +for (const key of waivedEdges.keys()) { + const [sharedPath, importer] = key.split(' -> '); + const sharedEntry = bySharedFile[sharedPath]; + assert.ok(sharedEntry, `stale waiver references unknown shared file ${sharedPath}`); + assert.ok( + Array.isArray(sharedEntry.importers) && sharedEntry.importers.includes(importer), + `stale waiver for removed edge ${key}` + ); +} + +console.log('shared-module boundary guard passed'); diff --git a/tests/indexing/policy/shared-module-cycle-guard.test.js b/tests/indexing/policy/shared-module-cycle-guard.test.js new file mode 100644 index 000000000..0b7842323 --- /dev/null +++ b/tests/indexing/policy/shared-module-cycle-guard.test.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { findSharedModuleCycles } from '../../../tools/testing/shared-module-cycles.js'; + +const report = await findSharedModuleCycles({ + root: process.cwd() +}); + +assert.equal(report.cycleCount, 0, `expected no shared-module cycles, found: ${report.cycles.map((cycle) => cycle.nodes.join(' -> ')).join('; ')}`); + +console.log('shared module cycle guard passed'); diff --git a/tests/indexing/policy/shared-module-ledger.test.js b/tests/indexing/policy/shared-module-ledger.test.js new file mode 100644 index 000000000..5b6060dd5 --- /dev/null +++ b/tests/indexing/policy/shared-module-ledger.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { repoRoot } from '../../helpers/root.js'; + +const root = repoRoot(); +const ledgerPath = path.join(root, 'docs', 'tooling', 'shared-module-ledger.json'); + +const fail = (message) => { + console.error(`shared module ledger policy failed: ${message}`); + process.exit(1); +}; + +const expectArray = (value, label) => { + if (!Array.isArray(value)) fail(`${label} is not an array`); +}; + +let payload; +try { + payload = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')); +} catch (error) { + fail(error?.message || String(error)); +} + +if (payload?.schemaVersion !== '1.0.0') fail('schemaVersion mismatch'); +if (typeof payload?.generatedAt !== 'string') fail('generatedAt missing'); +if (!payload?.census || typeof payload.census !== 'object') fail('census missing'); +if (!payload?.consumerMap || typeof payload.consumerMap !== 'object') fail('consumerMap missing'); +if (!payload?.reviewLedger || typeof payload.reviewLedger !== 'object') fail('reviewLedger missing'); +if (!payload?.scanLedger || typeof payload.scanLedger !== 'object') fail('scanLedger missing'); +if (!payload?.gaps || typeof payload.gaps !== 'object') fail('gaps missing'); + +expectArray(payload.census.sharedFiles, 'census.sharedFiles'); +expectArray(payload.reviewLedger.issues, 'reviewLedger.issues'); +expectArray(payload.scanLedger?.h31?.issues, 'scanLedger.h31.issues'); +expectArray(payload.scanLedger?.h32?.issues, 'scanLedger.h32.issues'); +expectArray(payload.gaps.unownedSharedFiles, 'gaps.unownedSharedFiles'); +expectArray(payload.gaps.unscannedH31Files, 'gaps.unscannedH31Files'); +expectArray(payload.gaps.emptyH31Scopes, 'gaps.emptyH31Scopes'); +expectArray(payload.gaps.emptyH32Scopes, 'gaps.emptyH32Scopes'); + +if (payload.gaps.unownedSharedFiles.length) { + fail(`unowned shared files remain: ${payload.gaps.unownedSharedFiles.join(', ')}`); +} +if (payload.gaps.unscannedH31Files.length) { + fail(`unscanned H31 files remain: ${payload.gaps.unscannedH31Files.slice(0, 10).join(', ')}`); +} +if (payload.gaps.emptyH31Scopes.length) { + fail(`empty H31 scopes remain: ${payload.gaps.emptyH31Scopes.map((entry) => entry.issueId).join(', ')}`); +} +if (payload.gaps.emptyH32Scopes.length) { + fail(`empty H32 scopes remain: ${payload.gaps.emptyH32Scopes.map((entry) => entry.issueId).join(', ')}`); +} + +const censusPaths = new Set(payload.census.sharedFiles.map((entry) => entry.path)); +for (const issue of payload.reviewLedger.issues) { + expectArray(issue.files, `reviewLedger.issues[#${issue.issueId}].files`); + for (const file of issue.files) { + if (!censusPaths.has(file)) { + fail(`review ledger file missing from census: ${file}`); + } + } +} + +console.log('shared module ledger policy test passed'); diff --git a/tests/indexing/policy/shared-module-reduction-files.test.js b/tests/indexing/policy/shared-module-reduction-files.test.js new file mode 100644 index 000000000..7d2564e78 --- /dev/null +++ b/tests/indexing/policy/shared-module-reduction-files.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const reductionsDir = path.join(repoRoot, 'docs', 'tooling', 'shared-module-reductions'); + +assert.ok(fs.existsSync(reductionsDir), 'expected shared-module reduction artifacts directory'); + +const reductionFiles = fs.readdirSync(reductionsDir).filter((file) => file.endsWith('.json')).sort(); +assert.ok(reductionFiles.length > 0, 'expected at least one shared-module reduction JSON artifact'); + +const seenIssues = new Set(); + +for (const fileName of reductionFiles) { + const reductionPath = path.join(reductionsDir, fileName); + const reduction = JSON.parse(fs.readFileSync(reductionPath, 'utf8')); + + const issueId = String(reduction?.issueId || '').trim(); + assert.ok(issueId, `${fileName}: expected issueId`); + assert.ok(!seenIssues.has(issueId), `${fileName}: duplicate reduction issueId ${issueId}`); + seenIssues.add(issueId); + + const mdPath = reductionPath.replace(/\.json$/i, '.md'); + assert.ok(fs.existsSync(mdPath), `${fileName}: expected markdown companion ${path.basename(mdPath)}`); + + assert.ok(typeof reduction?.title === 'string' && reduction.title.trim().length > 0, `${fileName}: missing title`); + assert.ok(typeof reduction?.reducedAt === 'string' && reduction.reducedAt.trim().length > 0, `${fileName}: missing reducedAt`); + assert.ok(Array.isArray(reduction?.sourceIssues) && reduction.sourceIssues.length > 0, `${fileName}: missing sourceIssues`); + assert.ok( + Array.isArray(reduction?.priorityBatches) && reduction.priorityBatches.length > 0, + `${fileName}: priorityBatches must be a non-empty array` + ); + assert.ok(Array.isArray(reduction?.deferOrDrop), `${fileName}: deferOrDrop must be an array`); + + for (const batch of reduction.priorityBatches) { + assert.ok(typeof batch?.batchId === 'string' && batch.batchId.trim().length > 0, `${fileName}: batch missing batchId`); + assert.ok(typeof batch?.priority === 'string' && batch.priority.trim().length > 0, `${fileName}: batch ${batch?.batchId || '(unknown)'} missing priority`); + assert.ok(Array.isArray(batch?.categories) && batch.categories.length > 0, `${fileName}: batch ${batch.batchId} missing categories`); + assert.ok(Array.isArray(batch?.dependsOn), `${fileName}: batch ${batch.batchId} missing dependsOn`); + assert.ok(typeof batch?.whyNow === 'string' && batch.whyNow.trim().length > 0, `${fileName}: batch ${batch.batchId} missing whyNow`); + assert.ok(Array.isArray(batch?.items) && batch.items.length > 0, `${fileName}: batch ${batch.batchId} missing items`); + for (const item of batch.items) { + assert.ok(typeof item?.title === 'string' && item.title.trim().length > 0, `${fileName}: batch ${batch.batchId} item missing title`); + assert.ok(Array.isArray(item?.paths) && item.paths.length > 0, `${fileName}: batch ${batch.batchId} item ${item?.title || '(unknown)'} missing paths`); + assert.ok( + typeof item?.bestFix === 'string' && item.bestFix.trim().length > 0, + `${fileName}: batch ${batch.batchId} item ${item?.title || '(unknown)'} missing bestFix` + ); + } + } +} + +console.log(`shared-module reduction artifacts validated: ${reductionFiles.length}`); diff --git a/tests/indexing/policy/shared-module-review-files.test.js b/tests/indexing/policy/shared-module-review-files.test.js new file mode 100644 index 000000000..95cbacd90 --- /dev/null +++ b/tests/indexing/policy/shared-module-review-files.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const ledgerPath = path.join(repoRoot, 'docs', 'tooling', 'shared-module-ledger.json'); +const reviewsDir = path.join(repoRoot, 'docs', 'tooling', 'shared-module-reviews'); + +const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8')); +const sharedFiles = Array.isArray(ledger?.census?.sharedFiles) ? ledger.census.sharedFiles : []; +const reviewFiles = fs.existsSync(reviewsDir) + ? fs.readdirSync(reviewsDir).filter((file) => file.endsWith('.json')).sort() + : []; + +assert.ok(reviewFiles.length > 0, 'expected at least one shared-module review JSON artifact'); + +const ALLOWED_CLASSIFICATIONS = new Set([ + 'keep', + 'split', + 'merge', + 'rename', + 'move', + 'deprecate', + 'document', + 'test', + 'optimize' +]); + +const seenIssues = new Set(); + +for (const fileName of reviewFiles) { + const reviewPath = path.join(reviewsDir, fileName); + const review = JSON.parse(fs.readFileSync(reviewPath, 'utf8')); + const issueId = String(review?.issueId || '').trim(); + assert.ok(issueId, `${fileName}: expected issueId`); + assert.ok(!seenIssues.has(issueId), `${fileName}: duplicate review issueId ${issueId}`); + seenIssues.add(issueId); + + const mdPath = reviewPath.replace(/\.json$/i, '.md'); + assert.ok(fs.existsSync(mdPath), `${fileName}: expected markdown companion ${path.basename(mdPath)}`); + + const expectedPaths = sharedFiles + .filter((entry) => String(entry?.primaryIssue?.issueId || '') === issueId) + .map((entry) => entry.path) + .sort(); + const entries = Array.isArray(review?.entries) ? review.entries : []; + const actualPaths = entries.map((entry) => entry.path).sort(); + assert.deepEqual( + actualPaths, + expectedPaths, + `${fileName}: review entries must exactly match ledger-owned files for issue ${issueId}` + ); + + const seenPaths = new Set(); + for (const entry of entries) { + assert.ok(typeof entry?.path === 'string' && entry.path.length > 0, `${fileName}: review entry missing path`); + assert.ok(!seenPaths.has(entry.path), `${fileName}: duplicate entry for ${entry.path}`); + seenPaths.add(entry.path); + + assert.ok(Number.isInteger(entry.consumerCount) && entry.consumerCount >= 0, `${fileName}: ${entry.path} missing consumerCount`); + assert.ok(Array.isArray(entry.classifications) && entry.classifications.length > 0, `${fileName}: ${entry.path} missing classifications`); + for (const classification of entry.classifications) { + assert.ok( + ALLOWED_CLASSIFICATIONS.has(classification), + `${fileName}: ${entry.path} uses unknown classification ${classification}` + ); + } + assert.ok( + typeof entry.consumerNotes === 'string' && entry.consumerNotes.trim().length > 0, + `${fileName}: ${entry.path} missing consumerNotes` + ); + assert.ok( + typeof entry.recommendation === 'string' && entry.recommendation.trim().length > 0, + `${fileName}: ${entry.path} missing recommendation` + ); + } +} + +console.log(`shared-module review artifacts validated: ${reviewFiles.length}`); diff --git a/tests/indexing/policy/shared-module-scan-files.test.js b/tests/indexing/policy/shared-module-scan-files.test.js new file mode 100644 index 000000000..2a99e730c --- /dev/null +++ b/tests/indexing/policy/shared-module-scan-files.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const scansDir = path.join(repoRoot, 'docs', 'tooling', 'shared-module-scans'); + +if (!fs.existsSync(scansDir)) { + console.log('shared-module scan artifacts validated: 0'); + process.exit(0); +} + +const scanFiles = fs.readdirSync(scansDir).filter((file) => file.endsWith('.json')).sort(); +const seenIssues = new Set(); + +for (const fileName of scanFiles) { + const scanPath = path.join(scansDir, fileName); + const scan = JSON.parse(fs.readFileSync(scanPath, 'utf8')); + + const issueId = String(scan?.issueId || '').trim(); + assert.ok(issueId, `${fileName}: expected issueId`); + assert.ok(!seenIssues.has(issueId), `${fileName}: duplicate scan issueId ${issueId}`); + seenIssues.add(issueId); + + const mdPath = scanPath.replace(/\.json$/i, '.md'); + assert.ok(fs.existsSync(mdPath), `${fileName}: expected markdown companion ${path.basename(mdPath)}`); + + assert.ok(typeof scan?.title === 'string' && scan.title.trim().length > 0, `${fileName}: missing title`); + assert.ok(typeof scan?.scanDate === 'string' && scan.scanDate.trim().length > 0, `${fileName}: missing scanDate`); + assert.ok(typeof scan?.scope === 'string' && scan.scope.trim().length > 0, `${fileName}: missing scope`); + assert.ok(typeof scan?.summary === 'object' && scan.summary, `${fileName}: missing summary`); + assert.ok( + typeof scan.summary?.overallAssessment === 'string' && scan.summary.overallAssessment.trim().length > 0, + `${fileName}: missing summary.overallAssessment` + ); + assert.ok(Array.isArray(scan?.hotspotMatrix) && scan.hotspotMatrix.length > 0, `${fileName}: hotspotMatrix must be a non-empty array`); + assert.ok(Array.isArray(scan?.mappings) && scan.mappings.length > 0, `${fileName}: mappings must be a non-empty array`); + assert.ok(Array.isArray(scan?.localExceptions), `${fileName}: localExceptions must be an array`); + + for (const hotspot of scan.hotspotMatrix) { + assert.ok(typeof hotspot?.name === 'string' && hotspot.name.trim().length > 0, `${fileName}: hotspot missing name`); + assert.ok( + typeof hotspot?.recommendedAction === 'string' && hotspot.recommendedAction.trim().length > 0, + `${fileName}: hotspot ${hotspot?.name || '(unknown)'} missing recommendedAction` + ); + assert.ok( + typeof hotspot?.whyThisWayIsBest === 'string' && hotspot.whyThisWayIsBest.trim().length > 0, + `${fileName}: hotspot ${hotspot?.name || '(unknown)'} missing whyThisWayIsBest` + ); + } +} + +console.log(`shared-module scan artifacts validated: ${scanFiles.length}`); diff --git a/tests/indexing/postings/backpressure-queue.test.js b/tests/indexing/postings/backpressure-queue.test.js deleted file mode 100644 index 86b5b1273..000000000 --- a/tests/indexing/postings/backpressure-queue.test.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -ensureTestingEnv(process.env); - -const fail = (message) => { - console.error(`backpressure queue test failed: ${message}`); - process.exit(1); -}; - -const queue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 2, - maxPendingBytes: 100, - maxHeapFraction: 1 -}); - -const first = await queue.reserve({ rows: 2, bytes: 80 }); -let secondResolved = false; -const start = Date.now(); -const secondPromise = queue.reserve({ rows: 2, bytes: 80 }).then((reservation) => { - secondResolved = true; - return reservation; -}); - -await sleep(50); -if (secondResolved) { - fail('expected second reservation to block on backpressure'); -} - -first.release(); -const second = await secondPromise; -const waitedMs = Date.now() - start; -if (waitedMs < 40) { - fail(`expected backpressure wait; waited ${waitedMs}ms`); -} -second.release(); - -const stats = queue.stats(); -if (!stats || typeof stats !== 'object') { - fail('missing queue stats'); -} -if (!stats.backpressure || stats.backpressure.count < 1) { - fail('expected backpressure count to increment'); -} -if (!stats.backpressure || stats.backpressure.waitMs <= 0) { - fail('expected backpressure wait time to be recorded'); -} - -const timeoutQueue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 2, - maxPendingBytes: 100, - maxHeapFraction: 1, - reserveTimeoutMs: 25 -}); -const timeoutGuard = await timeoutQueue.reserve({ rows: 2, bytes: 80 }); -await assert.rejects( - () => timeoutQueue.reserve({ rows: 2, bytes: 80 }), - (err) => err?.code === 'POSTINGS_BACKPRESSURE_TIMEOUT', - 'expected reserve timeout while queue remains saturated' -); -timeoutGuard.release(); - -const abortQueue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 2, - maxPendingBytes: 100, - maxHeapFraction: 1 -}); -const abortGuard = await abortQueue.reserve({ rows: 2, bytes: 80 }); -const abortController = new AbortController(); -setTimeout(() => abortController.abort(new Error('abort postings reserve wait')), 10); -await assert.rejects( - () => abortQueue.reserve({ rows: 2, bytes: 80, signal: abortController.signal }), - (err) => (err?.message || '').includes('abort postings reserve wait'), - 'expected reserve wait to reject when abort signal fires' -); -abortGuard.release(); - -console.log('backpressure queue test passed'); diff --git a/tests/indexing/postings/build-postings-sparse-disable.test.js b/tests/indexing/postings/build-sparse-disable.test.js similarity index 100% rename from tests/indexing/postings/build-postings-sparse-disable.test.js rename to tests/indexing/postings/build-sparse-disable.test.js diff --git a/tests/indexing/postings/chargram-bench-contract.test.js b/tests/indexing/postings/chargram-bench-contract.test.js index 4f6cea9ae..fed55089c 100644 --- a/tests/indexing/postings/chargram-bench-contract.test.js +++ b/tests/indexing/postings/chargram-bench-contract.test.js @@ -1,14 +1,16 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const script = path.join(root, 'tools', 'bench', 'index', 'chargram-postings.js'); -const result = spawnSync( - process.execPath, +const result = runNode( [script, '--vocab', '2000', '--docs', '1000', '--postings', '4', '--spill', '500', '--rolling-hash', '--mode', 'compare'], - { cwd: root, encoding: 'utf8' } + 'chargram postings bench contract', + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); if (result.status !== 0) { diff --git a/tests/indexing/postings/postings-chargram-config-defaults.test.js b/tests/indexing/postings/chargram-config-defaults.test.js similarity index 100% rename from tests/indexing/postings/postings-chargram-config-defaults.test.js rename to tests/indexing/postings/chargram-config-defaults.test.js diff --git a/tests/indexing/postings/postings-chargram-long-token-does-not-abort.test.js b/tests/indexing/postings/chargram-long-token-does-not-abort.test.js similarity index 100% rename from tests/indexing/postings/postings-chargram-long-token-does-not-abort.test.js rename to tests/indexing/postings/chargram-long-token-does-not-abort.test.js diff --git a/tests/indexing/postings/chunk-meta-determinism.test.js b/tests/indexing/postings/chunk-meta-determinism.test.js index e3868e491..ccd95648a 100644 --- a/tests/indexing/postings/chunk-meta-determinism.test.js +++ b/tests/indexing/postings/chunk-meta-determinism.test.js @@ -4,7 +4,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getRepoId } from '../../../tools/shared/dict-utils.js'; import { loadChunkMeta, MAX_JSON_BYTES } from '../../../src/shared/artifact-io.js'; import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; @@ -12,6 +11,7 @@ import { stableStringifyForSignature } from '../../../src/shared/stable-json.js' import { sha1 } from '../../../src/shared/hash.js'; import { rmDirRecursive } from '../../helpers/temp.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; applyTestEnv(); const root = process.cwd(); @@ -93,7 +93,11 @@ const runBuild = async ({ label, threads }) => { '--progress', 'off' ]; - const result = spawnSync(process.execPath, args, { cwd: fixtureRoot, env, encoding: 'utf8' }); + const result = runNode(args, `chunk meta determinism build ${label}`, fixtureRoot, env, { + stdio: 'pipe', + encoding: 'utf8', + allowFailure: true + }); if (result.status !== 0) { throw new Error(formatBuildFailure(label, result)); } diff --git a/tests/indexing/postings/postings-doc-only-missing-doc-is-zero.test.js b/tests/indexing/postings/doc-only-missing-doc-is-zero.test.js similarity index 100% rename from tests/indexing/postings/postings-doc-only-missing-doc-is-zero.test.js rename to tests/indexing/postings/doc-only-missing-doc-is-zero.test.js diff --git a/tests/indexing/postings/postings-guard-max-unique-keeps-existing.test.js b/tests/indexing/postings/guard-max-unique-keeps-existing.test.js similarity index 100% rename from tests/indexing/postings/postings-guard-max-unique-keeps-existing.test.js rename to tests/indexing/postings/guard-max-unique-keeps-existing.test.js diff --git a/tests/indexing/postings/helpers/build-postings-fixture.js b/tests/indexing/postings/helpers/build-postings-fixture.js new file mode 100644 index 000000000..491def4bb --- /dev/null +++ b/tests/indexing/postings/helpers/build-postings-fixture.js @@ -0,0 +1,76 @@ +import { createIndexState, appendChunk } from '../../../../src/index/build/state.js'; +import { buildPostings } from '../../../../src/index/build/postings.js'; + +export const buildPostingsFromTokens = async ({ tokens, tokenIds = null }) => { + const state = createIndexState(); + appendChunk(state, { + tokens, + ...(tokenIds ? { tokenIds } : {}), + seq: tokens, + file: 'sample.txt' + }, {}); + + return buildPostings({ + chunks: state.chunks, + df: state.df, + tokenPostings: state.tokenPostings, + tokenIdMap: state.tokenIdMap, + docLengths: state.docLengths, + fieldPostings: state.fieldPostings, + fieldDocLengths: state.fieldDocLengths, + phrasePost: state.phrasePost, + triPost: state.triPost, + postingsConfig: {}, + postingsGuard: state.postingsGuard, + embeddingsEnabled: false + }); +}; + +export const createPhrasePost = ({ count, modulo }) => { + const phrasePost = new Map(); + for (let i = 0; i < count; i += 1) { + phrasePost.set(`token-${String(i).padStart(4, '0')}`, [i % modulo]); + } + return phrasePost; +}; + +export const createPhraseSpillInput = ({ + phrasePost, + postingsConfig, + buildRoot = undefined +}) => ({ + chunks: [{ tokenCount: 1, tokens: ['alpha'] }], + df: new Map(), + tokenPostings: new Map([['alpha', [[0, 1]]]]), + docLengths: [1], + fieldPostings: null, + fieldDocLengths: null, + phrasePost: new Map(phrasePost), + triPost: null, + postingsConfig, + embeddingsEnabled: false, + log: () => {}, + ...(buildRoot ? { buildRoot } : {}) +}); + +export const createPostingsQueueBackpressureCase = async ({ + createPostingsQueue, + payload +}) => { + const queue = createPostingsQueue({ + maxPending: 2, + maxPendingRows: payload.rows, + maxPendingBytes: payload.bytes, + maxHeapFraction: 1 + }); + + const first = await queue.reserve(payload); + let secondResolved = false; + const secondPromise = queue.reserve({ rows: 1, bytes: 1 }).then((reservation) => { + secondResolved = true; + return reservation; + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + return { first, queue, secondPromise, secondResolved: () => secondResolved }; +}; diff --git a/tests/indexing/postings/helpers/vector-only-cleanup-fixture.js b/tests/indexing/postings/helpers/vector-only-cleanup-fixture.js new file mode 100644 index 000000000..e04a4c0bd --- /dev/null +++ b/tests/indexing/postings/helpers/vector-only-cleanup-fixture.js @@ -0,0 +1,128 @@ +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; + +import { writeIndexArtifacts } from '../../../../src/index/build/artifacts.js'; +import { buildPostings } from '../../../../src/index/build/postings.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const hasTokenPostingsArtifacts = (outDir) => ( + fsSync.existsSync(path.join(outDir, 'token_postings.json')) + || fsSync.existsSync(path.join(outDir, 'token_postings.json.gz')) + || fsSync.existsSync(path.join(outDir, 'token_postings.json.zst')) +); + +export const readArtifactCleanupActions = async (outDir) => { + const indexState = JSON.parse(await fs.readFile(path.join(outDir, 'index_state.json'), 'utf8')); + return Array.isArray(indexState?.extensions?.artifactCleanup?.actions) + ? indexState.extensions.artifactCleanup.actions + : []; +}; + +export const createVectorOnlyBuildRoots = (cacheName) => { + const repoRoot = process.cwd(); + const fixtureRoot = path.join(repoRoot, 'tests', 'fixtures', 'sample'); + return { + buildScript: path.join(repoRoot, 'build_index.js'), + cacheRoot: resolveTestCachePath(repoRoot, cacheName), + fixtureRoot, + repoRoot + }; +}; + +export const createVectorOnlyBuildEnv = ({ + cacheRoot, + testConfig, + stage = undefined +}) => { + const baseEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^pairofcleats_/i.test(key)) + ); + const env = { + ...baseEnv, + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_WORKER_POOL: 'off', + PAIROFCLEATS_TEST_CONFIG: JSON.stringify(testConfig) + }; + if (stage !== undefined) env.PAIROFCLEATS_STAGE = stage; + return env; +}; + +export const createVectorOnlyCleanupWriteContext = async (name) => { + applyTestEnv(); + applyTestEnv({ testing: '1' }); + + const root = process.cwd(); + const testRoot = resolveTestCachePath(root, name); + const outDir = path.join(testRoot, 'index-code'); + await fs.rm(testRoot, { recursive: true, force: true }); + await fs.mkdir(outDir, { recursive: true }); + + const state = { + chunks: [], + scannedFilesTimes: [], + scannedFiles: [], + skippedFiles: [], + totalTokens: 0, + fileRelations: new Map(), + fileInfoByPath: new Map(), + fileDetailsByPath: new Map(), + chunkUidToFile: new Map(), + docLengths: [], + vfsManifestRows: [], + vfsManifestCollector: null, + fieldTokens: [], + importResolutionGraph: null + }; + + const postings = await buildPostings({ + chunks: [], + df: new Map(), + tokenPostings: new Map(), + docLengths: [], + fieldPostings: {}, + fieldDocLengths: {}, + phrasePost: new Map(), + triPost: new Map(), + postingsConfig: {}, + embeddingsEnabled: false, + modelId: 'stub', + useStubEmbeddings: true, + log: () => {} + }); + + const runWrite = async ({ profileId }) => { + const timing = { start: Date.now() }; + await writeIndexArtifacts({ + outDir, + mode: 'code', + state, + postings, + postingsConfig: {}, + modelId: 'stub', + useStubEmbeddings: true, + dictSummary: null, + timing, + root: testRoot, + userConfig: { + indexing: { + profile: profileId, + embeddings: { enabled: profileId === 'vector_only' } + } + }, + incrementalEnabled: false, + fileCounts: { candidates: 0 }, + perfProfile: null, + indexState: { + generatedAt: new Date().toISOString(), + mode: 'code', + profile: { id: profileId, schemaVersion: 1 } + }, + graphRelations: null, + stageCheckpoints: null + }); + }; + + return { outDir, runWrite, testRoot }; +}; diff --git a/tests/indexing/postings/postings-packed-roundtrip.test.js b/tests/indexing/postings/packed-roundtrip.test.js similarity index 100% rename from tests/indexing/postings/postings-packed-roundtrip.test.js rename to tests/indexing/postings/packed-roundtrip.test.js diff --git a/tests/indexing/postings/payload-estimation-uses-precomputed-metadata.test.js b/tests/indexing/postings/payload-estimation-uses-precomputed-metadata.test.js index 377953da3..f281860ab 100644 --- a/tests/indexing/postings/payload-estimation-uses-precomputed-metadata.test.js +++ b/tests/indexing/postings/payload-estimation-uses-precomputed-metadata.test.js @@ -4,6 +4,7 @@ import { createPostingsQueue, estimatePostingsPayload } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; +import { createPostingsQueueBackpressureCase } from './helpers/build-postings-fixture.js'; const legacyResult = { chunks: [ @@ -35,22 +36,17 @@ const measured = estimatePostingsPayload(metadataResult); assert.deepEqual(measured, legacyPayload, 'precomputed payload should preserve legacy rows/bytes'); assert.equal(serialized, false, 'expected metadata path to bypass fallback stringify estimation'); -const queue = createPostingsQueue({ - maxPending: 2, - maxPendingRows: measured.rows, - maxPendingBytes: measured.bytes, - maxHeapFraction: 1 -}); - -const first = await queue.reserve(measured); -let secondResolved = false; -const secondPromise = queue.reserve({ rows: 1, bytes: 1 }).then((reservation) => { - secondResolved = true; - return reservation; +const { + first, + queue, + secondPromise, + secondResolved +} = await createPostingsQueueBackpressureCase({ + createPostingsQueue, + payload: measured }); -await new Promise((resolve) => setTimeout(resolve, 50)); -assert.equal(secondResolved, false, 'expected reservation accounting to match legacy backpressure behavior'); +assert.equal(secondResolved(), false, 'expected reservation accounting to match legacy backpressure behavior'); first.release(); const second = await secondPromise; diff --git a/tests/indexing/postings/phrase-postings-skip-non-actionable.test.js b/tests/indexing/postings/phrase-skip-non-actionable.test.js similarity index 100% rename from tests/indexing/postings/phrase-postings-skip-non-actionable.test.js rename to tests/indexing/postings/phrase-skip-non-actionable.test.js diff --git a/tests/indexing/postings/postings-queue-bypass-deadlock.test.js b/tests/indexing/postings/postings-queue-bypass-deadlock.test.js deleted file mode 100644 index 9dbe02ab2..000000000 --- a/tests/indexing/postings/postings-queue-bypass-deadlock.test.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const queue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 10, - maxPendingBytes: 1024, - maxHeapFraction: 1 -}); - -const flushed = []; -const appender = buildOrderedAppender( - async (result) => { - flushed.push(result.id); - }, - {}, - { - startIndex: 105, - expectedCount: 2 - } -); - -const tailReservation = await queue.reserve({ rows: 10, bytes: 0 }); -const tailDone = appender - .enqueue(106, { id: 106, chunks: Array.from({ length: 10 }, () => ({})) }) - .finally(() => tailReservation.release()); - -const nextIndex = appender.peekNextIndex(); -const headReservation = await queue.reserve({ - rows: 10, - bytes: 0, - bypass: Number.isFinite(nextIndex) && 105 <= nextIndex -}); -const headDone = appender - .enqueue(105, { id: 105, chunks: Array.from({ length: 10 }, () => ({})) }) - .finally(() => headReservation.release()); - -const completionState = await Promise.race([ - Promise.all([headDone, tailDone]).then(() => 'resolved', () => 'rejected'), - sleep(300).then(() => 'pending') -]); -assert.equal( - completionState, - 'resolved', - 'expected head-of-line reserve bypass to prevent reserve deadlock' -); - -await Promise.all([headDone, tailDone]); -assert.deepEqual( - flushed, - [105, 106], - 'expected ordered flush to commit head before tail after bypass unlocks head reservation' -); -const stats = queue.stats(); -assert.ok(stats.backpressure.bypass >= 1, 'expected reserve bypass telemetry to increment'); -assert.equal(stats.pending.count, 0, 'expected pending reservation count to drain'); - -console.log('postings queue bypass deadlock test passed'); diff --git a/tests/indexing/postings/postings-queue-metrics.test.js b/tests/indexing/postings/postings-queue-metrics.test.js deleted file mode 100644 index c0bd77d4f..000000000 --- a/tests/indexing/postings/postings-queue-metrics.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; - -const fail = (message) => { - console.error(`postings queue metrics test failed: ${message}`); - process.exit(1); -}; - -const queue = createPostingsQueue({ - maxPending: 2, - maxPendingRows: 4, - maxPendingBytes: 200, - maxHeapFraction: 1 -}); - -const first = await queue.reserve({ rows: 2, bytes: 60 }); -const second = await queue.reserve({ rows: 1, bytes: 20 }); - -const mid = queue.stats(); -if (mid.limits?.maxPending !== 2) fail('limits.maxPending mismatch'); -if (mid.pending?.count !== 2) fail('pending count mismatch'); -if (mid.pending?.rows !== 3) fail('pending rows mismatch'); -if (mid.pending?.bytes !== 80) fail('pending bytes mismatch'); -if (mid.highWater?.pending < 2) fail('highWater.pending not updated'); -if (mid.highWater?.rows < 3) fail('highWater.rows not updated'); -if (mid.highWater?.bytes < 80) fail('highWater.bytes not updated'); - -second.release(); -first.release(); - -const end = queue.stats(); -if (end.pending?.count !== 0) fail('pending count not reset after release'); -if (end.pending?.rows !== 0) fail('pending rows not reset after release'); -if (end.pending?.bytes !== 0) fail('pending bytes not reset after release'); - -console.log('postings queue metrics test passed'); diff --git a/tests/indexing/postings/postings-queue-unbounded-count-wakes-all.test.js b/tests/indexing/postings/postings-queue-unbounded-count-wakes-all.test.js deleted file mode 100644 index f755e54cc..000000000 --- a/tests/indexing/postings/postings-queue-unbounded-count-wakes-all.test.js +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const queue = createPostingsQueue({ - maxPendingRows: 100, - maxPendingBytes: 1000, - maxHeapFraction: 1 -}); - -const first = await queue.reserve({ rows: 100, bytes: 300 }); -let secondReservation = null; -let thirdReservation = null; -let fourthReservation = null; - -const secondPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { - secondReservation = reservation; - return reservation; -}); -const thirdPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { - thirdReservation = reservation; - return reservation; -}); -const fourthPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { - fourthReservation = reservation; - return reservation; -}); - -await sleep(30); -assert.equal(secondReservation, null, 'expected second waiter to block before release'); -assert.equal(thirdReservation, null, 'expected third waiter to block before release'); -assert.equal(fourthReservation, null, 'expected fourth waiter to block before release'); - -first.release(); -await sleep(30); - -assert.ok(secondReservation, 'expected second waiter to wake after release'); -assert.ok(thirdReservation, 'expected third waiter to wake after release'); -assert.ok(fourthReservation, 'expected fourth waiter to wake after release'); - -secondReservation.release(); -thirdReservation.release(); -fourthReservation.release(); -await Promise.all([secondPromise, thirdPromise, fourthPromise]); - -console.log('postings queue unbounded count wake test passed'); diff --git a/tests/indexing/postings/postings-queue-wake-fairness.test.js b/tests/indexing/postings/postings-queue-wake-fairness.test.js deleted file mode 100644 index 1fab7ddeb..000000000 --- a/tests/indexing/postings/postings-queue-wake-fairness.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const queue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 1, - maxPendingBytes: 8, - maxHeapFraction: 1 -}); - -const first = await queue.reserve({ rows: 1, bytes: 8 }); -let secondReservation = null; -let thirdReservation = null; - -const secondPromise = queue.reserve({ rows: 1, bytes: 8 }).then((reservation) => { - secondReservation = reservation; - return reservation; -}); -const thirdPromise = queue.reserve({ rows: 1, bytes: 8 }).then((reservation) => { - thirdReservation = reservation; - return reservation; -}); - -await sleep(30); -assert.equal(secondReservation, null, 'expected second waiter to block before first release'); -assert.equal(thirdReservation, null, 'expected third waiter to block before first release'); - -first.release(); -await sleep(30); -assert.ok(secondReservation, 'expected second waiter to wake after first release'); -assert.equal(thirdReservation, null, 'expected third waiter to remain blocked until next release'); - -secondReservation.release(); -await sleep(30); -assert.ok(thirdReservation, 'expected third waiter to wake after second release'); - -thirdReservation.release(); -await Promise.all([secondPromise, thirdPromise]); - -console.log('postings queue wake fairness test passed'); diff --git a/tests/indexing/postings/postings-real-bench-contract.test.js b/tests/indexing/postings/postings-real-bench-contract.test.js deleted file mode 100644 index b12a712be..000000000 --- a/tests/indexing/postings/postings-real-bench-contract.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const script = path.join(root, 'tools', 'bench', 'index', 'postings-real.js'); -const MAX_RUNTIME_MS = 30_000; -const env = { - ...process.env, - PAIROFCLEATS_WORKER_POOL: 'off', - PAIROFCLEATS_TESTING: '1' -}; -const result = spawnSync( - process.execPath, - [script, '--count', '1', '--seed', 'postings-real-contract', '--mode', 'baseline', '--threads-baseline', '1'], - { cwd: root, env, encoding: 'utf8', timeout: MAX_RUNTIME_MS } -); - -if (result.error?.code === 'ETIMEDOUT') { - console.warn(`postings real bench contract skipped after ${MAX_RUNTIME_MS}ms timeout`); - process.exit(0); -} - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(1); -} - -const output = `${result.stdout || ''}${result.stderr || ''}`; -assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); - -console.log('postings real bench contract test passed'); - diff --git a/tests/indexing/postings/postings-reserve-after-enqueue-deadlock-guard.test.js b/tests/indexing/postings/postings-reserve-after-enqueue-deadlock-guard.test.js deleted file mode 100644 index febfdd303..000000000 --- a/tests/indexing/postings/postings-reserve-after-enqueue-deadlock-guard.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; -import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; -import { runApplyWithPostingsBackpressure } from '../../../src/index/build/indexer/steps/process-files.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const queue = createPostingsQueue({ - maxPending: 1, - maxPendingRows: 10, - maxPendingBytes: 1024, - maxHeapFraction: 1 -}); - -const flushed = []; -const appender = buildOrderedAppender( - async (result) => runApplyWithPostingsBackpressure({ - sparsePostingsEnabled: true, - postingsQueue: queue, - result, - runApply: async () => { - flushed.push(result.id); - } - }), - {}, - { - startIndex: 105, - expectedCount: 2 - } -); - -const guardReservation = await queue.reserve({ rows: 10, bytes: 0 }); -const tailDone = appender.enqueue(106, { id: 106, chunks: [{ id: 'tail' }] }); -const headDone = appender.enqueue(105, { id: 105, chunks: [{ id: 'head' }] }); - -const waitingState = await Promise.race([ - Promise.all([headDone, tailDone]).then(() => 'resolved'), - sleep(30).then(() => 'pending') -]); -assert.equal(waitingState, 'pending', 'expected apply path to wait while external reservation is held'); - -guardReservation.release(); -await Promise.race([ - Promise.all([headDone, tailDone]), - sleep(500).then(() => { - throw new Error('expected enqueue-first ordered flow to complete after releasing queue backpressure'); - }) -]); - -assert.deepEqual(flushed, [105, 106], 'expected ordered flush order after delayed postings reservation'); -assert.equal(queue.stats().pending.count, 0, 'expected postings queue reservations to fully drain'); - -console.log('postings reserve-after-enqueue deadlock guard test passed'); diff --git a/tests/indexing/postings/postings-quantize.test.js b/tests/indexing/postings/quantize.test.js similarity index 100% rename from tests/indexing/postings/postings-quantize.test.js rename to tests/indexing/postings/quantize.test.js diff --git a/tests/indexing/postings/queue-contract-matrix.test.js b/tests/indexing/postings/queue-contract-matrix.test.js new file mode 100644 index 000000000..771d3906e --- /dev/null +++ b/tests/indexing/postings/queue-contract-matrix.test.js @@ -0,0 +1,291 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createPostingsQueue } from '../../../src/index/build/indexer/steps/process-files/postings-queue.js'; +import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; +import { runApplyWithPostingsBackpressure } from '../../../src/index/build/indexer/steps/process-files.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const cases = [ + { + name: 'stats reflect current pending reservations and high-water marks', + async run() { + const queue = createPostingsQueue({ + maxPending: 2, + maxPendingRows: 4, + maxPendingBytes: 200, + maxHeapFraction: 1 + }); + + const first = await queue.reserve({ rows: 2, bytes: 60 }); + const second = await queue.reserve({ rows: 1, bytes: 20 }); + + const mid = queue.stats(); + assert.equal(mid.limits?.maxPending, 2); + assert.equal(mid.pending?.count, 2); + assert.equal(mid.pending?.rows, 3); + assert.equal(mid.pending?.bytes, 80); + assert.ok(mid.highWater?.pending >= 2); + assert.ok(mid.highWater?.rows >= 3); + assert.ok(mid.highWater?.bytes >= 80); + + second.release(); + first.release(); + + const end = queue.stats(); + assert.equal(end.pending?.count, 0); + assert.equal(end.pending?.rows, 0); + assert.equal(end.pending?.bytes, 0); + } + }, + { + name: 'waiters wake in order as capacity is released', + async run() { + const queue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 1, + maxPendingBytes: 8, + maxHeapFraction: 1 + }); + + const first = await queue.reserve({ rows: 1, bytes: 8 }); + let secondReservation = null; + let thirdReservation = null; + + const secondPromise = queue.reserve({ rows: 1, bytes: 8 }).then((reservation) => { + secondReservation = reservation; + return reservation; + }); + const thirdPromise = queue.reserve({ rows: 1, bytes: 8 }).then((reservation) => { + thirdReservation = reservation; + return reservation; + }); + + await sleep(30); + assert.equal(secondReservation, null); + assert.equal(thirdReservation, null); + + first.release(); + await sleep(30); + assert.ok(secondReservation); + assert.equal(thirdReservation, null); + + secondReservation.release(); + await sleep(30); + assert.ok(thirdReservation); + + thirdReservation.release(); + await Promise.all([secondPromise, thirdPromise]); + } + }, + { + name: 'backpressure records waits and supports timeout and abort rejection', + async run() { + const queue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 2, + maxPendingBytes: 100, + maxHeapFraction: 1 + }); + + const first = await queue.reserve({ rows: 2, bytes: 80 }); + let secondResolved = false; + const start = Date.now(); + const secondPromise = queue.reserve({ rows: 2, bytes: 80 }).then((reservation) => { + secondResolved = true; + return reservation; + }); + + await sleep(50); + assert.equal(secondResolved, false); + + first.release(); + const second = await secondPromise; + const waitedMs = Date.now() - start; + assert.ok(waitedMs >= 40); + second.release(); + + const stats = queue.stats(); + assert.ok(stats.backpressure?.count >= 1); + assert.ok(stats.backpressure?.waitMs > 0); + + const timeoutQueue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 2, + maxPendingBytes: 100, + maxHeapFraction: 1, + reserveTimeoutMs: 25 + }); + const timeoutGuard = await timeoutQueue.reserve({ rows: 2, bytes: 80 }); + await assert.rejects( + () => timeoutQueue.reserve({ rows: 2, bytes: 80 }), + (err) => err?.code === 'POSTINGS_BACKPRESSURE_TIMEOUT' + ); + timeoutGuard.release(); + + const abortQueue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 2, + maxPendingBytes: 100, + maxHeapFraction: 1 + }); + const abortGuard = await abortQueue.reserve({ rows: 2, bytes: 80 }); + const abortController = new AbortController(); + setTimeout(() => abortController.abort(new Error('abort postings reserve wait')), 10); + await assert.rejects( + () => abortQueue.reserve({ rows: 2, bytes: 80, signal: abortController.signal }), + (err) => (err?.message || '').includes('abort postings reserve wait') + ); + abortGuard.release(); + } + }, + { + name: 'unbounded-count queues wake all waiters when row and byte budgets permit', + async run() { + const queue = createPostingsQueue({ + maxPendingRows: 100, + maxPendingBytes: 1000, + maxHeapFraction: 1 + }); + + const first = await queue.reserve({ rows: 100, bytes: 300 }); + let secondReservation = null; + let thirdReservation = null; + let fourthReservation = null; + + const secondPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { + secondReservation = reservation; + return reservation; + }); + const thirdPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { + thirdReservation = reservation; + return reservation; + }); + const fourthPromise = queue.reserve({ rows: 20, bytes: 100 }).then((reservation) => { + fourthReservation = reservation; + return reservation; + }); + + await sleep(30); + assert.equal(secondReservation, null); + assert.equal(thirdReservation, null); + assert.equal(fourthReservation, null); + + first.release(); + await sleep(30); + assert.ok(secondReservation); + assert.ok(thirdReservation); + assert.ok(fourthReservation); + + secondReservation.release(); + thirdReservation.release(); + fourthReservation.release(); + await Promise.all([secondPromise, thirdPromise, fourthPromise]); + } + }, + { + name: 'head-of-line bypass prevents ordered flush deadlocks', + async run() { + const queue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 10, + maxPendingBytes: 1024, + maxHeapFraction: 1 + }); + + const flushed = []; + const appender = buildOrderedAppender( + async (result) => { + flushed.push(result.id); + }, + {}, + { + startIndex: 105, + expectedCount: 2 + } + ); + + const tailReservation = await queue.reserve({ rows: 10, bytes: 0 }); + const tailDone = appender + .enqueue(106, { id: 106, chunks: Array.from({ length: 10 }, () => ({})) }) + .finally(() => tailReservation.release()); + + const nextIndex = appender.peekNextIndex(); + const headReservation = await queue.reserve({ + rows: 10, + bytes: 0, + bypass: Number.isFinite(nextIndex) && 105 <= nextIndex + }); + const headDone = appender + .enqueue(105, { id: 105, chunks: Array.from({ length: 10 }, () => ({})) }) + .finally(() => headReservation.release()); + + const completionState = await Promise.race([ + Promise.all([headDone, tailDone]).then(() => 'resolved', () => 'rejected'), + sleep(300).then(() => 'pending') + ]); + assert.equal(completionState, 'resolved'); + + await Promise.all([headDone, tailDone]); + assert.deepEqual(flushed, [105, 106]); + const stats = queue.stats(); + assert.ok(stats.backpressure.bypass >= 1); + assert.equal(stats.pending.count, 0); + } + }, + { + name: 'enqueue-first apply flow drains after held reservations release', + async run() { + const queue = createPostingsQueue({ + maxPending: 1, + maxPendingRows: 10, + maxPendingBytes: 1024, + maxHeapFraction: 1 + }); + + const flushed = []; + const appender = buildOrderedAppender( + async (result) => runApplyWithPostingsBackpressure({ + sparsePostingsEnabled: true, + postingsQueue: queue, + result, + runApply: async () => { + flushed.push(result.id); + } + }), + {}, + { + startIndex: 105, + expectedCount: 2 + } + ); + + const guardReservation = await queue.reserve({ rows: 10, bytes: 0 }); + const tailDone = appender.enqueue(106, { id: 106, chunks: [{ id: 'tail' }] }); + const headDone = appender.enqueue(105, { id: 105, chunks: [{ id: 'head' }] }); + + const waitingState = await Promise.race([ + Promise.all([headDone, tailDone]).then(() => 'resolved'), + sleep(30).then(() => 'pending') + ]); + assert.equal(waitingState, 'pending'); + + guardReservation.release(); + await Promise.race([ + Promise.all([headDone, tailDone]), + sleep(500).then(() => { + throw new Error('expected enqueue-first ordered flow to complete after releasing queue backpressure'); + }) + ]); + + assert.deepEqual(flushed, [105, 106]); + assert.equal(queue.stats().pending.count, 0); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('postings queue contract matrix test passed'); diff --git a/tests/indexing/postings/real-bench-contract.test.js b/tests/indexing/postings/real-bench-contract.test.js new file mode 100644 index 000000000..e8b0fe4ad --- /dev/null +++ b/tests/indexing/postings/real-bench-contract.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const script = path.join(root, 'tools', 'bench', 'index', 'postings-real.js'); +const MAX_RUNTIME_MS = 30_000; +const env = applyTestEnv({ + syncProcess: false, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); +const result = runNode( + [script, '--count', '1', '--seed', 'postings-real-contract', '--mode', 'baseline', '--threads-baseline', '1'], + 'postings real bench contract', + root, + env, + { stdio: 'pipe', encoding: 'utf8', timeoutMs: MAX_RUNTIME_MS, allowFailure: true } +); + +if (result.error?.code === 'ETIMEDOUT') { + console.warn(`postings real bench contract skipped after ${MAX_RUNTIME_MS}ms timeout`); + process.exit(0); +} + +if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(1); +} + +const output = `${result.stdout || ''}${result.stderr || ''}`; +assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); + +console.log('postings real bench contract test passed'); + diff --git a/tests/indexing/postings/spill-merge-compat.test.js b/tests/indexing/postings/spill-merge-compat.test.js index 7125d7ef4..0befbe3ce 100644 --- a/tests/indexing/postings/spill-merge-compat.test.js +++ b/tests/indexing/postings/spill-merge-compat.test.js @@ -5,42 +5,32 @@ import path from 'node:path'; import { buildPostings } from '../../../src/index/build/postings.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createPhrasePost, + createPhraseSpillInput +} from './helpers/build-postings-fixture.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'postings-spill-compat'); await fs.rm(tempRoot, { recursive: true, force: true }); await fs.mkdir(tempRoot, { recursive: true }); -const phrasePost = new Map(); -for (let i = 0; i < 6001; i += 1) { - phrasePost.set(`token-${String(i).padStart(4, '0')}`, [i % 5]); -} - -const buildInput = (overrides = {}) => ({ - chunks: [{ tokenCount: 1, tokens: ['alpha'] }], - df: new Map(), - tokenPostings: new Map([['alpha', [[0, 1]]]]), - docLengths: [1], - fieldPostings: null, - fieldDocLengths: null, - phrasePost: new Map(phrasePost), - triPost: null, +const phrasePost = createPhrasePost({ count: 6001, modulo: 5 }); +const buildInput = ({ buildRoot = undefined, postingsConfig = {} } = {}) => createPhraseSpillInput({ + phrasePost, + buildRoot, postingsConfig: { enablePhraseNgrams: true, enableChargrams: false, - phraseSpillMaxBytes: 0 + phraseSpillMaxBytes: 0, + ...postingsConfig }, - embeddingsEnabled: false, - log: () => {}, - ...overrides }); const baseline = await buildPostings(buildInput()); const spilled = await buildPostings(buildInput({ buildRoot: tempRoot, postingsConfig: { - enablePhraseNgrams: true, - enableChargrams: false, phraseSpillMaxBytes: 1 } })); diff --git a/tests/indexing/postings/spill-merge-unique-threshold.test.js b/tests/indexing/postings/spill-merge-unique-threshold.test.js index 7e6f38e36..1b535bad2 100644 --- a/tests/indexing/postings/spill-merge-unique-threshold.test.js +++ b/tests/indexing/postings/spill-merge-unique-threshold.test.js @@ -5,44 +5,33 @@ import path from 'node:path'; import { buildPostings } from '../../../src/index/build/postings.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createPhrasePost, + createPhraseSpillInput +} from './helpers/build-postings-fixture.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'postings-spill-unique-threshold'); await fs.rm(tempRoot, { recursive: true, force: true }); await fs.mkdir(tempRoot, { recursive: true }); -const phrasePost = new Map(); -for (let i = 0; i < 6001; i += 1) { - phrasePost.set(`token-${String(i).padStart(4, '0')}`, [i % 7]); -} - -const buildInput = (overrides = {}) => ({ - chunks: [{ tokenCount: 1, tokens: ['alpha'] }], - df: new Map(), - tokenPostings: new Map([['alpha', [[0, 1]]]]), - docLengths: [1], - fieldPostings: null, - fieldDocLengths: null, - phrasePost: new Map(phrasePost), - triPost: null, +const phrasePost = createPhrasePost({ count: 6001, modulo: 7 }); +const buildInput = ({ buildRoot = undefined, postingsConfig = {} } = {}) => createPhraseSpillInput({ + phrasePost, + buildRoot, postingsConfig: { enablePhraseNgrams: true, enableChargrams: false, phraseSpillMaxBytes: 0, - phraseSpillMaxUnique: 0 + phraseSpillMaxUnique: 0, + ...postingsConfig }, - embeddingsEnabled: false, - log: () => {}, - ...overrides }); const baseline = await buildPostings(buildInput()); const spilled = await buildPostings(buildInput({ buildRoot: tempRoot, postingsConfig: { - enablePhraseNgrams: true, - enableChargrams: false, - phraseSpillMaxBytes: 0, phraseSpillMaxUnique: 1 } })); diff --git a/tests/indexing/postings/token-id-canonicalization.test.js b/tests/indexing/postings/token-id-canonicalization.test.js index 233dca191..dfb65b87c 100644 --- a/tests/indexing/postings/token-id-canonicalization.test.js +++ b/tests/indexing/postings/token-id-canonicalization.test.js @@ -1,34 +1,12 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createIndexState, appendChunk } from '../../../src/index/build/state.js'; -import { buildPostings } from '../../../src/index/build/postings.js'; import { hashTokenId } from '../../../src/shared/token-id.js'; +import { buildPostingsFromTokens } from './helpers/build-postings-fixture.js'; -const state = createIndexState(); const tokens = ['delta', 'alpha', 'beta', 'alpha']; const tokenIds = tokens.map((token) => hashTokenId(token)); -appendChunk(state, { - tokens, - tokenIds, - seq: tokens, - file: 'sample.txt' -}, {}); - -const postings = await buildPostings({ - chunks: state.chunks, - df: state.df, - tokenPostings: state.tokenPostings, - tokenIdMap: state.tokenIdMap, - docLengths: state.docLengths, - fieldPostings: state.fieldPostings, - fieldDocLengths: state.fieldDocLengths, - phrasePost: state.phrasePost, - triPost: state.triPost, - postingsConfig: {}, - postingsGuard: state.postingsGuard, - embeddingsEnabled: false -}); +const postings = await buildPostingsFromTokens({ tokens, tokenIds }); assert.equal(postings.tokenVocab.length, postings.tokenVocabIds.length, 'token vocab ids length mismatch'); for (let i = 0; i < postings.tokenVocab.length; i += 1) { diff --git a/tests/indexing/postings/postings-tokenless-chunk-preserved.test.js b/tests/indexing/postings/tokenless-chunk-preserved.test.js similarity index 100% rename from tests/indexing/postings/postings-tokenless-chunk-preserved.test.js rename to tests/indexing/postings/tokenless-chunk-preserved.test.js diff --git a/tests/indexing/postings/postings-typed-map-parity.test.js b/tests/indexing/postings/typed-map-parity.test.js similarity index 100% rename from tests/indexing/postings/postings-typed-map-parity.test.js rename to tests/indexing/postings/typed-map-parity.test.js diff --git a/tests/indexing/postings/postings-typedarray-legacy-float-extraction.test.js b/tests/indexing/postings/typedarray-legacy-float-extraction.test.js similarity index 100% rename from tests/indexing/postings/postings-typedarray-legacy-float-extraction.test.js rename to tests/indexing/postings/typedarray-legacy-float-extraction.test.js diff --git a/tests/indexing/postings/vector-only-analysis-shortcuts-policy.test.js b/tests/indexing/postings/vector-only-analysis-shortcuts-policy.test.js deleted file mode 100644 index 7606aa0b3..000000000 --- a/tests/indexing/postings/vector-only-analysis-shortcuts-policy.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveVectorOnlyShortcutPolicy } from '../../../src/index/build/indexer/pipeline.js'; - -const defaultVectorOnly = resolveVectorOnlyShortcutPolicy({ - profile: { id: 'vector_only' }, - indexingConfig: { profile: 'vector_only' } -}); -assert.equal(defaultVectorOnly.enabled, true, 'expected vector-only shortcuts to be enabled'); -assert.equal(defaultVectorOnly.disableImportGraph, true, 'expected import graph shortcut enabled by default'); -assert.equal(defaultVectorOnly.disableCrossFileInference, true, 'expected cross-file shortcut enabled by default'); - -const optOutVectorOnly = resolveVectorOnlyShortcutPolicy({ - profile: { id: 'vector_only' }, - indexingConfig: { - profile: 'vector_only', - vectorOnly: { - disableImportGraph: false, - disableCrossFileInference: false - } - } -}); -assert.equal(optOutVectorOnly.enabled, true, 'expected vector-only shortcuts to stay active for vector-only profile'); -assert.equal(optOutVectorOnly.disableImportGraph, false, 'expected import graph shortcut opt-out to be honored'); -assert.equal(optOutVectorOnly.disableCrossFileInference, false, 'expected cross-file shortcut opt-out to be honored'); - -const defaultProfile = resolveVectorOnlyShortcutPolicy({ - profile: { id: 'default' }, - indexingConfig: { profile: 'default' } -}); -assert.equal(defaultProfile.enabled, false, 'expected shortcuts disabled for default profile'); -assert.equal(defaultProfile.disableImportGraph, false, 'expected no import graph shortcut outside vector-only profile'); -assert.equal(defaultProfile.disableCrossFileInference, false, 'expected no cross-file shortcut outside vector-only profile'); - -console.log('vector-only analysis shortcuts policy test passed'); diff --git a/tests/indexing/postings/vector-only-cleanup-allowlist-safety.test.js b/tests/indexing/postings/vector-only-cleanup-allowlist-safety.test.js index 52c4dd761..a2a59510a 100644 --- a/tests/indexing/postings/vector-only-cleanup-allowlist-safety.test.js +++ b/tests/indexing/postings/vector-only-cleanup-allowlist-safety.test.js @@ -3,85 +3,14 @@ import assert from 'node:assert/strict'; import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { buildPostings } from '../../../src/index/build/postings.js'; -import { writeIndexArtifacts } from '../../../src/index/build/artifacts.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createVectorOnlyCleanupWriteContext, + hasTokenPostingsArtifacts, + readArtifactCleanupActions +} from './helpers/vector-only-cleanup-fixture.js'; -applyTestEnv(); - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'phase18-vector-only-allowlist-safety'); -const outDir = path.join(testRoot, 'index-code'); -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); -applyTestEnv({ testing: '1' }); - -const state = { - chunks: [], - scannedFilesTimes: [], - scannedFiles: [], - skippedFiles: [], - totalTokens: 0, - fileRelations: new Map(), - fileInfoByPath: new Map(), - fileDetailsByPath: new Map(), - chunkUidToFile: new Map(), - docLengths: [], - vfsManifestRows: [], - vfsManifestCollector: null, - fieldTokens: [], - importResolutionGraph: null -}; - -const postings = await buildPostings({ - chunks: [], - df: new Map(), - tokenPostings: new Map(), - docLengths: [], - fieldPostings: {}, - fieldDocLengths: {}, - phrasePost: new Map(), - triPost: new Map(), - postingsConfig: {}, - embeddingsEnabled: false, - modelId: 'stub', - useStubEmbeddings: true, - log: () => {} -}); - -const runWrite = async ({ profileId }) => { - const timing = { start: Date.now() }; - await writeIndexArtifacts({ - outDir, - mode: 'code', - state, - postings, - postingsConfig: {}, - modelId: 'stub', - useStubEmbeddings: true, - dictSummary: null, - timing, - root: testRoot, - userConfig: { - indexing: { - profile: profileId, - embeddings: { enabled: profileId === 'vector_only' } - } - }, - incrementalEnabled: false, - fileCounts: { candidates: 0 }, - perfProfile: null, - indexState: { - generatedAt: new Date().toISOString(), - mode: 'code', - profile: { id: profileId, schemaVersion: 1 } - }, - graphRelations: null, - stageCheckpoints: null - }); -}; +const { outDir, runWrite } = await createVectorOnlyCleanupWriteContext('phase18-vector-only-allowlist-safety'); await runWrite({ profileId: 'default' }); const unknownFileName = 'token_postings.custom.keep'; @@ -100,18 +29,13 @@ await runWrite({ profileId: 'vector_only' }); assert.equal(fsSync.existsSync(unknownFilePath), true, 'vector_only cleanup should not delete unknown file'); assert.equal(fsSync.existsSync(unknownSentinelPath), true, 'vector_only cleanup should not delete unknown directory'); assert.equal( - fsSync.existsSync(path.join(outDir, 'token_postings.json')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.gz')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.zst')), + hasTokenPostingsArtifacts(outDir), false, 'known sparse artifact should be removed' ); assert.equal(fsSync.existsSync(path.join(outDir, 'token_postings.shards')), false, 'known sparse shard dir should be removed'); -const indexState = JSON.parse(await fs.readFile(path.join(outDir, 'index_state.json'), 'utf8')); -const actions = Array.isArray(indexState?.extensions?.artifactCleanup?.actions) - ? indexState.extensions.artifactCleanup.actions - : []; +const actions = await readArtifactCleanupActions(outDir); assert.equal( actions.some((entry) => String(entry?.path || '').includes(unknownFileName)), false, diff --git a/tests/indexing/postings/vector-only-does-not-emit-sparse.test.js b/tests/indexing/postings/vector-only-does-not-emit-sparse.test.js deleted file mode 100644 index c7eab7f14..000000000 --- a/tests/indexing/postings/vector-only-does-not-emit-sparse.test.js +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsSync from 'node:fs'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildPostings } from '../../../src/index/build/postings.js'; -import { writeIndexArtifacts } from '../../../src/index/build/artifacts.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'phase18-vector-only-no-sparse'); -const outDir = path.join(testRoot, 'index-code'); -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); -applyTestEnv({ testing: '1' }); - -const state = { - chunks: [], - scannedFilesTimes: [], - scannedFiles: [], - skippedFiles: [], - totalTokens: 0, - fileRelations: new Map(), - fileInfoByPath: new Map(), - fileDetailsByPath: new Map(), - chunkUidToFile: new Map(), - docLengths: [], - vfsManifestRows: [], - vfsManifestCollector: null, - fieldTokens: [], - importResolutionGraph: null -}; - -const postings = await buildPostings({ - chunks: [], - df: new Map(), - tokenPostings: new Map(), - docLengths: [], - fieldPostings: {}, - fieldDocLengths: {}, - phrasePost: new Map(), - triPost: new Map(), - postingsConfig: {}, - embeddingsEnabled: false, - modelId: 'stub', - useStubEmbeddings: true, - log: () => {} -}); - -const timing = { start: Date.now() }; -await writeIndexArtifacts({ - outDir, - mode: 'code', - state, - postings, - postingsConfig: {}, - modelId: 'stub', - useStubEmbeddings: true, - dictSummary: null, - timing, - root: testRoot, - userConfig: { - indexing: { - profile: 'vector_only', - embeddings: { enabled: true } - } - }, - incrementalEnabled: false, - fileCounts: { candidates: 0 }, - perfProfile: null, - indexState: { - generatedAt: new Date().toISOString(), - mode: 'code', - profile: { id: 'vector_only', schemaVersion: 1 } - }, - graphRelations: null, - stageCheckpoints: null -}); - -const sparseArtifacts = [ - 'token_postings.json', - 'token_postings.json.gz', - 'token_postings.json.zst', - 'token_postings.meta.json', - 'token_postings.shards', - 'token_postings.packed.bin', - 'phrase_ngrams.json', - 'chargram_postings.json', - 'field_postings.json', - 'field_tokens.json', - 'vocab_order.json', - 'minhash_signatures.json', - 'minhash_signatures.packed.bin' -]; -for (const artifactName of sparseArtifacts) { - assert.equal( - fsSync.existsSync(path.join(outDir, artifactName)), - false, - `vector_only should not emit sparse artifact ${artifactName}` - ); -} - -console.log('vector-only sparse artifact omission test passed'); diff --git a/tests/indexing/postings/vector-only-missing-embeddings-is-error.test.js b/tests/indexing/postings/vector-only-missing-embeddings-is-error.test.js deleted file mode 100644 index dc83f1d3b..000000000 --- a/tests/indexing/postings/vector-only-missing-embeddings-is-error.test.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const buildScript = path.join(root, 'build_index.js'); -const cacheRoot = resolveTestCachePath(root, 'phase18-vector-only-missing-embeddings'); - -const testConfig = { - indexing: { - profile: 'vector_only', - embeddings: { - enabled: true, - mode: 'off', - hnsw: { enabled: false }, - lancedb: { enabled: false } - } - }, - sqlite: { use: false }, - lmdb: { use: false } -}; - -const baseEnv = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !/^pairofcleats_/i.test(key)) -); - -const env = { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_STAGE: '', - PAIROFCLEATS_WORKER_POOL: 'off', - PAIROFCLEATS_TEST_CONFIG: JSON.stringify(testConfig) -}; -ensureTestingEnv(env); - -const result = spawnSync( - process.execPath, - [buildScript, '--repo', fixtureRoot, '--mode', 'code', '--stage', 'stage2'], - { cwd: fixtureRoot, env, encoding: 'utf8' } -); - -assert.notEqual(result.status, 0, 'expected vector_only build without embeddings to fail'); -const output = `${result.stderr || ''}\n${result.stdout || ''}`; -assert.equal( - output.includes('indexing.profile=vector_only requires embeddings'), - true, - 'expected error output to mention vector_only embeddings requirement' -); - -console.log('vector-only missing embeddings rejection test passed'); - diff --git a/tests/indexing/postings/vector-only-policy-contract-matrix.test.js b/tests/indexing/postings/vector-only-policy-contract-matrix.test.js new file mode 100644 index 000000000..6500cad98 --- /dev/null +++ b/tests/indexing/postings/vector-only-policy-contract-matrix.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import path from 'node:path'; + +import { INDEX_PROFILE_VECTOR_ONLY } from '../../../src/contracts/index-profile.js'; +import { resolveVectorOnlyShortcutPolicy, buildFeatureSettings } from '../../../src/index/build/indexer/pipeline.js'; +import { resolveChunkProcessingFeatureFlags } from '../../../src/index/build/indexer/steps/process-files.js'; +import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { + createVectorOnlyBuildEnv, + createVectorOnlyBuildRoots, + createVectorOnlyCleanupWriteContext, + hasTokenPostingsArtifacts +} from './helpers/vector-only-cleanup-fixture.js'; + +applyTestEnv(); + +const { buildScript, fixtureRoot } = createVectorOnlyBuildRoots('postings-vector-only-policy-contract-matrix'); + +const cases = [ + { + name: 'vector-only runtime policy keeps tokenization but disables sparse postings', + async run() { + const vectorRuntime = { + profile: { id: INDEX_PROFILE_VECTOR_ONLY }, + indexingConfig: { profile: INDEX_PROFILE_VECTOR_ONLY }, + analysisPolicy: {} + }; + const vectorSettings = buildFeatureSettings(vectorRuntime, 'code'); + assert.equal(vectorSettings.tokenize, true); + assert.equal(vectorSettings.postings, false); + + const vectorFlags = resolveChunkProcessingFeatureFlags(vectorRuntime); + assert.equal(vectorFlags.tokenizeEnabled, true); + assert.equal(vectorFlags.sparsePostingsEnabled, false); + + const shortcutPolicy = resolveVectorOnlyShortcutPolicy({ + profile: { id: INDEX_PROFILE_VECTOR_ONLY }, + indexingConfig: { profile: INDEX_PROFILE_VECTOR_ONLY } + }); + assert.equal(shortcutPolicy.enabled, true); + assert.equal(shortcutPolicy.disableImportGraph, true); + assert.equal(shortcutPolicy.disableCrossFileInference, true); + } + }, + { + name: 'vector-only shortcuts honor explicit opt-out', + async run() { + const shortcutPolicy = resolveVectorOnlyShortcutPolicy({ + profile: { id: INDEX_PROFILE_VECTOR_ONLY }, + indexingConfig: { + profile: INDEX_PROFILE_VECTOR_ONLY, + vectorOnly: { + disableImportGraph: false, + disableCrossFileInference: false + } + } + }); + assert.equal(shortcutPolicy.enabled, true); + assert.equal(shortcutPolicy.disableImportGraph, false); + assert.equal(shortcutPolicy.disableCrossFileInference, false); + } + }, + { + name: 'vector-only writes omit sparse artifacts on a clean output root', + async run() { + const { outDir, runWrite } = await createVectorOnlyCleanupWriteContext('postings-vector-only-clean-write'); + await runWrite({ profileId: INDEX_PROFILE_VECTOR_ONLY }); + assert.equal(hasTokenPostingsArtifacts(outDir), false); + assert.equal(fsSync.existsSync(path.join(outDir, 'token_postings.shards')), false); + } + }, + { + name: 'vector-only builds fail closed when embeddings are disabled', + async run() { + const { cacheRoot } = createVectorOnlyBuildRoots('postings-vector-only-missing-embeddings'); + const testConfig = { + indexing: { + profile: INDEX_PROFILE_VECTOR_ONLY, + embeddings: { + enabled: true, + mode: 'off', + hnsw: { enabled: false }, + lancedb: { enabled: false } + } + }, + sqlite: { use: false }, + lmdb: { use: false } + }; + const env = createVectorOnlyBuildEnv({ cacheRoot, testConfig, stage: '' }); + ensureTestingEnv(env); + + const result = runNode( + [buildScript, '--repo', fixtureRoot, '--mode', 'code', '--stage', 'stage2'], + 'vector-only missing embeddings build index', + fixtureRoot, + env, + { encoding: 'utf8', stdio: 'pipe', allowFailure: true } + ); + assert.notEqual(result.status, 0); + const output = `${result.stderr || ''}\n${result.stdout || ''}`; + assert.equal(output.includes('indexing.profile=vector_only requires embeddings'), true); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('vector-only policy contract matrix test passed'); diff --git a/tests/indexing/postings/vector-only-service-embeddings-pending-artifacts.test.js b/tests/indexing/postings/vector-only-service-embeddings-pending-artifacts.test.js index 9a1597c3a..6994b961c 100644 --- a/tests/indexing/postings/vector-only-service-embeddings-pending-artifacts.test.js +++ b/tests/indexing/postings/vector-only-service-embeddings-pending-artifacts.test.js @@ -3,17 +3,20 @@ import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; +import { runNode } from '../../helpers/run-node.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createVectorOnlyBuildEnv, + createVectorOnlyBuildRoots +} from './helpers/vector-only-cleanup-fixture.js'; applyTestEnv(); -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const buildScript = path.join(root, 'build_index.js'); -const cacheRoot = resolveTestCachePath(root, 'phase18-vector-only-service-embeddings'); +const { + buildScript, + cacheRoot, + fixtureRoot +} = createVectorOnlyBuildRoots('phase18-vector-only-service-embeddings'); const testConfig = { indexing: { @@ -38,25 +41,18 @@ const testConfig = { lmdb: { use: false } }; -const baseEnv = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !/^pairofcleats_/i.test(key)) -); - -const env = { - ...baseEnv, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_WORKER_POOL: 'off', - PAIROFCLEATS_TEST_CONFIG: JSON.stringify(testConfig) -}; +const env = createVectorOnlyBuildEnv({ cacheRoot, testConfig }); ensureTestingEnv(env); await fs.rm(cacheRoot, { recursive: true, force: true }); await fs.mkdir(cacheRoot, { recursive: true }); -const result = spawnSync( - process.execPath, +const result = runNode( [buildScript, '--repo', fixtureRoot, '--mode', 'code', '--progress', 'log'], - { cwd: fixtureRoot, env, encoding: 'utf8' } + 'vector-only service embeddings pending artifacts build index', + fixtureRoot, + env, + { encoding: 'utf8', stdio: 'pipe', allowFailure: true } ); const output = `${result.stderr || ''}\n${result.stdout || ''}`; diff --git a/tests/indexing/postings/vector-only-switching-cleans-stale-sparse.test.js b/tests/indexing/postings/vector-only-switching-cleans-stale-sparse.test.js index b7af6d6b8..3e0f8cb93 100644 --- a/tests/indexing/postings/vector-only-switching-cleans-stale-sparse.test.js +++ b/tests/indexing/postings/vector-only-switching-cleans-stale-sparse.test.js @@ -1,110 +1,29 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fsSync from 'node:fs'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildPostings } from '../../../src/index/build/postings.js'; -import { writeIndexArtifacts } from '../../../src/index/build/artifacts.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createVectorOnlyCleanupWriteContext, + hasTokenPostingsArtifacts, + readArtifactCleanupActions +} from './helpers/vector-only-cleanup-fixture.js'; -applyTestEnv(); - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'phase18-vector-only-switch-cleanup'); -const outDir = path.join(testRoot, 'index-code'); -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); -applyTestEnv({ testing: '1' }); - -const state = { - chunks: [], - scannedFilesTimes: [], - scannedFiles: [], - skippedFiles: [], - totalTokens: 0, - fileRelations: new Map(), - fileInfoByPath: new Map(), - fileDetailsByPath: new Map(), - chunkUidToFile: new Map(), - docLengths: [], - vfsManifestRows: [], - vfsManifestCollector: null, - fieldTokens: [], - importResolutionGraph: null -}; - -const postings = await buildPostings({ - chunks: [], - df: new Map(), - tokenPostings: new Map(), - docLengths: [], - fieldPostings: {}, - fieldDocLengths: {}, - phrasePost: new Map(), - triPost: new Map(), - postingsConfig: {}, - embeddingsEnabled: false, - modelId: 'stub', - useStubEmbeddings: true, - log: () => {} -}); - -const runWrite = async ({ profileId }) => { - const timing = { start: Date.now() }; - await writeIndexArtifacts({ - outDir, - mode: 'code', - state, - postings, - postingsConfig: {}, - modelId: 'stub', - useStubEmbeddings: true, - dictSummary: null, - timing, - root: testRoot, - userConfig: { - indexing: { - profile: profileId, - embeddings: { enabled: profileId === 'vector_only' } - } - }, - incrementalEnabled: false, - fileCounts: { candidates: 0 }, - perfProfile: null, - indexState: { - generatedAt: new Date().toISOString(), - mode: 'code', - profile: { id: profileId, schemaVersion: 1 } - }, - graphRelations: null, - stageCheckpoints: null - }); -}; +const { outDir, runWrite } = await createVectorOnlyCleanupWriteContext('phase18-vector-only-switch-cleanup'); await runWrite({ profileId: 'default' }); assert.equal( - fsSync.existsSync(path.join(outDir, 'token_postings.json')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.gz')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.zst')), + hasTokenPostingsArtifacts(outDir), true, 'expected default profile write to emit token_postings artifact' ); await runWrite({ profileId: 'vector_only' }); assert.equal( - fsSync.existsSync(path.join(outDir, 'token_postings.json')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.gz')) - || fsSync.existsSync(path.join(outDir, 'token_postings.json.zst')), + hasTokenPostingsArtifacts(outDir), false, 'expected vector_only profile write to clean stale token_postings artifact' ); -const indexState = JSON.parse(await fs.readFile(path.join(outDir, 'index_state.json'), 'utf8')); -const actions = Array.isArray(indexState?.extensions?.artifactCleanup?.actions) - ? indexState.extensions.artifactCleanup.actions - : []; +const actions = await readArtifactCleanupActions(outDir); assert.equal( actions.some((entry) => String(entry?.path || '').includes('token_postings')), true, diff --git a/tests/indexing/postings/vector-only-tokenization-policy.test.js b/tests/indexing/postings/vector-only-tokenization-policy.test.js deleted file mode 100644 index fd7d09fb8..000000000 --- a/tests/indexing/postings/vector-only-tokenization-policy.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { INDEX_PROFILE_VECTOR_ONLY } from '../../../src/contracts/index-profile.js'; -import { buildFeatureSettings } from '../../../src/index/build/indexer/pipeline.js'; -import { resolveChunkProcessingFeatureFlags } from '../../../src/index/build/indexer/steps/process-files.js'; - -const vectorRuntime = { - profile: { id: INDEX_PROFILE_VECTOR_ONLY }, - indexingConfig: { profile: INDEX_PROFILE_VECTOR_ONLY }, - analysisPolicy: {} -}; - -const vectorSettings = buildFeatureSettings(vectorRuntime, 'code'); -assert.equal(vectorSettings.tokenize, true, 'vector_only feature settings should keep tokenization enabled'); -assert.equal(vectorSettings.postings, false, 'vector_only feature settings should disable sparse postings'); - -const vectorFlags = resolveChunkProcessingFeatureFlags(vectorRuntime); -assert.equal(vectorFlags.tokenizeEnabled, true, 'vector_only processing should keep chunk tokenization enabled'); -assert.equal(vectorFlags.sparsePostingsEnabled, false, 'vector_only processing should disable sparse postings'); - -const defaultFlags = resolveChunkProcessingFeatureFlags({ - profile: { id: 'default' }, - indexingConfig: { profile: 'default' } -}); -assert.equal(defaultFlags.tokenizeEnabled, true, 'default processing should keep chunk tokenization enabled'); -assert.equal(defaultFlags.sparsePostingsEnabled, true, 'default processing should keep sparse postings enabled'); - -console.log('vector-only tokenization policy test passed'); diff --git a/tests/indexing/postings/vocab-order-determinism.test.js b/tests/indexing/postings/vocab-order-determinism.test.js index f71a3834a..dec3be37a 100644 --- a/tests/indexing/postings/vocab-order-determinism.test.js +++ b/tests/indexing/postings/vocab-order-determinism.test.js @@ -1,26 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createIndexState, appendChunk } from '../../../src/index/build/state.js'; -import { buildPostings } from '../../../src/index/build/postings.js'; import { createOrderingHasher } from '../../../src/shared/order.js'; +import { buildPostingsFromTokens } from './helpers/build-postings-fixture.js'; const buildVocab = async (tokens) => { - const state = createIndexState(); - appendChunk(state, { tokens, seq: tokens, file: 'sample.txt' }, {}); - const postings = await buildPostings({ - chunks: state.chunks, - df: state.df, - tokenPostings: state.tokenPostings, - tokenIdMap: state.tokenIdMap, - docLengths: state.docLengths, - fieldPostings: state.fieldPostings, - fieldDocLengths: state.fieldDocLengths, - phrasePost: state.phrasePost, - triPost: state.triPost, - postingsConfig: {}, - postingsGuard: state.postingsGuard, - embeddingsEnabled: false - }); + const postings = await buildPostingsFromTokens({ tokens }); return postings.tokenVocab; }; diff --git a/tests/indexing/preprocess/preprocess-files.test.js b/tests/indexing/preprocess/files.test.js similarity index 100% rename from tests/indexing/preprocess/preprocess-files.test.js rename to tests/indexing/preprocess/files.test.js diff --git a/tests/indexing/records/records-discovery.test.js b/tests/indexing/records/discovery.test.js similarity index 100% rename from tests/indexing/records/records-discovery.test.js rename to tests/indexing/records/discovery.test.js diff --git a/tests/indexing/records/exclusion.test.js b/tests/indexing/records/exclusion.test.js new file mode 100644 index 000000000..027a1ed3b --- /dev/null +++ b/tests/indexing/records/exclusion.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'records-exclusion'); +const repoRoot = path.join(tempRoot, 'repo'); +const srcDir = path.join(repoRoot, 'src'); +const docsDir = path.join(repoRoot, 'docs'); +const logsDir = path.join(repoRoot, 'logs'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(srcDir, { recursive: true }); +await fsPromises.mkdir(docsDir, { recursive: true }); +await fsPromises.mkdir(logsDir, { recursive: true }); + +await fsPromises.writeFile(path.join(srcDir, 'app.js'), 'export const alpha = 1;\n'); +await fsPromises.writeFile(path.join(docsDir, 'readme.md'), '# Readme\n'); +await fsPromises.writeFile(path.join(logsDir, 'build.log'), '2024-01-01 12:00:00 service startup completed alpha bravo\n'); + +const env = applyTestEnv({ + cacheRoot: path.join(tempRoot, 'cache'), + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const buildResult = runNode( + [path.join(root, 'build_index.js'), '--repo', repoRoot, '--stage', 'stage2', '--mode', 'all', '--stub-embeddings'], + 'records exclusion build index', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (buildResult.status !== 0) { + console.error('records exclusion test failed: build_index error.'); + if (buildResult.stderr) console.error(buildResult.stderr.trim()); + process.exit(buildResult.status ?? 1); +} + +const userConfig = loadUserConfig(repoRoot); +const codeDir = getIndexDir(repoRoot, 'code', userConfig); +const proseDir = getIndexDir(repoRoot, 'prose', userConfig); +const extractedDir = getIndexDir(repoRoot, 'extracted-prose', userConfig); +const recordsDir = getIndexDir(repoRoot, 'records', userConfig); + +const codeMeta = await loadChunkMeta(codeDir); +const proseMeta = await loadChunkMeta(proseDir); +const extractedMeta = await loadChunkMeta(extractedDir); +const recordsMeta = await loadChunkMeta(recordsDir); + +const loadFileMeta = async (dir) => { + const entries = await readJsonFile(path.join(dir, 'file_meta.json')); + return new Map(entries.map((entry) => [entry.id, entry.file])); +}; + +const codeFiles = await loadFileMeta(codeDir); +const proseFiles = await loadFileMeta(proseDir); +const extractedFiles = await loadFileMeta(extractedDir); +const recordsFiles = await loadFileMeta(recordsDir); + +const hasLog = (meta, fileMap) => meta.some((chunk) => fileMap.get(chunk.fileId) === 'logs/build.log'); +if (hasLog(codeMeta, codeFiles) || hasLog(proseMeta, proseFiles) || hasLog(extractedMeta, extractedFiles)) { + console.error('records exclusion test failed: records file leaked into non-records index.'); + process.exit(1); +} +if (!hasLog(recordsMeta, recordsFiles)) { + console.error('records exclusion test failed: records file missing from records index.'); + process.exit(1); +} + +console.log('records exclusion test passed.'); + diff --git a/tests/indexing/records/records-exclusion.test.js b/tests/indexing/records/records-exclusion.test.js deleted file mode 100644 index 331579514..000000000 --- a/tests/indexing/records/records-exclusion.test.js +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'records-exclusion'); -const repoRoot = path.join(tempRoot, 'repo'); -const srcDir = path.join(repoRoot, 'src'); -const docsDir = path.join(repoRoot, 'docs'); -const logsDir = path.join(repoRoot, 'logs'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(srcDir, { recursive: true }); -await fsPromises.mkdir(docsDir, { recursive: true }); -await fsPromises.mkdir(logsDir, { recursive: true }); - -await fsPromises.writeFile(path.join(srcDir, 'app.js'), 'export const alpha = 1;\n'); -await fsPromises.writeFile(path.join(docsDir, 'readme.md'), '# Readme\n'); -await fsPromises.writeFile(path.join(logsDir, 'build.log'), '2024-01-01 12:00:00 service startup completed alpha bravo\n'); - -const env = applyTestEnv({ - cacheRoot: path.join(tempRoot, 'cache'), - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--repo', repoRoot, '--stage', 'stage2', '--mode', 'all', '--stub-embeddings'], - { env, encoding: 'utf8' } -); -if (buildResult.status !== 0) { - console.error('records exclusion test failed: build_index error.'); - if (buildResult.stderr) console.error(buildResult.stderr.trim()); - process.exit(buildResult.status ?? 1); -} - -const userConfig = loadUserConfig(repoRoot); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -const proseDir = getIndexDir(repoRoot, 'prose', userConfig); -const extractedDir = getIndexDir(repoRoot, 'extracted-prose', userConfig); -const recordsDir = getIndexDir(repoRoot, 'records', userConfig); - -const codeMeta = await loadChunkMeta(codeDir); -const proseMeta = await loadChunkMeta(proseDir); -const extractedMeta = await loadChunkMeta(extractedDir); -const recordsMeta = await loadChunkMeta(recordsDir); - -const loadFileMeta = async (dir) => { - const entries = await readJsonFile(path.join(dir, 'file_meta.json')); - return new Map(entries.map((entry) => [entry.id, entry.file])); -}; - -const codeFiles = await loadFileMeta(codeDir); -const proseFiles = await loadFileMeta(proseDir); -const extractedFiles = await loadFileMeta(extractedDir); -const recordsFiles = await loadFileMeta(recordsDir); - -const hasLog = (meta, fileMap) => meta.some((chunk) => fileMap.get(chunk.fileId) === 'logs/build.log'); -if (hasLog(codeMeta, codeFiles) || hasLog(proseMeta, proseFiles) || hasLog(extractedMeta, extractedFiles)) { - console.error('records exclusion test failed: records file leaked into non-records index.'); - process.exit(1); -} -if (!hasLog(recordsMeta, recordsFiles)) { - console.error('records exclusion test failed: records file missing from records index.'); - process.exit(1); -} - -console.log('records exclusion test passed.'); - diff --git a/tests/indexing/relations/atomicity-rollback.test.js b/tests/indexing/relations/atomicity-rollback.test.js new file mode 100644 index 000000000..fb383388b --- /dev/null +++ b/tests/indexing/relations/atomicity-rollback.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { MAX_JSON_BYTES, loadJsonArrayArtifact, readJsonFile } from '../../../src/shared/artifact-io.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { fromPosix } from '../../../src/shared/file-paths.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + buildRelationBenchChunks, + buildRelationBenchFileRelations +} from '../../../tools/bench/index/relations-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { writeAndLoadRelationBenchGraphArtifacts } from './helpers/graph-relations-artifact-fixture.js'; + +applyTestEnv({ testing: '1' }); + +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +const root = process.cwd(); +const testRoot = resolveTestCachePath(root, 'relations-atomicity-rollback'); +const outDir = path.join(testRoot, 'index-code'); + +await fs.rm(testRoot, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); + +const fileRelations = buildRelationBenchFileRelations(); +const chunks = buildRelationBenchChunks({ + chunkCount: 250, + edgesPerChunk: 2, + fileModulo: 20, + edgeCountForChunk: (chunkIndex) => (chunkIndex === 0 ? 1500 : 2) +}); + +const runBuild = async ({ maxJsonBytes }) => { + const { pieces, rows } = await writeAndLoadRelationBenchGraphArtifacts({ + outDir, + chunks, + fileRelations, + maxJsonBytes + }); + return { pieces, rows }; +}; + +// First run should succeed and write stable artifacts. +const baseline = await runBuild({ maxJsonBytes: 512 * 1024 }); +const baselineHash = stableStringify(baseline.rows); + +// Second run should fail on a too-small maxJsonBytes (single-row overflow) but must not delete baseline. +let threw = false; +try { + await runBuild({ maxJsonBytes: 96 }); +} catch (err) { + threw = true; + const message = err?.message || String(err); + if (!message.includes('exceeds maxBytes')) { + fail(`relations atomicity rollback test failed: unexpected error: ${message}`); + } +} +if (!threw) { + fail('relations atomicity rollback test failed: expected rebuild to throw.'); +} + +// Verify baseline artifacts still load after the failed rebuild. +const metaRaw = readJsonFile(path.join(outDir, 'graph_relations.meta.json')); +const meta = metaRaw?.fields && typeof metaRaw.fields === 'object' ? metaRaw.fields : metaRaw; +const parts = Array.isArray(meta?.parts) ? meta.parts : []; +if (!parts.length) { + fail('relations atomicity rollback test failed: missing graph_relations parts after failure.'); +} + +const manifestAfter = { + version: 2, + generatedAt: new Date().toISOString(), + mode: 'code', + stage: 'stage2', + pieces: [ + ...parts.map((part) => ({ type: 'relations', name: 'graph_relations', format: 'jsonl', path: part.path })), + { type: 'relations', name: 'graph_relations_meta', format: 'json', path: 'graph_relations.meta.json' } + ] +}; +for (const part of parts) { + const abs = path.join(outDir, fromPosix(part.path)); + await fs.stat(abs).catch(() => fail(`relations atomicity rollback test failed: missing ${part.path}`)); +} + +const loadedAfter = await loadJsonArrayArtifact(outDir, 'graph_relations', { + manifest: manifestAfter, + strict: true, + maxBytes: MAX_JSON_BYTES +}); + +if (stableStringify(loadedAfter) !== baselineHash) { + fail('relations atomicity rollback test failed: graph_relations output changed after failed rebuild.'); +} + +console.log('relations atomicity rollback test passed'); + diff --git a/tests/indexing/relations/call-graph-contract-matrix.test.js b/tests/indexing/relations/call-graph-contract-matrix.test.js new file mode 100644 index 000000000..6fa1f257a --- /dev/null +++ b/tests/indexing/relations/call-graph-contract-matrix.test.js @@ -0,0 +1,364 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildCodeRelations } from '../../../src/lang/javascript.js'; +import { buildTypeScriptRelations } from '../../../src/lang/typescript.js'; +import { buildLineIndex } from '../../../src/shared/lines.js'; +import { discoverSegments, chunkSegments } from '../../../src/index/segments.js'; +import { createCallSites } from '../../../src/index/build/artifacts/writers/call-sites.js'; +import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; + +const baseChunks = [ + { + file: 'caller.js', + name: 'caller', + kind: 'function', + chunkUid: 'uid-caller', + codeRelations: { + callLinks: [ + { + v: 1, + edgeKind: 'call', + fromChunkUid: 'uid-caller', + to: { + v: 1, + targetName: 'other', + kindHint: null, + importHint: null, + candidates: [], + status: 'resolved', + resolved: { symbolId: 'sym1:heur:other', chunkUid: 'uid-other' } + }, + legacy: { legacy: true, file: 'other.js', target: 'other', kind: 'function' } + } + ] + } + }, + { + file: 'target.js', + name: 'target', + kind: 'function', + chunkUid: 'uid-target' + }, + { + file: 'other.js', + name: 'other', + kind: 'function', + chunkUid: 'uid-other' + } +]; + +{ + const chunks = structuredClone(baseChunks); + chunks[0].codeRelations.callLinks[0].to.targetName = 'target'; + chunks[0].codeRelations.callLinks[0].to.resolved = { symbolId: 'sym1:heur:target', chunkUid: 'uid-target' }; + chunks[0].codeRelations.callLinks[0].legacy = { + legacy: true, + file: 'target.js', + target: 'target', + kind: 'function' + }; + + const graphs = buildRelationGraphs({ chunks }); + const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); + assert.ok(callerNode, 'expected caller node in call graph'); + assert.deepEqual(callerNode.out, ['uid-target']); +} + +{ + const graphs = buildRelationGraphs({ + chunks: structuredClone(baseChunks), + callSites: [{ callerChunkUid: 'uid-caller', targetChunkUid: 'uid-target' }] + }); + const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); + assert.ok(callerNode, 'expected caller node in call graph'); + assert.deepEqual(callerNode.out, ['uid-other', 'uid-target']); +} + +{ + const stableChunkId = 'chunk_graph_1'; + const stableChunkUid = 'ck64:graph-1'; + const collisionChunkA = 'chunk_graph_a'; + const collisionChunkB = 'chunk_graph_b'; + const collisionChunkUidA = 'ck64:dup-a'; + const collisionChunkUidB = 'ck64:dup-b'; + const graphs = buildRelationGraphs({ + chunks: [ + { + file: 'src/graph.js', + name: 'buildWidget', + kind: 'Function', + metaV2: { + chunkId: stableChunkId, + chunkUid: stableChunkUid, + symbol: { symbolId: 'sym1:heur:graph-1' } + }, + codeRelations: { + callLinks: [{ + v: 1, + edgeKind: 'call', + fromChunkUid: stableChunkUid, + to: { + v: 1, + targetName: 'helper', + kindHint: null, + importHint: null, + candidates: [], + status: 'resolved', + resolved: { symbolId: 'sym1:heur:helper', chunkUid: 'ck64:helper' } + } + }] + } + }, + { + file: 'src/collision.js', + name: 'dupName', + kind: 'Function', + metaV2: { chunkId: collisionChunkA, chunkUid: collisionChunkUidA } + }, + { + file: 'src/collision.js', + name: 'dupName', + kind: 'Function', + metaV2: { chunkId: collisionChunkB, chunkUid: collisionChunkUidB } + } + ], + fileRelations: new Map() + }); + assert.equal(graphs.version, 2, 'expected graph_relations version 2'); + const node = graphs.callGraph.nodes.find((entry) => entry.id === stableChunkUid); + assert.ok(node, 'expected call graph node'); + assert.equal(node.chunkUid, stableChunkUid, 'expected chunkUid in graph output'); + assert.equal(node.chunkId, stableChunkId, 'expected stable chunkId in graph attrs'); + assert.equal(node.legacyKey, 'src/graph.js::buildWidget', 'expected legacy key to be preserved'); + const collisionNodes = graphs.callGraph.nodes.filter((entry) => entry.legacyKey === 'src/collision.js::dupName'); + assert.equal(collisionNodes.length, 2, 'expected distinct nodes for colliding legacy keys'); + assert.ok(collisionNodes.some((entry) => entry.id === collisionChunkUidA)); + assert.ok(collisionNodes.some((entry) => entry.id === collisionChunkUidB)); +} + +{ + const graphs = buildRelationGraphs({ + chunks: [ + { + file: 'src/caller.js', + name: 'caller', + kind: 'function', + chunkUid: 'uid-caller', + metaV2: { chunkUid: 'uid-caller', chunkId: 'chunk-caller', symbol: { symbolId: 'sym1:heur:caller' } }, + codeRelations: { + callLinks: [ + { + v: 1, + edgeKind: 'call', + fromChunkUid: 'uid-caller', + to: { + v: 1, + targetName: 'target', + kindHint: null, + importHint: null, + candidates: [], + status: 'resolved', + resolved: { symbolId: 'sym1:heur:target', chunkUid: 'uid-target' } + } + }, + { + v: 1, + edgeKind: 'call', + fromChunkUid: 'uid-caller', + to: { + v: 1, + targetName: 'ambiguous', + kindHint: null, + importHint: null, + candidates: [ + { symbolId: 'sym1:heur:a1', chunkUid: 'uid-a1', symbolKey: 'amb', signatureKey: null, kindGroup: 'function' }, + { symbolId: 'sym1:heur:a2', chunkUid: 'uid-a2', symbolKey: 'amb', signatureKey: null, kindGroup: 'function' } + ], + status: 'ambiguous', + resolved: null + } + } + ] + } + }, + { + file: 'src/target.js', + name: 'target', + kind: 'function', + chunkUid: 'uid-target', + metaV2: { chunkUid: 'uid-target', chunkId: 'chunk-target', symbol: { symbolId: 'sym1:heur:target' } } + } + ], + fileRelations: new Map() + }); + const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); + assert.ok(callerNode, 'expected caller node'); + assert.deepEqual(callerNode.out, ['uid-target'], 'only resolved edges should be emitted'); +} + +{ + const rows = createCallSites({ + chunks: [ + { + id: 1, + file: 'b.ts', + lang: 'typescript', + codeRelations: { + callDetails: [ + { + caller: 'beta', + callee: 'zeta.do', + start: 20, + end: 24, + startLine: 2, + startCol: 1, + endLine: 2, + endCol: 5, + args: ['b'] + } + ] + } + }, + { + id: 0, + file: 'a.ts', + lang: 'typescript', + codeRelations: { + callDetails: [ + { + caller: 'alpha', + callee: 'alpha.run', + start: 5, + end: 9, + startLine: 1, + startCol: 1, + endLine: 1, + endCol: 5, + args: ['a'] + } + ] + } + } + ] + }); + const reversed = createCallSites({ + chunks: [ + { + id: 0, + file: 'a.ts', + lang: 'typescript', + codeRelations: { + callDetails: [ + { + caller: 'alpha', + callee: 'alpha.run', + start: 5, + end: 9, + startLine: 1, + startCol: 1, + endLine: 1, + endCol: 5, + args: ['a'] + } + ] + } + }, + { + id: 1, + file: 'b.ts', + lang: 'typescript', + codeRelations: { + callDetails: [ + { + caller: 'beta', + callee: 'zeta.do', + start: 20, + end: 24, + startLine: 2, + startCol: 1, + endLine: 2, + endCol: 5, + args: ['b'] + } + ] + } + } + ] + }); + assert.equal(rows.length, 2, 'expected two call_sites rows'); + assert.equal(rows[0].file, 'a.ts', 'call_sites should be ordered by file'); + assert.equal(rows[1].file, 'b.ts', 'call_sites should be ordered by file'); + assert.equal(rows[0].calleeNormalized, 'run', 'calleeNormalized should be derived'); + assert.deepEqual(rows, reversed, 'call_sites ordering should be deterministic'); +} + +{ + const source = ` +function alpha() { + obj.method("alpha", 1, true, foo, bar, baz); + fn(a(b())); +} +`; + const relations = buildCodeRelations(source, 'sample.js', { ext: '.js' }); + const details = Array.isArray(relations?.callDetails) ? relations.callDetails : []; + assert.ok(details.length >= 2, 'expected at least two JS call details'); + const methodCall = details.find((detail) => detail.calleeRaw === 'obj.method'); + assert.ok(methodCall, 'expected obj.method call detail'); + assert.equal(methodCall.calleeNormalized, 'method'); + assert.equal(methodCall.receiver, 'obj'); + assert.ok(Number.isFinite(methodCall.startLine)); + assert.ok(Number.isFinite(methodCall.startCol)); + assert.ok(Array.isArray(methodCall.args)); + assert.ok(methodCall.args.length <= 5); +} + +{ + const tsSource = ` +function beta() { + foo?.bar(1, 2, 3, 4, 5, 6); +} +`; + const tsRelations = buildTypeScriptRelations(tsSource, null, { ext: '.ts' }); + const tsDetails = Array.isArray(tsRelations?.callDetails) ? tsRelations.callDetails : []; + assert.ok(tsDetails.length >= 1, 'expected call details from TS'); + const call = tsDetails[0]; + assert.ok(call.calleeRaw); + assert.ok(call.calleeNormalized); + assert.ok(Number.isFinite(call.startLine)); + assert.ok(Number.isFinite(call.startCol)); + assert.ok(call.args.length <= 5); + + const tsxSource = ` +const View = () =>
{bar()}
; +`; + const tsxRelations = buildTypeScriptRelations(tsxSource, null, { ext: '.tsx' }); + const tsxDetails = Array.isArray(tsxRelations?.callDetails) ? tsxRelations.callDetails : []; + assert.ok(tsxDetails.length >= 1, 'expected TSX call details'); + assert.ok(tsxDetails.some((detail) => detail.calleeNormalized === 'bar')); + + const segments = discoverSegments({ + text: tsxSource, + ext: '.tsx', + relPath: 'sample.tsx', + mode: 'code', + languageId: 'typescript', + segmentsConfig: null, + extraSegments: [] + }); + const chunks = chunkSegments({ + text: tsxSource, + ext: '.tsx', + relPath: 'sample.tsx', + mode: 'code', + segments, + lineIndex: buildLineIndex(tsxSource) + }); + const barCall = tsxDetails.find((detail) => detail.calleeNormalized === 'bar'); + assert.ok(barCall, 'expected bar() call detail'); + assert.ok(Number.isFinite(barCall.start) && Number.isFinite(barCall.end)); + assert.ok(tsxSource.slice(barCall.start, barCall.end).includes('bar')); + const containingChunk = chunks.find((chunk) => barCall.start >= chunk.start && barCall.end <= chunk.end); + assert.ok(containingChunk, 'expected callsite to map to a chunk in container space'); +} + +console.log('relations call graph contract matrix test passed'); diff --git a/tests/indexing/relations/call-sites-ordering.test.js b/tests/indexing/relations/call-sites-ordering.test.js deleted file mode 100644 index 3ad4f6d7a..000000000 --- a/tests/indexing/relations/call-sites-ordering.test.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createCallSites } from '../../../src/index/build/artifacts/writers/call-sites.js'; - -const chunks = [ - { - id: 1, - file: 'b.ts', - lang: 'typescript', - codeRelations: { - callDetails: [ - { - caller: 'beta', - callee: 'zeta.do', - start: 20, - end: 24, - startLine: 2, - startCol: 1, - endLine: 2, - endCol: 5, - args: ['b'] - } - ] - } - }, - { - id: 0, - file: 'a.ts', - lang: 'typescript', - codeRelations: { - callDetails: [ - { - caller: 'alpha', - callee: 'alpha.run', - start: 5, - end: 9, - startLine: 1, - startCol: 1, - endLine: 1, - endCol: 5, - args: ['a'] - } - ] - } - } -]; - -const rows = createCallSites({ chunks }); -const reversed = createCallSites({ chunks: [chunks[1], chunks[0]] }); -assert.equal(rows.length, 2, 'expected two call_sites rows'); -assert.equal(rows[0].file, 'a.ts', 'call_sites should be ordered by file'); -assert.equal(rows[1].file, 'b.ts', 'call_sites should be ordered by file'); -assert.equal(rows[0].calleeNormalized, 'run', 'calleeNormalized should be derived'); -assert.deepEqual(rows, reversed, 'call_sites ordering should be deterministic'); - -console.log('call_sites ordering test passed'); diff --git a/tests/indexing/relations/clike-callable-only-relations.test.js b/tests/indexing/relations/clike-callable-only.test.js similarity index 100% rename from tests/indexing/relations/clike-callable-only-relations.test.js rename to tests/indexing/relations/clike-callable-only.test.js diff --git a/tests/indexing/relations/relations-collision-guard.test.js b/tests/indexing/relations/collision-guard.test.js similarity index 100% rename from tests/indexing/relations/relations-collision-guard.test.js rename to tests/indexing/relations/collision-guard.test.js diff --git a/tests/indexing/relations/determinism-bench-contract.test.js b/tests/indexing/relations/determinism-bench-contract.test.js new file mode 100644 index 000000000..86174bea6 --- /dev/null +++ b/tests/indexing/relations/determinism-bench-contract.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + buildRelationBenchChunks, + buildRelationBenchFileRelations +} from '../../../tools/bench/index/relations-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { writeAndLoadRelationBenchGraphArtifacts } from './helpers/graph-relations-artifact-fixture.js'; + +applyTestEnv({ testing: '1' }); + +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +const root = process.cwd(); +const testRoot = resolveTestCachePath(root, 'relations-determinism'); + +const chunks = buildRelationBenchChunks({ chunkCount: 1500, edgesPerChunk: 2, fileModulo: 50 }); +const fileRelations = buildRelationBenchFileRelations(); + +const runOnce = async ({ label, maxJsonBytes }) => { + const outDir = path.join(testRoot, label); + await fs.rm(outDir, { recursive: true, force: true }); + await fs.mkdir(outDir, { recursive: true }); + + const { rows } = await writeAndLoadRelationBenchGraphArtifacts({ + outDir, + chunks, + fileRelations, + maxJsonBytes + }); + + return { rows }; +}; + +// Vary sharding/spill behavior by using different maxJsonBytes values. +const runSmall = await runOnce({ label: 'small', maxJsonBytes: 32 * 1024 }); +const runLarge = await runOnce({ label: 'large', maxJsonBytes: 2 * 1024 * 1024 }); + +if (stableStringify(runSmall.rows) !== stableStringify(runLarge.rows)) { + fail('relations determinism test failed: graph_relations rows differ between runs.'); +} + +console.log('relations determinism test passed'); diff --git a/tests/indexing/relations/graph-call-sites-fallback.test.js b/tests/indexing/relations/graph-call-sites-fallback.test.js deleted file mode 100644 index 8cd7b9935..000000000 --- a/tests/indexing/relations/graph-call-sites-fallback.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; - -const chunks = [ - { - file: 'caller.js', - name: 'caller', - kind: 'function', - chunkUid: 'uid-caller', - codeRelations: { - callLinks: [ - { - v: 1, - edgeKind: 'call', - fromChunkUid: 'uid-caller', - to: { - v: 1, - targetName: 'target', - kindHint: null, - importHint: null, - candidates: [], - status: 'resolved', - resolved: { symbolId: 'sym1:heur:target', chunkUid: 'uid-target' } - }, - legacy: { legacy: true, file: 'target.js', target: 'target', kind: 'function' } - } - ] - } - }, - { - file: 'target.js', - name: 'target', - kind: 'function', - chunkUid: 'uid-target' - } -]; - -const graphs = buildRelationGraphs({ chunks }); -const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); -assert.ok(callerNode, 'expected caller node in call graph'); -assert.deepEqual(callerNode.out, ['uid-target'], 'callLinks should populate call graph when callSites absent'); - -console.log('graph call_sites fallback test passed'); diff --git a/tests/indexing/relations/graph-call-sites-preferred.test.js b/tests/indexing/relations/graph-call-sites-preferred.test.js deleted file mode 100644 index e56df2417..000000000 --- a/tests/indexing/relations/graph-call-sites-preferred.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; - -const chunks = [ - { - file: 'caller.js', - name: 'caller', - kind: 'function', - chunkUid: 'uid-caller', - codeRelations: { - callLinks: [ - { - v: 1, - edgeKind: 'call', - fromChunkUid: 'uid-caller', - to: { - v: 1, - targetName: 'other', - kindHint: null, - importHint: null, - candidates: [], - status: 'resolved', - resolved: { symbolId: 'sym1:heur:other', chunkUid: 'uid-other' } - }, - legacy: { legacy: true, file: 'other.js', target: 'other', kind: 'function' } - } - ] - } - }, - { - file: 'target.js', - name: 'target', - kind: 'function', - chunkUid: 'uid-target' - }, - { - file: 'other.js', - name: 'other', - kind: 'function', - chunkUid: 'uid-other' - } -]; - -const callSites = [ - { callerChunkUid: 'uid-caller', targetChunkUid: 'uid-target' } -]; - -const graphs = buildRelationGraphs({ chunks, callSites }); -const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); -assert.ok(callerNode, 'expected caller node in call graph'); -assert.deepEqual(callerNode.out, ['uid-other', 'uid-target'], 'callSites should union with callLinks for call graph'); - -console.log('graph call_sites union test passed'); diff --git a/tests/indexing/relations/graph-chunk-id.test.js b/tests/indexing/relations/graph-chunk-id.test.js deleted file mode 100644 index 002381d09..000000000 --- a/tests/indexing/relations/graph-chunk-id.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; - -const stableChunkId = 'chunk_graph_1'; -const stableChunkUid = 'ck64:graph-1'; -const collisionChunkA = 'chunk_graph_a'; -const collisionChunkB = 'chunk_graph_b'; -const collisionChunkUidA = 'ck64:dup-a'; -const collisionChunkUidB = 'ck64:dup-b'; -const chunks = [ - { - file: 'src/graph.js', - name: 'buildWidget', - kind: 'Function', - metaV2: { - chunkId: stableChunkId, - chunkUid: stableChunkUid, - symbol: { symbolId: 'sym1:heur:graph-1' } - }, - codeRelations: { - callLinks: [{ - v: 1, - edgeKind: 'call', - fromChunkUid: stableChunkUid, - to: { - v: 1, - targetName: 'helper', - kindHint: null, - importHint: null, - candidates: [], - status: 'resolved', - resolved: { symbolId: 'sym1:heur:helper', chunkUid: 'ck64:helper' } - } - }] - } - }, - { - file: 'src/collision.js', - name: 'dupName', - kind: 'Function', - metaV2: { chunkId: collisionChunkA, chunkUid: collisionChunkUidA } - }, - { - file: 'src/collision.js', - name: 'dupName', - kind: 'Function', - metaV2: { chunkId: collisionChunkB, chunkUid: collisionChunkUidB } - } -]; - -const graphs = buildRelationGraphs({ chunks, fileRelations: new Map() }); -assert.equal(graphs.version, 2, 'expected graph_relations version 2'); -const node = graphs.callGraph.nodes.find((entry) => entry.id === stableChunkUid); -assert.ok(node, 'expected call graph node'); -assert.equal(node.chunkUid, stableChunkUid, 'expected chunkUid in graph output'); -assert.equal(node.chunkId, stableChunkId, 'expected stable chunkId in graph attrs'); -assert.equal(node.legacyKey, 'src/graph.js::buildWidget', 'expected legacy key to be preserved'); - -const collisionNodes = graphs.callGraph.nodes.filter((entry) => entry.legacyKey === 'src/collision.js::dupName'); -assert.equal(collisionNodes.length, 2, 'expected distinct nodes for colliding legacy keys'); -assert.ok(collisionNodes.some((entry) => entry.id === collisionChunkUidA)); -assert.ok(collisionNodes.some((entry) => entry.id === collisionChunkUidB)); - -console.log('graph chunk id test passed'); diff --git a/tests/indexing/relations/graph-relations-v2-chunkuid.test.js b/tests/indexing/relations/graph-relations-v2-chunkuid.test.js deleted file mode 100644 index efa1ba771..000000000 --- a/tests/indexing/relations/graph-relations-v2-chunkuid.test.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; - -const chunks = [ - { - file: 'src/caller.js', - name: 'caller', - kind: 'function', - chunkUid: 'uid-caller', - metaV2: { chunkUid: 'uid-caller', chunkId: 'chunk-caller', symbol: { symbolId: 'sym1:heur:caller' } }, - codeRelations: { - callLinks: [ - { - v: 1, - edgeKind: 'call', - fromChunkUid: 'uid-caller', - to: { - v: 1, - targetName: 'target', - kindHint: null, - importHint: null, - candidates: [], - status: 'resolved', - resolved: { symbolId: 'sym1:heur:target', chunkUid: 'uid-target' } - } - }, - { - v: 1, - edgeKind: 'call', - fromChunkUid: 'uid-caller', - to: { - v: 1, - targetName: 'ambiguous', - kindHint: null, - importHint: null, - candidates: [ - { symbolId: 'sym1:heur:a1', chunkUid: 'uid-a1', symbolKey: 'amb', signatureKey: null, kindGroup: 'function' }, - { symbolId: 'sym1:heur:a2', chunkUid: 'uid-a2', symbolKey: 'amb', signatureKey: null, kindGroup: 'function' } - ], - status: 'ambiguous', - resolved: null - } - } - ] - } - }, - { - file: 'src/target.js', - name: 'target', - kind: 'function', - chunkUid: 'uid-target', - metaV2: { chunkUid: 'uid-target', chunkId: 'chunk-target', symbol: { symbolId: 'sym1:heur:target' } } - } -]; - -const graphs = buildRelationGraphs({ chunks, fileRelations: new Map() }); -assert.equal(graphs.version, 2, 'expected graph_relations version 2'); -const callerNode = graphs.callGraph.nodes.find((node) => node.id === 'uid-caller'); -assert.ok(callerNode, 'expected caller node'); -assert.deepEqual(callerNode.out, ['uid-target'], 'only resolved edges should be emitted'); - -console.log('graph relations v2 chunkUid test passed'); diff --git a/tests/indexing/relations/helpers/graph-relations-artifact-fixture.js b/tests/indexing/relations/helpers/graph-relations-artifact-fixture.js new file mode 100644 index 000000000..a500ffd4e --- /dev/null +++ b/tests/indexing/relations/helpers/graph-relations-artifact-fixture.js @@ -0,0 +1,37 @@ +import { MAX_JSON_BYTES, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; +import { writeRelationBenchGraphArtifacts } from '../../../../tools/bench/index/relations-fixture.js'; + +export const writeAndLoadRelationBenchGraphArtifacts = async ({ + outDir, + chunks, + fileRelations, + maxJsonBytes, + loadMaxBytes = MAX_JSON_BYTES +}) => { + const pieces = []; + await writeRelationBenchGraphArtifacts({ + outDir, + chunks, + fileRelations, + maxJsonBytes, + onPiece: (entry, _filePath, label) => { + pieces.push({ ...entry, path: label }); + } + }); + + const manifest = { + version: 2, + generatedAt: new Date().toISOString(), + mode: 'code', + stage: 'stage2', + pieces + }; + + const rows = await loadJsonArrayArtifact(outDir, 'graph_relations', { + manifest, + strict: true, + maxBytes: loadMaxBytes + }); + + return { pieces, manifest, rows }; +}; diff --git a/tests/indexing/relations/js-call-details-v2.test.js b/tests/indexing/relations/js-call-details-v2.test.js deleted file mode 100644 index af51dec2a..000000000 --- a/tests/indexing/relations/js-call-details-v2.test.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildCodeRelations } from '../../../src/lang/javascript.js'; - -const source = ` -function alpha() { - obj.method("alpha", 1, true, foo, bar, baz); - fn(a(b())); -} -`; - -const relations = buildCodeRelations(source, 'sample.js', { ext: '.js' }); -const details = Array.isArray(relations?.callDetails) ? relations.callDetails : []; -assert.ok(details.length >= 2, 'expected at least two call details'); - -const methodCall = details.find((detail) => detail.calleeRaw === 'obj.method'); -assert.ok(methodCall, 'expected obj.method call detail'); -assert.equal(methodCall.calleeNormalized, 'method', 'calleeNormalized should use leaf name'); -assert.equal(methodCall.receiver, 'obj', 'receiver should be object for member calls'); -assert.ok(Number.isFinite(methodCall.startLine), 'startLine should be set'); -assert.ok(Number.isFinite(methodCall.startCol), 'startCol should be set'); -assert.ok(Array.isArray(methodCall.args), 'args should be an array'); -assert.ok(methodCall.args.length <= 5, 'args should be capped'); - -console.log('js call details v2 test passed'); diff --git a/tests/indexing/relations/relations-memory-budget.test.js b/tests/indexing/relations/memory-budget.test.js similarity index 100% rename from tests/indexing/relations/relations-memory-budget.test.js rename to tests/indexing/relations/memory-budget.test.js diff --git a/tests/indexing/relations/relations-memory-release.test.js b/tests/indexing/relations/memory-release.test.js similarity index 100% rename from tests/indexing/relations/relations-memory-release.test.js rename to tests/indexing/relations/memory-release.test.js diff --git a/tests/indexing/relations/relations-atomicity-rollback.test.js b/tests/indexing/relations/relations-atomicity-rollback.test.js deleted file mode 100644 index 07b9124fe..000000000 --- a/tests/indexing/relations/relations-atomicity-rollback.test.js +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { MAX_JSON_BYTES, loadJsonArrayArtifact, readJsonFile } from '../../../src/shared/artifact-io.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { enqueueGraphRelationsArtifacts } from '../../../src/index/build/artifacts/graph-relations.js'; -import { fromPosix } from '../../../src/shared/files.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const fail = (message) => { - console.error(message); - process.exit(1); -}; - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'relations-atomicity-rollback'); -const outDir = path.join(testRoot, 'index-code'); - -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); - -const buildChunks = ({ chunkCount = 250, edgesFromFirst = 1500 } = {}) => { - const chunks = new Array(chunkCount); - for (let i = 0; i < chunkCount; i += 1) { - const file = `src/file-${String(i % 20).padStart(3, '0')}.js`; - const uid = `u${i}`; - const callDetails = []; - const edgeCount = i === 0 ? edgesFromFirst : 2; - for (let j = 1; j <= edgeCount; j += 1) { - callDetails.push({ targetChunkUid: `u${(i + j) % chunkCount}` }); - } - chunks[i] = { - file, - ext: '.js', - name: `sym${i}`, - kind: 'FunctionDeclaration', - chunkUid: uid, - metaV2: { - chunkUid: uid, - lang: 'javascript', - effective: { languageId: 'javascript' }, - symbol: { symbolId: `sym-${i}` } - }, - codeRelations: { callDetails } - }; - } - return chunks; -}; - -const fileRelations = new Map([ - ['src/file-000.js', { importLinks: ['src/file-001.js'] }], - ['src/file-001.js', { importLinks: ['src/file-000.js'] }] -]); -const chunks = buildChunks(); - -const runBuild = async ({ maxJsonBytes }) => { - const pieces = []; - const toPosix = (value) => value.split(path.sep).join('/'); - const formatArtifactLabel = (filePath) => toPosix(path.relative(outDir, filePath)); - const removeArtifact = async (targetPath) => { - await fs.rm(targetPath, { recursive: true, force: true }).catch(() => {}); - }; - - await enqueueGraphRelationsArtifacts({ - graphRelations: null, - chunks, - fileRelations, - callSites: null, - caps: null, - outDir, - maxJsonBytes, - byteBudget: null, - log: null, - enqueueWrite: () => { - throw new Error('enqueueWrite should not be called by streaming graph_relations build'); - }, - addPieceFile: (entry, filePath) => { - pieces.push({ ...entry, path: formatArtifactLabel(filePath) }); - }, - formatArtifactLabel, - removeArtifact, - stageCheckpoints: null - }); - - const manifest = { - version: 2, - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'stage2', - pieces - }; - const rows = await loadJsonArrayArtifact(outDir, 'graph_relations', { - manifest, - strict: true, - maxBytes: MAX_JSON_BYTES - }); - return { pieces, rows }; -}; - -// First run should succeed and write stable artifacts. -const baseline = await runBuild({ maxJsonBytes: 512 * 1024 }); -const baselineHash = stableStringify(baseline.rows); - -// Second run should fail on a too-small maxJsonBytes (single-row overflow) but must not delete baseline. -let threw = false; -try { - await runBuild({ maxJsonBytes: 96 }); -} catch (err) { - threw = true; - const message = err?.message || String(err); - if (!message.includes('exceeds maxBytes')) { - fail(`relations atomicity rollback test failed: unexpected error: ${message}`); - } -} -if (!threw) { - fail('relations atomicity rollback test failed: expected rebuild to throw.'); -} - -// Verify baseline artifacts still load after the failed rebuild. -const metaRaw = readJsonFile(path.join(outDir, 'graph_relations.meta.json')); -const meta = metaRaw?.fields && typeof metaRaw.fields === 'object' ? metaRaw.fields : metaRaw; -const parts = Array.isArray(meta?.parts) ? meta.parts : []; -if (!parts.length) { - fail('relations atomicity rollback test failed: missing graph_relations parts after failure.'); -} - -const manifestAfter = { - version: 2, - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'stage2', - pieces: [ - ...parts.map((part) => ({ type: 'relations', name: 'graph_relations', format: 'jsonl', path: part.path })), - { type: 'relations', name: 'graph_relations_meta', format: 'json', path: 'graph_relations.meta.json' } - ] -}; -for (const part of parts) { - const abs = path.join(outDir, fromPosix(part.path)); - await fs.stat(abs).catch(() => fail(`relations atomicity rollback test failed: missing ${part.path}`)); -} - -const loadedAfter = await loadJsonArrayArtifact(outDir, 'graph_relations', { - manifest: manifestAfter, - strict: true, - maxBytes: MAX_JSON_BYTES -}); - -if (stableStringify(loadedAfter) !== baselineHash) { - fail('relations atomicity rollback test failed: graph_relations output changed after failed rebuild.'); -} - -console.log('relations atomicity rollback test passed'); - diff --git a/tests/indexing/relations/relations-determinism-bench-contract.test.js b/tests/indexing/relations/relations-determinism-bench-contract.test.js deleted file mode 100644 index d01ed7b38..000000000 --- a/tests/indexing/relations/relations-determinism-bench-contract.test.js +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { MAX_JSON_BYTES, loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { enqueueGraphRelationsArtifacts } from '../../../src/index/build/artifacts/graph-relations.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const fail = (message) => { - console.error(message); - process.exit(1); -}; - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'relations-determinism'); - -const buildChunks = ({ chunkCount = 1500, edgesPerChunk = 2 } = {}) => { - const chunks = new Array(chunkCount); - for (let i = 0; i < chunkCount; i += 1) { - const file = `src/file-${String(i % 50).padStart(3, '0')}.js`; - const uid = `u${i}`; - const callDetails = []; - for (let j = 1; j <= edgesPerChunk; j += 1) { - callDetails.push({ targetChunkUid: `u${(i + j) % chunkCount}` }); - } - chunks[i] = { - file, - ext: '.js', - name: `sym${i}`, - kind: 'FunctionDeclaration', - chunkUid: uid, - metaV2: { - chunkUid: uid, - lang: 'javascript', - effective: { languageId: 'javascript' }, - symbol: { symbolId: `sym-${i}` } - }, - codeRelations: { callDetails } - }; - } - return chunks; -}; - -const buildFileRelations = () => new Map([ - ['src/file-000.js', { importLinks: ['src/file-001.js'] }], - ['src/file-001.js', { importLinks: ['src/file-000.js'] }] -]); - -const chunks = buildChunks(); -const fileRelations = buildFileRelations(); - -const runOnce = async ({ label, maxJsonBytes }) => { - const outDir = path.join(testRoot, label); - await fs.rm(outDir, { recursive: true, force: true }); - await fs.mkdir(outDir, { recursive: true }); - - const pieces = []; - const toPosix = (value) => value.split(path.sep).join('/'); - const formatArtifactLabel = (filePath) => toPosix(path.relative(outDir, filePath)); - const removeArtifact = async (targetPath) => { - await fs.rm(targetPath, { recursive: true, force: true }).catch(() => {}); - }; - - await enqueueGraphRelationsArtifacts({ - graphRelations: null, - chunks, - fileRelations, - callSites: null, - caps: null, - outDir, - maxJsonBytes, - byteBudget: null, - log: null, - enqueueWrite: () => { - throw new Error('enqueueWrite should not be called by streaming graph_relations build'); - }, - addPieceFile: (entry, filePath) => { - pieces.push({ ...entry, path: formatArtifactLabel(filePath) }); - }, - formatArtifactLabel, - removeArtifact, - stageCheckpoints: null - }); - - const manifest = { - version: 2, - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'stage2', - pieces - }; - - const rows = await loadJsonArrayArtifact(outDir, 'graph_relations', { - manifest, - strict: true, - maxBytes: MAX_JSON_BYTES - }); - - return { rows }; -}; - -// Vary sharding/spill behavior by using different maxJsonBytes values. -const runSmall = await runOnce({ label: 'small', maxJsonBytes: 32 * 1024 }); -const runLarge = await runOnce({ label: 'large', maxJsonBytes: 2 * 1024 * 1024 }); - -if (stableStringify(runSmall.rows) !== stableStringify(runLarge.rows)) { - fail('relations determinism test failed: graph_relations rows differ between runs.'); -} - -console.log('relations determinism test passed'); diff --git a/tests/indexing/relations/segment-call-site-offsets.test.js b/tests/indexing/relations/segment-call-site-offsets.test.js deleted file mode 100644 index 55d2c1703..000000000 --- a/tests/indexing/relations/segment-call-site-offsets.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildLineIndex } from '../../../src/shared/lines.js'; -import { discoverSegments, chunkSegments } from '../../../src/index/segments.js'; -import { buildTypeScriptRelations } from '../../../src/lang/typescript.js'; - -const source = ` -const View = () =>
{bar()}
; -`; - -const segments = discoverSegments({ - text: source, - ext: '.tsx', - relPath: 'sample.tsx', - mode: 'code', - languageId: 'typescript', - segmentsConfig: null, - extraSegments: [] -}); - -const chunks = chunkSegments({ - text: source, - ext: '.tsx', - relPath: 'sample.tsx', - mode: 'code', - segments, - lineIndex: buildLineIndex(source) -}); - -const relations = buildTypeScriptRelations(source, null, { ext: '.tsx' }); -const call = Array.isArray(relations?.callDetails) - ? relations.callDetails.find((detail) => detail.calleeNormalized === 'bar') - : null; - -assert.ok(call, 'expected bar() call detail'); -assert.ok(Number.isFinite(call.start) && Number.isFinite(call.end), 'call detail should include offsets'); -assert.ok(source.slice(call.start, call.end).includes('bar'), 'offsets should be absolute in container text'); -const containingChunk = chunks.find((chunk) => call.start >= chunk.start && call.end <= chunk.end); -assert.ok(containingChunk, 'expected callsite to map to a chunk in container space'); - -console.log('segment callsite offset test passed'); diff --git a/tests/indexing/relations/ts-call-details-tsx.test.js b/tests/indexing/relations/ts-call-details-tsx.test.js deleted file mode 100644 index ed8f5d60c..000000000 --- a/tests/indexing/relations/ts-call-details-tsx.test.js +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildTypeScriptRelations } from '../../../src/lang/typescript.js'; - -const source = ` -const View = () =>
{bar()}
; -`; - -const relations = buildTypeScriptRelations(source, null, { ext: '.tsx' }); -const details = Array.isArray(relations?.callDetails) ? relations.callDetails : []; -assert.ok(details.length >= 1, 'expected TSX call details'); -assert.ok(details.some((detail) => detail.calleeNormalized === 'bar'), 'expected bar() call detail'); - -console.log('tsx call details v2 test passed'); diff --git a/tests/indexing/relations/ts-call-details-v2.test.js b/tests/indexing/relations/ts-call-details-v2.test.js deleted file mode 100644 index 64e3aebdd..000000000 --- a/tests/indexing/relations/ts-call-details-v2.test.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildTypeScriptRelations } from '../../../src/lang/typescript.js'; - -const source = ` -function beta() { - foo?.bar(1, 2, 3, 4, 5, 6); -} -`; - -const relations = buildTypeScriptRelations(source, null, { ext: '.ts' }); -const details = Array.isArray(relations?.callDetails) ? relations.callDetails : []; -assert.ok(details.length >= 1, 'expected call details from TS'); - -const call = details[0]; -assert.ok(call.calleeRaw, 'calleeRaw should be set'); -assert.ok(call.calleeNormalized, 'calleeNormalized should be set'); -assert.ok(Number.isFinite(call.startLine), 'startLine should be set'); -assert.ok(Number.isFinite(call.startCol), 'startCol should be set'); -assert.ok(call.args.length <= 5, 'args should be capped'); - -console.log('ts call details v2 test passed'); diff --git a/tests/indexing/repo-map/repo-map-roundtrip.test.js b/tests/indexing/repo-map/repo-map-roundtrip.test.js deleted file mode 100644 index 607fbf476..000000000 --- a/tests/indexing/repo-map/repo-map-roundtrip.test.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { SHARDED_JSONL_META_SCHEMA_VERSION } from '../../../src/contracts/versioning.js'; -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { createRepoMapIterator } from '../../../src/index/build/artifacts/writers/repo-map.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const cacheRoot = resolveTestCachePath(root, 'repo-map-roundtrip'); -const outDir = path.join(cacheRoot, 'index-code'); - -await fs.rm(cacheRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); - -const chunks = [ - { file: 'src/a.js', ext: '.js', name: 'alpha', kind: 'FunctionDeclaration', startLine: 1, endLine: 2, docmeta: { signature: '(a)' } }, - { file: 'src/a.js', ext: '.js', name: 'alpha', kind: 'FunctionDeclaration', startLine: 1, endLine: 2, docmeta: { signature: '(a)' } }, // dup - { file: 'src/a.js', ext: '.js', name: 'beta', kind: 'VariableDeclaration', startLine: 10, endLine: 11, docmeta: { signature: null } }, - { file: 'src/b.js', ext: '.js', name: 'gamma', kind: 'FunctionDeclaration', startLine: 3, endLine: 4, docmeta: { signature: '(g)' } } -]; -const fileRelations = new Map([ - ['src/a.js', { exports: ['alpha'] }] -]); - -const iterator = createRepoMapIterator({ chunks, fileRelations }); -const expected = Array.from(iterator()); - -const maxBytes = 256; -const result = await writeJsonLinesSharded({ - dir: outDir, - partsDirName: 'repo_map.parts', - partPrefix: 'repo_map.part-', - items: expected, - maxBytes, - atomic: true -}); - -const parts = result.parts.map((part, index) => ({ - path: part, - records: result.counts[index] || 0, - bytes: result.bytes[index] || 0 -})); - -await writeJsonObjectFile(path.join(outDir, 'repo_map.meta.json'), { - fields: { - schemaVersion: SHARDED_JSONL_META_SCHEMA_VERSION, - artifact: 'repo_map', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: result.total, - totalBytes: result.totalBytes, - maxPartRecords: result.maxPartRecords, - maxPartBytes: result.maxPartBytes, - targetMaxBytes: result.targetMaxBytes, - parts - }, - atomic: true -}); - -const manifest = { - version: 2, - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'stage2', - pieces: [ - ...result.parts.map((relPath, index) => ({ - type: 'chunks', - name: 'repo_map', - format: 'jsonl', - count: result.counts[index] || 0, - path: relPath - })), - { - type: 'chunks', - name: 'repo_map_meta', - format: 'json', - path: 'repo_map.meta.json' - } - ] -}; - -const loaded = await loadJsonArrayArtifact(outDir, 'repo_map', { - manifest, - strict: true, - maxBytes: 1024 * 1024 -}); - -if (stableStringify(loaded) !== stableStringify(expected)) { - console.error('repo-map roundtrip failed: loaded rows mismatch.'); - process.exit(1); -} - -console.log('repo-map roundtrip test passed'); diff --git a/tests/indexing/repo-map/roundtrip.test.js b/tests/indexing/repo-map/roundtrip.test.js new file mode 100644 index 000000000..124cf2b23 --- /dev/null +++ b/tests/indexing/repo-map/roundtrip.test.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { SHARDED_JSONL_META_SCHEMA_VERSION } from '../../../src/contracts/versioning.js'; +import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { createRepoMapIterator } from '../../../src/index/build/artifacts/writers/repo-map.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const cacheRoot = resolveTestCachePath(root, 'repo-map-roundtrip'); +const outDir = path.join(cacheRoot, 'index-code'); + +await fs.rm(cacheRoot, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); + +const chunks = [ + { file: 'src/a.js', ext: '.js', name: 'alpha', kind: 'FunctionDeclaration', startLine: 1, endLine: 2, docmeta: { signature: '(a)' } }, + { file: 'src/a.js', ext: '.js', name: 'alpha', kind: 'FunctionDeclaration', startLine: 1, endLine: 2, docmeta: { signature: '(a)' } }, // dup + { file: 'src/a.js', ext: '.js', name: 'beta', kind: 'VariableDeclaration', startLine: 10, endLine: 11, docmeta: { signature: null } }, + { file: 'src/b.js', ext: '.js', name: 'gamma', kind: 'FunctionDeclaration', startLine: 3, endLine: 4, docmeta: { signature: '(g)' } } +]; +const fileRelations = new Map([ + ['src/a.js', { exports: ['alpha'] }] +]); + +const iterator = createRepoMapIterator({ chunks, fileRelations }); +const expected = Array.from(iterator()); + +const maxBytes = 256; +const result = await writeJsonLinesSharded({ + dir: outDir, + partsDirName: 'repo_map.parts', + partPrefix: 'repo_map.part-', + items: expected, + maxBytes, + atomic: true +}); + +const parts = result.parts.map((part, index) => ({ + path: part, + records: result.counts[index] || 0, + bytes: result.bytes[index] || 0 +})); + +await writeJsonObjectFile(path.join(outDir, 'repo_map.meta.json'), { + fields: { + schemaVersion: SHARDED_JSONL_META_SCHEMA_VERSION, + artifact: 'repo_map', + format: 'jsonl-sharded', + generatedAt: new Date().toISOString(), + compression: 'none', + totalRecords: result.total, + totalBytes: result.totalBytes, + maxPartRecords: result.maxPartRecords, + maxPartBytes: result.maxPartBytes, + targetMaxBytes: result.targetMaxBytes, + parts + }, + atomic: true +}); + +const manifest = { + version: 2, + generatedAt: new Date().toISOString(), + mode: 'code', + stage: 'stage2', + pieces: [ + ...result.parts.map((relPath, index) => ({ + type: 'chunks', + name: 'repo_map', + format: 'jsonl', + count: result.counts[index] || 0, + path: relPath + })), + { + type: 'chunks', + name: 'repo_map_meta', + format: 'json', + path: 'repo_map.meta.json' + } + ] +}; + +const loaded = await loadJsonArrayArtifact(outDir, 'repo_map', { + manifest, + strict: true, + maxBytes: 1024 * 1024 +}); + +if (stableStringify(loaded) !== stableStringify(expected)) { + console.error('repo-map roundtrip failed: loaded rows mismatch.'); + process.exit(1); +} + +console.log('repo-map roundtrip test passed'); diff --git a/tests/indexing/risk/interprocedural/artifacts-written.test.js b/tests/indexing/risk/interprocedural/artifacts-written.test.js index 38893ddff..8e36c3d5f 100644 --- a/tests/indexing/risk/interprocedural/artifacts-written.test.js +++ b/tests/indexing/risk/interprocedural/artifacts-written.test.js @@ -4,12 +4,13 @@ import fs from 'node:fs'; import assert from 'node:assert/strict'; import path from 'node:path'; import { ensureFixtureIndex } from '../../../helpers/fixture-index.js'; +import { loadJsonArrayArtifactSync, MAX_JSON_BYTES } from '../../../../src/shared/artifact-io.js'; applyTestEnv(); const { codeDir } = await ensureFixtureIndex({ fixtureName: 'risk-interprocedural/js-simple', - cacheName: 'risk-interprocedural-js-simple', + cacheName: 'risk-interprocedural-js-simple-partial-artifacts', requireRiskTags: true, cacheScope: 'isolated', requiredModes: ['code'] @@ -23,6 +24,7 @@ const hasJsonl = (base) => { assert.ok(hasJsonl('risk_summaries'), 'risk_summaries jsonl missing'); assert.ok(hasJsonl('risk_flows'), 'risk_flows jsonl missing'); +assert.ok(hasJsonl('risk_partial_flows'), 'risk_partial_flows jsonl missing'); assert.ok(fs.existsSync(path.join(codeDir, 'risk_interprocedural_stats.json')), 'risk_interprocedural_stats.json missing'); const summariesMeta = path.join(codeDir, 'risk_summaries.meta.json'); @@ -49,4 +51,40 @@ if (fs.existsSync(flowsMeta)) { } } +const partialFlowsMeta = path.join(codeDir, 'risk_partial_flows.meta.json'); +if (fs.existsSync(partialFlowsMeta)) { + const meta = JSON.parse(fs.readFileSync(partialFlowsMeta, 'utf8')); + const parts = Array.isArray(meta.parts) ? meta.parts : []; + assert.ok(parts.length > 0, 'risk_partial_flows meta should list parts'); + for (const part of parts) { + const rel = typeof part === 'string' ? part : part.path; + assert.ok(rel, 'risk_partial_flows part path missing'); + assert.ok(fs.existsSync(path.join(codeDir, rel)), `risk_partial_flows part missing: ${rel}`); + } +} + +const firstJsonlRow = (base) => { + try { + const rows = loadJsonArrayArtifactSync(codeDir, base, { + maxBytes: MAX_JSON_BYTES, + strict: true + }); + return Array.isArray(rows) && rows.length ? rows[0] : null; + } catch { + return null; + } +}; + +const firstFlow = firstJsonlRow('risk_flows'); +if (firstFlow) { + assert.ok(Array.isArray(firstFlow?.path?.watchByStep), 'risk_flows path.watchByStep missing'); + assert.equal(firstFlow.path.watchByStep.length, firstFlow.path.callSiteIdsByStep.length, 'risk_flows watchByStep length mismatch'); +} + +const firstPartialFlow = firstJsonlRow('risk_partial_flows'); +if (firstPartialFlow) { + assert.ok(Array.isArray(firstPartialFlow?.path?.watchByStep), 'risk_partial_flows path.watchByStep missing'); + assert.equal(firstPartialFlow.path.watchByStep.length, firstPartialFlow.path.callSiteIdsByStep.length, 'risk_partial_flows watchByStep length mismatch'); +} + console.log('risk interprocedural artifacts written test passed'); diff --git a/tests/indexing/risk/interprocedural/config-normalization.test.js b/tests/indexing/risk/interprocedural/config-normalization.test.js index fc8ba2c24..934c53a2d 100644 --- a/tests/indexing/risk/interprocedural/config-normalization.test.js +++ b/tests/indexing/risk/interprocedural/config-normalization.test.js @@ -8,6 +8,7 @@ assert.equal(defaults.summaryOnly, false, 'default summaryOnly should be false') assert.equal(defaults.strictness, 'conservative'); assert.equal(defaults.sanitizerPolicy, 'terminate'); assert.equal(defaults.emitArtifacts, 'jsonl'); +assert.deepEqual(defaults.semantics, [], 'default semantics registry should be empty'); assert.equal(defaults.caps.maxDepth, 4); assert.equal(defaults.caps.maxPathsPerPair, 3); @@ -39,6 +40,41 @@ assert.equal(normalized.caps.maxCallSitesPerEdge, 50, 'maxCallSitesPerEdge shoul assert.equal(normalized.caps.maxEdgeExpansions, 10000, 'maxEdgeExpansions should clamp to min'); assert.equal(normalized.caps.maxMs, null, 'maxMs should allow null'); +const semanticsConfig = normalizeRiskInterproceduralConfig({ + enabled: true, + semantics: [ + { + id: 'sem.callback.register-handler-payload', + kind: 'callback', + name: 'register handler payload handoff', + frameworks: ['express'], + languages: ['javascript'], + patterns: ['\\bregisterHandler\\b'], + fromArgs: [1], + taintHints: ['payload'] + }, + { + id: 'sem.invalid', + kind: 'unsupported', + patterns: ['\\bnope\\b'] + } + ] +}, { mode: 'code' }); +assert.equal(semanticsConfig.semantics.length, 1, 'expected supported semantics entry to be kept'); +assert.equal(semanticsConfig.semantics[0]?.kind, 'callback'); +assert.deepEqual(semanticsConfig.semantics[0]?.frameworks, ['express']); +assert.deepEqual(semanticsConfig.semantics[0]?.fromArgs, [1]); +assert.deepEqual(semanticsConfig.semantics[0]?.taintHints, ['payload']); +assert.deepEqual( + semanticsConfig.semantics[0]?.patterns, + ['\\bregisterHandler\\b'], + 'expected normalized config to retain plain semantics patterns for stable signatures' +); +assert.ok( + semanticsConfig.diagnostics?.warnings?.some((entry) => entry?.code === 'UNSUPPORTED_SEMANTICS_KIND'), + 'expected unsupported semantics kinds to emit diagnostics' +); + const proseMode = normalizeRiskInterproceduralConfig({ enabled: true }, { mode: 'prose' }); assert.equal(proseMode.enabled, false, 'non-code modes should disable interprocedural'); diff --git a/tests/indexing/risk/interprocedural/flows-argaware-negative.test.js b/tests/indexing/risk/interprocedural/flows-argaware-negative.test.js index 3c92c6ad1..a7064dd1d 100644 --- a/tests/indexing/risk/interprocedural/flows-argaware-negative.test.js +++ b/tests/indexing/risk/interprocedural/flows-argaware-negative.test.js @@ -1,116 +1,26 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { buildRiskSummaries } from '../../../../src/index/risk-interprocedural/summaries.js'; -import { computeInterproceduralRisk } from '../../../../src/index/risk-interprocedural/engine.js'; +import { + computeRiskScenario, + createRiskRuntime, + createRiskSinkChunk, + createRiskSourceChunk +} from './helpers/risk-flow-fixtures.js'; -const sourceChunk = { - file: 'src/source.js', - chunkUid: 'uid-source', - name: 'source', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [ - { - id: 'source.req.body', - name: 'req.body', - ruleType: 'source', - category: 'input', - severity: 'low', - confidence: 0.6, - tags: ['input'], - evidence: { line: 1, column: 1, excerpt: 'req.body' } - } - ], - sinks: [], - sanitizers: [], - flows: [] - } - }, - codeRelations: { - callDetails: [ +const result = computeRiskScenario({ + chunks: [ + createRiskSourceChunk({ callLine: 3, callArgs: ['safeValue'] }), + createRiskSinkChunk() + ], + runtime: createRiskRuntime({ + strictness: 'argAware', + sourceRules: [ { - callee: 'sink', - calleeRaw: 'sink', - calleeNormalized: 'sink', - startLine: 3, - startCol: 1, - endLine: 3, - endCol: 8, - args: ['safeValue'], - targetChunkUid: 'uid-sink' + id: 'source.req.body', + patterns: [/req\.body/i] } ] - } -}; - -const sinkChunk = { - file: 'src/sink.js', - chunkUid: 'uid-sink', - name: 'sink', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [], - sinks: [ - { - id: 'sink.eval', - name: 'eval', - ruleType: 'sink', - category: 'code-exec', - severity: 'high', - confidence: 0.8, - tags: ['exec'], - evidence: { line: 2, column: 1, excerpt: 'eval' } - } - ], - sanitizers: [], - flows: [] - } - } -}; - -const runtime = { - riskInterproceduralConfig: { - enabled: true, - summaryOnly: false, - strictness: 'argAware', - sanitizerPolicy: 'terminate', - emitArtifacts: 'jsonl', - caps: { - maxDepth: 4, - maxPathsPerPair: 3, - maxTotalFlows: 100, - maxCallSitesPerEdge: 2, - maxEdgeExpansions: 100, - maxMs: null - } - }, - riskInterproceduralEnabled: true, - riskConfig: { - rules: { - sources: [ - { - id: 'source.req.body', - patterns: [/req\.body/i] - } - ] - } - } -}; - -const { rows } = buildRiskSummaries({ - chunks: [sourceChunk, sinkChunk], - runtime, - mode: 'code' -}); - -const result = computeInterproceduralRisk({ - chunks: [sourceChunk, sinkChunk], - summaries: rows, - runtime + }) }); assert.equal(result.flowRows.length, 0, 'argAware should skip non-tainted args'); diff --git a/tests/indexing/risk/interprocedural/flows-cap-matrix.test.js b/tests/indexing/risk/interprocedural/flows-cap-matrix.test.js index 00e51ddea..222efd770 100644 --- a/tests/indexing/risk/interprocedural/flows-cap-matrix.test.js +++ b/tests/indexing/risk/interprocedural/flows-cap-matrix.test.js @@ -17,6 +17,8 @@ const cases = [ assert.equal(flow.sink.chunkUid, 'uid-sink'); assert.equal(flow.path.chunkUids.length, 2); assert.equal(flow.path.callSiteIdsByStep.length, 1); + assert.equal(flow.path.watchByStep.length, 1); + assert.equal(flow.path.watchByStep[0].calleeNormalized, 'sink'); assert.ok(Array.isArray(flow.path.callSiteIdsByStep[0])); assert.ok(flow.flowId.startsWith('sha1:'), 'flowId should be sha1'); const expectedConfidence = Math.max(0.05, Math.min(1, Math.sqrt(0.6 * 0.8) * 0.85)); diff --git a/tests/indexing/risk/interprocedural/flows-sanitizer-policy.test.js b/tests/indexing/risk/interprocedural/flows-sanitizer-policy.test.js index c37562728..06ebbfc46 100644 --- a/tests/indexing/risk/interprocedural/flows-sanitizer-policy.test.js +++ b/tests/indexing/risk/interprocedural/flows-sanitizer-policy.test.js @@ -1,155 +1,32 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { buildRiskSummaries } from '../../../../src/index/risk-interprocedural/summaries.js'; -import { computeInterproceduralRisk } from '../../../../src/index/risk-interprocedural/engine.js'; - -const sourceChunk = { - file: 'src/source.js', - chunkUid: 'uid-source', - name: 'source', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [ - { - id: 'source.req.body', - name: 'req.body', - ruleType: 'source', - category: 'input', - severity: 'low', - confidence: 0.6, - tags: ['input'], - evidence: { line: 1, column: 1, excerpt: 'req.body' } - } - ], - sinks: [], - sanitizers: [], - flows: [] - } - }, - codeRelations: { - callDetails: [ - { - callee: 'sanitize', - calleeRaw: 'sanitize', - calleeNormalized: 'sanitize', - startLine: 3, - startCol: 1, - endLine: 3, - endCol: 10, - args: ['req.body'], - targetChunkUid: 'uid-sanitize' - } - ] - } -}; - -const sanitizerChunk = { - file: 'src/sanitize.js', - chunkUid: 'uid-sanitize', - name: 'sanitize', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [], - sinks: [], - sanitizers: [ - { - id: 'sanitize.escape', - name: 'escape', - ruleType: 'sanitizer', - category: 'sanitize', - severity: null, - confidence: 0.4, - tags: ['sanitize'], - evidence: { line: 2, column: 1, excerpt: 'escape' } - } - ], - flows: [] - } - }, - codeRelations: { - callDetails: [ - { - callee: 'sink', - calleeRaw: 'sink', - calleeNormalized: 'sink', - startLine: 5, - startCol: 1, - endLine: 5, - endCol: 8, - args: ['value'], - targetChunkUid: 'uid-sink' - } - ] - } -}; - -const sinkChunk = { - file: 'src/sink.js', - chunkUid: 'uid-sink', - name: 'sink', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [], - sinks: [ - { - id: 'sink.eval', - name: 'eval', - ruleType: 'sink', - category: 'code-exec', - severity: 'high', - confidence: 0.8, - tags: ['exec'], - evidence: { line: 2, column: 1, excerpt: 'eval' } - } - ], - sanitizers: [], - flows: [] - } - } -}; - -const baseRuntime = { - riskInterproceduralConfig: { - enabled: true, - summaryOnly: false, - strictness: 'conservative', - emitArtifacts: 'jsonl', - caps: { - maxDepth: 4, - maxPathsPerPair: 3, - maxTotalFlows: 100, - maxCallSitesPerEdge: 2, - maxEdgeExpansions: 100, - maxMs: null - } - }, - riskInterproceduralEnabled: true, - riskConfig: { rules: { sources: [] } } -}; - -const { rows } = buildRiskSummaries({ - chunks: [sourceChunk, sanitizerChunk, sinkChunk], - runtime: baseRuntime, - mode: 'code' -}); - -const terminateResult = computeInterproceduralRisk({ - chunks: [sourceChunk, sanitizerChunk, sinkChunk], - summaries: rows, - runtime: { ...baseRuntime, riskInterproceduralConfig: { ...baseRuntime.riskInterproceduralConfig, sanitizerPolicy: 'terminate' } } +import { + computeRiskScenario, + createRiskRuntime, + createRiskSanitizerChunk, + createRiskSinkChunk, + createRiskSourceChunk +} from './helpers/risk-flow-fixtures.js'; + +const chunks = [ + createRiskSourceChunk({ + callLine: 3, + callee: 'sanitize', + targetChunkUid: 'uid-sanitize' + }), + createRiskSanitizerChunk(), + createRiskSinkChunk() +]; + +const terminateResult = computeRiskScenario({ + chunks, + runtime: createRiskRuntime({ sanitizerPolicy: 'terminate' }) }); assert.equal(terminateResult.flowRows.length, 0, 'terminate should stop propagation past sanitizer'); -const weakenResult = computeInterproceduralRisk({ - chunks: [sourceChunk, sanitizerChunk, sinkChunk], - summaries: rows, - runtime: { ...baseRuntime, riskInterproceduralConfig: { ...baseRuntime.riskInterproceduralConfig, sanitizerPolicy: 'weaken' } } +const weakenResult = computeRiskScenario({ + chunks, + runtime: createRiskRuntime({ sanitizerPolicy: 'weaken' }) }); assert.equal(weakenResult.flowRows.length, 1, 'weaken should allow propagation'); assert.equal(weakenResult.flowRows[0].notes.sanitizerBarriersHit, 1, 'should record sanitizer barrier'); diff --git a/tests/indexing/risk/interprocedural/framework-semantics.test.js b/tests/indexing/risk/interprocedural/framework-semantics.test.js new file mode 100644 index 000000000..f8489d23a --- /dev/null +++ b/tests/indexing/risk/interprocedural/framework-semantics.test.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { buildRiskSummaries } from '../../../../src/index/risk-interprocedural/summaries.js'; +import { computeInterproceduralRisk } from '../../../../src/index/risk-interprocedural/engine.js'; + +applyTestEnv(); + +const createChunks = (frameworkId = 'express') => ([ + { + file: 'src/source.js', + chunkUid: 'uid-source', + name: 'source', + kind: 'Function', + startLine: 1, + lang: 'javascript', + docmeta: { + frameworkProfile: { id: frameworkId }, + risk: { + sources: [{ + id: 'source.req.body', + name: 'req.body', + ruleType: 'source', + category: 'input', + severity: 'low', + confidence: 0.6, + tags: ['input'], + evidence: { line: 1, column: 1, excerpt: 'req.body' } + }], + sinks: [], + sanitizers: [], + flows: [], + taintHints: { + taintedIdentifiers: ['req.body'] + } + } + }, + codeRelations: { + callDetails: [{ + callee: 'registerHandler', + calleeRaw: 'registerHandler', + calleeNormalized: 'registerHandler', + startLine: 3, + startCol: 1, + endLine: 3, + endCol: 18, + args: ['handler', 'req.body'], + targetChunkUid: 'uid-wrapper' + }] + } + }, + { + file: 'src/wrapper.js', + chunkUid: 'uid-wrapper', + name: 'registerHandler', + kind: 'Function', + startLine: 1, + lang: 'javascript', + docmeta: { + frameworkProfile: { id: frameworkId }, + risk: { + sources: [], + sinks: [], + sanitizers: [], + flows: [] + } + }, + codeRelations: { + callDetails: [{ + callee: 'query', + calleeRaw: 'query', + calleeNormalized: 'query', + startLine: 4, + startCol: 1, + endLine: 4, + endCol: 8, + args: ['payload'], + targetChunkUid: 'uid-sink' + }] + } + }, + { + file: 'src/sink.js', + chunkUid: 'uid-sink', + name: 'query', + kind: 'Function', + startLine: 1, + lang: 'javascript', + docmeta: { + frameworkProfile: { id: frameworkId }, + risk: { + sources: [], + sinks: [{ + id: 'sink.sql.query', + name: 'sql.query', + ruleType: 'sink', + category: 'sql', + severity: 'high', + confidence: 0.8, + tags: ['sql'], + evidence: { line: 1, column: 1, excerpt: 'query' } + }], + sanitizers: [], + flows: [] + } + } + } +]); + +const createRuntime = (semantics = []) => ({ + riskInterproceduralConfig: { + enabled: true, + summaryOnly: false, + strictness: 'argAware', + sanitizerPolicy: 'terminate', + emitArtifacts: 'jsonl', + caps: { + maxDepth: 4, + maxPathsPerPair: 3, + maxTotalFlows: 100, + maxPartialFlows: 10, + maxCallSitesPerEdge: 3, + maxBlockedExpansionsPerPartial: 4, + maxEdgeExpansions: 100, + maxMs: null + }, + semantics + }, + riskInterproceduralEnabled: true, + riskConfig: { + rules: { + sources: [] + } + } +}); + +const runScenario = ({ semantics = [], frameworkId = 'express' } = {}) => { + const chunks = createChunks(frameworkId); + const runtime = createRuntime(semantics); + const { rows } = buildRiskSummaries({ + chunks, + runtime, + mode: 'code' + }); + return computeInterproceduralRisk({ + chunks, + summaries: rows, + runtime + }); +}; + +const noSemantics = runScenario(); +assert.equal(noSemantics.flowRows.length, 0, 'expected no interprocedural flow without framework semantics'); +assert.equal(noSemantics.partialFlowRows.length, 1, 'expected a retained partial flow without semantics'); + +const semantics = [{ + id: 'sem.callback.register-handler-payload', + kind: 'callback', + name: 'register handler payload handoff', + frameworks: ['express'], + languages: ['javascript'], + patterns: ['\\bregisterHandler\\b'], + fromArgs: [1], + taintHints: ['payload'] +}]; +const withSemantics = runScenario({ semantics }); +assert.equal(withSemantics.flowRows.length, 1, 'expected framework semantics to recover the interprocedural flow'); +assert.equal( + withSemantics.partialFlowRows.length, + 1, + 'expected the engine to retain the terminal no-callees frontier even when semantics recover a full flow' +); +assert.deepEqual( + withSemantics.flowRows[0]?.path?.watchByStep?.[0]?.semanticIds, + ['sem.callback.register-handler-payload'], + 'expected applied semantics id on watch window' +); +assert.deepEqual( + withSemantics.flowRows[0]?.path?.watchByStep?.[0]?.semanticKinds, + ['callback'], + 'expected applied semantics kind on watch window' +); +assert.deepEqual( + withSemantics.flowRows[0]?.path?.watchByStep?.[0]?.taintOut, + ['payload'], + 'expected semantics taint hint to drive the next step' +); +assert.deepEqual( + withSemantics.partialFlowRows[0]?.path?.watchByStep?.[0]?.semanticIds, + ['sem.callback.register-handler-payload'], + 'expected retained partial frontier to preserve semantics metadata' +); +assert.equal( + withSemantics.partialFlowRows[0]?.notes?.terminalReason, + 'noCallees', + 'expected the retained partial frontier to reflect terminal no-callees behavior' +); + +const wrongFramework = runScenario({ + semantics: [{ ...semantics[0], frameworks: ['next'] }] +}); +assert.equal(wrongFramework.flowRows.length, 0, 'expected framework-scoped semantics not to apply outside their framework'); + +console.log('risk interprocedural framework semantics test passed'); diff --git a/tests/indexing/risk/interprocedural/helpers/flow-cap-matrix.js b/tests/indexing/risk/interprocedural/helpers/flow-cap-matrix.js index b5ea61ebb..330f94ada 100644 --- a/tests/indexing/risk/interprocedural/helpers/flow-cap-matrix.js +++ b/tests/indexing/risk/interprocedural/helpers/flow-cap-matrix.js @@ -1,123 +1,20 @@ -import { buildRiskSummaries } from '../../../../../src/index/risk-interprocedural/summaries.js'; -import { computeInterproceduralRisk } from '../../../../../src/index/risk-interprocedural/engine.js'; +import { + computeRiskScenario, + createRiskRuntime, + createRiskSinkChunk, + createRiskSourceChunk +} from './risk-flow-fixtures.js'; -const sourceChunk = { - file: 'src/source.js', - chunkUid: 'uid-source', - name: 'source', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [ - { - id: 'source.req.body', - name: 'req.body', - ruleType: 'source', - category: 'input', - severity: 'low', - confidence: 0.6, - tags: ['input'], - evidence: { line: 1, column: 1, excerpt: 'req.body' } - } - ], - sinks: [], - sanitizers: [], - flows: [] - } - }, - codeRelations: { - callDetails: [ - { - callee: 'sink', - calleeRaw: 'sink', - calleeNormalized: 'sink', - startLine: 5, - startCol: 1, - endLine: 5, - endCol: 10, - args: ['req.body'], - targetChunkUid: 'uid-sink' - } - ] - } -}; - -const sinkChunk = { - file: 'src/sink.js', - chunkUid: 'uid-sink', - name: 'sink', - kind: 'Function', - startLine: 1, - docmeta: { - risk: { - sources: [], - sinks: [ - { - id: 'sink.eval', - name: 'eval', - ruleType: 'sink', - category: 'code-exec', - severity: 'high', - confidence: 0.8, - tags: ['exec'], - evidence: { line: 2, column: 1, excerpt: 'eval' } - } - ], - sanitizers: [], - flows: [] - } - } -}; - -const createRuntime = (caps) => ({ - riskInterproceduralConfig: { - enabled: true, - summaryOnly: false, - strictness: 'conservative', - sanitizerPolicy: 'terminate', - emitArtifacts: 'jsonl', - caps: { - maxDepth: 4, - maxPathsPerPair: 3, - maxTotalFlows: 100, - maxCallSitesPerEdge: 2, - maxEdgeExpansions: 100, - maxMs: null, - ...(caps || {}) - } - }, - riskInterproceduralEnabled: true, - riskConfig: { rules: { sources: [] } } -}); - -const CHUNKS = [sourceChunk, sinkChunk]; +const CHUNKS = [createRiskSourceChunk(), createRiskSinkChunk()]; export const runFlowCapScenario = ({ caps = null, nowStepMs = null } = {}) => { - const runtime = createRuntime(caps); - const { rows } = buildRiskSummaries({ + const runtime = createRiskRuntime({ caps }); + return computeRiskScenario({ chunks: CHUNKS, runtime, - mode: 'code' + nowStepMs }); - const originalNow = Date.now; - if (Number.isFinite(nowStepMs)) { - let tick = 0; - Date.now = () => { - tick += nowStepMs; - return tick; - }; - } - try { - return computeInterproceduralRisk({ - chunks: CHUNKS, - summaries: rows, - runtime - }); - } finally { - Date.now = originalNow; - } }; diff --git a/tests/indexing/risk/interprocedural/helpers/risk-flow-fixtures.js b/tests/indexing/risk/interprocedural/helpers/risk-flow-fixtures.js new file mode 100644 index 000000000..ba2158942 --- /dev/null +++ b/tests/indexing/risk/interprocedural/helpers/risk-flow-fixtures.js @@ -0,0 +1,202 @@ +import { computeInterproceduralRisk } from '../../../../../src/index/risk-interprocedural/engine.js'; +import { buildRiskSummaries } from '../../../../../src/index/risk-interprocedural/summaries.js'; + +const DEFAULT_CAPS = Object.freeze({ + maxDepth: 4, + maxPathsPerPair: 3, + maxTotalFlows: 100, + maxCallSitesPerEdge: 2, + maxEdgeExpansions: 100, + maxMs: null +}); + +const createRiskRecord = ({ + id, + name, + ruleType, + category, + severity, + confidence, + tags, + excerpt +}) => ({ + id, + name, + ruleType, + category, + severity, + confidence, + tags, + evidence: { line: 1, column: 1, excerpt } +}); + +export const createRiskSourceChunk = ({ + callArgs = ['req.body'], + callLine = 5, + callee = 'sink', + targetChunkUid = 'uid-sink' +} = {}) => ({ + file: 'src/source.js', + chunkUid: 'uid-source', + name: 'source', + kind: 'Function', + startLine: 1, + docmeta: { + risk: { + sources: [ + createRiskRecord({ + id: 'source.req.body', + name: 'req.body', + ruleType: 'source', + category: 'input', + severity: 'low', + confidence: 0.6, + tags: ['input'], + excerpt: 'req.body' + }) + ], + sinks: [], + sanitizers: [], + flows: [] + } + }, + codeRelations: { + callDetails: [ + { + callee, + calleeRaw: callee, + calleeNormalized: callee, + startLine: callLine, + startCol: 1, + endLine: callLine, + endCol: 10, + args: callArgs, + targetChunkUid + } + ] + } +}); + +export const createRiskSanitizerChunk = () => ({ + file: 'src/sanitize.js', + chunkUid: 'uid-sanitize', + name: 'sanitize', + kind: 'Function', + startLine: 1, + docmeta: { + risk: { + sources: [], + sinks: [], + sanitizers: [ + createRiskRecord({ + id: 'sanitize.escape', + name: 'escape', + ruleType: 'sanitizer', + category: 'sanitize', + severity: null, + confidence: 0.4, + tags: ['sanitize'], + excerpt: 'escape' + }) + ], + flows: [] + } + }, + codeRelations: { + callDetails: [ + { + callee: 'sink', + calleeRaw: 'sink', + calleeNormalized: 'sink', + startLine: 5, + startCol: 1, + endLine: 5, + endCol: 8, + args: ['value'], + targetChunkUid: 'uid-sink' + } + ] + } +}); + +export const createRiskSinkChunk = () => ({ + file: 'src/sink.js', + chunkUid: 'uid-sink', + name: 'sink', + kind: 'Function', + startLine: 1, + docmeta: { + risk: { + sources: [], + sinks: [ + createRiskRecord({ + id: 'sink.eval', + name: 'eval', + ruleType: 'sink', + category: 'code-exec', + severity: 'high', + confidence: 0.8, + tags: ['exec'], + excerpt: 'eval' + }) + ], + sanitizers: [], + flows: [] + } + } +}); + +export const createRiskRuntime = ({ + caps = null, + strictness = 'conservative', + sanitizerPolicy = 'terminate', + sourceRules = [] +} = {}) => ({ + riskInterproceduralConfig: { + enabled: true, + summaryOnly: false, + strictness, + sanitizerPolicy, + emitArtifacts: 'jsonl', + caps: { + ...DEFAULT_CAPS, + ...(caps || {}) + } + }, + riskInterproceduralEnabled: true, + riskConfig: { + rules: { + sources: sourceRules + } + } +}); + +export const computeRiskScenario = ({ + chunks, + runtime, + mode = 'code', + nowStepMs = null +}) => { + const { rows } = buildRiskSummaries({ + chunks, + runtime, + mode + }); + const originalNow = Date.now; + if (Number.isFinite(nowStepMs)) { + let tick = 0; + Date.now = () => { + tick += nowStepMs; + return tick; + }; + } + try { + return computeInterproceduralRisk({ + chunks, + summaries: rows, + runtime + }); + } finally { + Date.now = originalNow; + } +}; diff --git a/tests/indexing/risk/interprocedural/partial-flows.test.js b/tests/indexing/risk/interprocedural/partial-flows.test.js new file mode 100644 index 000000000..0eefeedb4 --- /dev/null +++ b/tests/indexing/risk/interprocedural/partial-flows.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runFlowCapScenario } from './helpers/flow-cap-matrix.js'; + +applyTestEnv(); + +const maxDepthResult = runFlowCapScenario({ + caps: { maxDepth: 1, maxPartialFlows: 4 } +}); +assert.equal(maxDepthResult.status, 'ok'); +assert.equal(maxDepthResult.flowRows.length, 1, 'expected full flow to still be emitted before depth cap'); +assert.equal(maxDepthResult.partialFlowRows.length, 1, 'expected one partial flow from the depth cap'); +assert.equal(maxDepthResult.partialFlowRows[0]?.frontier?.terminalReason, 'maxDepth'); +assert.deepEqual(maxDepthResult.partialFlowRows[0]?.path?.chunkUids, ['uid-source', 'uid-sink']); +assert.deepEqual(maxDepthResult.partialFlowRows[0]?.path?.watchByStep?.[0]?.boundParams, []); +assert.equal(maxDepthResult.partialFlowRows[0]?.path?.watchByStep?.[0]?.calleeNormalized, 'sink'); +assert.equal(maxDepthResult.partialFlowRows[0]?.notes?.terminalReason, 'maxDepth'); +assert.ok( + Array.isArray(maxDepthResult.partialFlowRows[0]?.notes?.capsHit) + && maxDepthResult.partialFlowRows[0].notes.capsHit.includes('maxDepth'), + 'expected partial flow to record the maxDepth cap' +); +assert.equal(maxDepthResult.stats?.counts?.partialFlowsEmitted, 1); + +const blockedExpansionResult = runFlowCapScenario({ + caps: { maxEdgeExpansions: 1, maxPartialFlows: 4 }, + nowStepMs: 0 +}); +assert.equal(blockedExpansionResult.status, 'ok'); +assert.equal(blockedExpansionResult.flowRows.length, 1, 'expected the direct flow to be emitted'); +assert.equal(blockedExpansionResult.partialFlowRows.length, 1, 'expected one blocked partial flow'); +assert.equal(blockedExpansionResult.partialFlowRows[0]?.frontier?.terminalReason, 'noCallees'); +assert.equal(blockedExpansionResult.partialFlowRows[0]?.path?.watchByStep?.length, 1); +assert.deepEqual( + blockedExpansionResult.partialFlowRows[0]?.frontier?.blockedExpansions, + [], + 'expected terminal sink partial flow without blocked expansions in the helper graph' +); +assert.equal(blockedExpansionResult.stats?.counts?.partialFlowsEmitted, 1); + +const timedOutResult = runFlowCapScenario({ + caps: { maxMs: 10, maxPartialFlows: 4 }, + nowStepMs: 20 +}); +assert.equal(timedOutResult.status, 'timed_out'); +assert.equal(timedOutResult.flowRows.length, 0, 'timeout should not emit full flows'); +assert.equal(timedOutResult.partialFlowRows.length, 1, 'timeout should emit one retained partial frontier'); +assert.equal(timedOutResult.partialFlowRows[0]?.frontier?.terminalReason, 'maxMs'); +assert.equal(timedOutResult.partialFlowRows[0]?.path?.watchByStep?.length, 0, 'timeout before expansion should not synthesize watch steps'); +assert.ok( + Array.isArray(timedOutResult.partialFlowRows[0]?.notes?.capsHit) + && timedOutResult.partialFlowRows[0].notes.capsHit.includes('maxMs'), + 'expected timeout partial flow to record maxMs' +); +assert.equal(timedOutResult.stats?.counts?.flowsEmitted, 0); +assert.equal(timedOutResult.stats?.counts?.partialFlowsEmitted, 1); + +console.log('risk interprocedural partial flows test passed'); diff --git a/tests/indexing/risk/interprocedural/stats-effective-config.test.js b/tests/indexing/risk/interprocedural/stats-effective-config.test.js index b11301df7..2bc71bea7 100644 --- a/tests/indexing/risk/interprocedural/stats-effective-config.test.js +++ b/tests/indexing/risk/interprocedural/stats-effective-config.test.js @@ -10,6 +10,18 @@ const runtime = { strictness: 'conservative', emitArtifacts: 'jsonl', sanitizerPolicy: 'terminate', + semantics: [ + { + id: 'sem.callback.register-handler-payload', + kind: 'callback', + name: 'register handler payload handoff', + frameworks: ['express'], + languages: ['javascript'], + patterns: ['\\bregisterHandler\\b'], + fromArgs: [1], + taintHints: ['payload'] + } + ], caps: {} } }; @@ -26,5 +38,22 @@ assert.equal( false, 'effectiveConfig.enabled should reflect runtime gating' ); +assert.deepEqual( + result.stats?.effectiveConfig?.semantics, + [ + { + id: 'sem.callback.register-handler-payload', + kind: 'callback', + name: 'register handler payload handoff', + frameworks: ['express'], + languages: ['javascript'], + patterns: ['\\bregisterHandler\\b'], + fromArgs: [1], + toParams: [], + taintHints: ['payload'] + } + ], + 'effectiveConfig should preserve semantics registry for provenance fingerprinting' +); console.log('risk interprocedural stats effective config test passed'); diff --git a/tests/indexing/risk/interprocedural/summary-only-status.test.js b/tests/indexing/risk/interprocedural/summary-only-status.test.js index 3e5ef44b1..3596674a6 100644 --- a/tests/indexing/risk/interprocedural/summary-only-status.test.js +++ b/tests/indexing/risk/interprocedural/summary-only-status.test.js @@ -141,7 +141,15 @@ const roi = buildCrossFileInferenceRoiMetrics({ linkedCalls: 12, linkedUsages: 18, inferredReturns: 5, - riskFlows: 3 + riskFlows: 3, + toolingProvidersExecuted: 4, + toolingProvidersContributed: 2, + toolingDegradedProviders: 1, + toolingDegradedWarnings: 3, + toolingDegradedErrors: 1, + toolingRequests: 20, + toolingRequestFailures: 4, + toolingRequestTimeouts: 2 }, budgetStats, durationMs: 87 @@ -151,5 +159,14 @@ assert.equal(roi.contributionSignal, 8); assert.ok(roi.retainedLinksAfterFiltering > 0, 'expected non-zero retained link count'); assert.ok(roi.linkRetentionRate > 0, 'expected non-zero retention rate'); assert.ok(roi.contributionPerAddedLink > 0, 'expected non-zero contribution per added link'); +assert.equal(roi.tooling.providersExecuted, 4, 'expected tooling providersExecuted in roi metrics'); +assert.equal(roi.tooling.providersContributed, 2, 'expected tooling providersContributed in roi metrics'); +assert.equal(roi.tooling.degradedProviders, 1, 'expected tooling degradedProviders in roi metrics'); +assert.equal(roi.tooling.requests, 20, 'expected tooling requests in roi metrics'); +assert.equal(roi.tooling.requestFailures, 4, 'expected tooling requestFailures in roi metrics'); +assert.equal(roi.tooling.requestTimeouts, 2, 'expected tooling requestTimeouts in roi metrics'); +assert.equal(roi.tooling.requestFailureRate, 0.2, 'expected tooling request failure rate'); +assert.equal(roi.tooling.requestTimeoutRate, 0.1, 'expected tooling request timeout rate'); +assert.equal(roi.tooling.degradedProviderRate, 0.25, 'expected tooling degraded provider rate'); console.log('risk interprocedural summary-only status test passed'); diff --git a/tests/indexing/risk/risk-analysis-caps.test.js b/tests/indexing/risk/risk-analysis-caps.test.js deleted file mode 100644 index fc225a27d..000000000 --- a/tests/indexing/risk/risk-analysis-caps.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { detectRiskSignals, normalizeRiskConfig } from '../../../src/index/risk.js'; - -const buildConfig = (caps) => normalizeRiskConfig({ - enabled: true, - caps, - rules: { - includeDefaults: false, - rules: { - sources: [{ id: 'source.one', name: 'SRC', patterns: ['SRC'] }], - sinks: [{ id: 'sink.one', name: 'SINK', patterns: ['SINK'] }], - sanitizers: [] - } - } -}); - -const cappedConfig = buildConfig({ maxBytes: 8, maxLines: 10 }); -const cappedText = 'SRC and SINK in a long line.'; -const capped = detectRiskSignals({ text: cappedText, config: cappedConfig, languageId: 'javascript' }); -assert.ok(capped, 'expected capped risk result'); -assert.equal(capped.analysisStatus?.status, 'capped'); -assert.ok(capped.analysisStatus?.reason?.includes('maxBytes')); - -const okConfig = buildConfig({ maxBytes: 1024, maxLines: 10, maxMs: 1000 }); -const okText = 'SRC value\nconst x = 1;\nSINK(value)'; -const runA = detectRiskSignals({ text: okText, config: okConfig, languageId: 'javascript' }); -const runB = detectRiskSignals({ text: okText, config: okConfig, languageId: 'javascript' }); -assert.ok(runA, 'expected risk signals in non-capped run'); -assert.equal(JSON.stringify(runA), JSON.stringify(runB), 'expected deterministic risk output'); - -const longLineConfig = buildConfig({ maxBytes: 200000, maxLines: 5, maxMs: 1000 }); -const longLineText = `SRC ${'x'.repeat(10000)} SINK`; -const longLine = detectRiskSignals({ text: longLineText, config: longLineConfig, languageId: 'javascript' }); -assert.ok(longLine === null || typeof longLine === 'object', 'expected long-line run to complete'); - -console.log('risk analysis caps test passed'); diff --git a/tests/indexing/risk/risk-analysis-taint.test.js b/tests/indexing/risk/risk-analysis-taint.test.js deleted file mode 100644 index f22684b41..000000000 --- a/tests/indexing/risk/risk-analysis-taint.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { detectRiskSignals, normalizeRiskConfig } from '../../../src/index/risk.js'; - -const config = normalizeRiskConfig({ - enabled: true, - rules: { - includeDefaults: false, - rules: { - sources: [{ id: 'source.one', name: 'SRC', patterns: ['SRC'], confidence: 0.9 }], - sinks: [{ id: 'sink.one', name: 'SINK', patterns: ['SINK'], confidence: 0.6 }], - sanitizers: [{ id: 'san.one', name: 'SAN', patterns: ['sanitize'] }] - } - } -}); - -const sanitizerText = [ - 'const user = SRC;', - 'const admin = SRC;', - 'sanitize(user); SINK(admin);' -].join('\n'); -const sanitizerRisk = detectRiskSignals({ text: sanitizerText, config, languageId: 'javascript' }); -assert.ok(sanitizerRisk?.flows?.length, 'expected flow to remain after unrelated sanitizer call'); - -const destructuringText = [ - 'const { token } = SRC;', - 'SINK(token);' -].join('\n'); -const destructuringRisk = detectRiskSignals({ text: destructuringText, config, languageId: 'javascript' }); -assert.ok(destructuringRisk?.flows?.length, 'expected destructured assignment to propagate taint'); - -console.log('risk analysis taint test passed'); diff --git a/tests/indexing/risk/risk-contract-matrix.test.js b/tests/indexing/risk/risk-contract-matrix.test.js new file mode 100644 index 000000000..b14a9916e --- /dev/null +++ b/tests/indexing/risk/risk-contract-matrix.test.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { detectRiskSignals, normalizeRiskConfig } from '../../../src/index/risk.js'; +import { + containsIdentifier, + matchRulePatterns, + SEVERITY_RANK +} from '../../../src/index/risk/shared.js'; + +applyTestEnv(); + +const buildConfig = (caps) => normalizeRiskConfig({ + enabled: true, + caps, + rules: { + includeDefaults: false, + rules: { + sources: [{ id: 'source.one', name: 'SRC', patterns: ['SRC'], confidence: 0.9 }], + sinks: [{ id: 'sink.one', name: 'SINK', patterns: ['SINK'], confidence: 0.6 }], + sanitizers: [{ id: 'san.one', name: 'SAN', patterns: ['sanitize'] }] + } + } +}); + +{ + assert.equal(SEVERITY_RANK.low, 1); + assert.equal(SEVERITY_RANK.critical, 4); + + assert.equal(containsIdentifier('foo bar', 'foo'), true); + assert.equal(containsIdentifier('foobar', 'foo'), false); + assert.equal(containsIdentifier('foo_bar', 'foo'), false); + assert.equal(containsIdentifier('foo + bar', 'bar'), true); + assert.equal(containsIdentifier('x foo y', 'foo', { start: 2, end: 5 }), true); + assert.equal(containsIdentifier('x foo y', 'foo', { start: 0, end: 2 }), false); + + const pattern = /danger\(/i; + pattern.prefilter = 'danger'; + pattern.prefilterLower = 'danger'; + + const rule = { patterns: [pattern] }; + const lineLowerRef = { value: null }; + const match = matchRulePatterns('if (danger(input)) {}', rule, { + returnMatch: true, + lineLowerRef + }); + assert.equal(typeof lineLowerRef.value, 'string'); + assert.equal(match.index, 4); + assert.equal(match.match, 'danger('); + assert.equal(matchRulePatterns('safe(input)', rule, { returnMatch: true, lineLowerRef: { value: null } }), null); + assert.equal(matchRulePatterns('safe(input)', rule), false); + assert.equal(matchRulePatterns('danger(input)', rule), true); +} + +{ + const cappedConfig = buildConfig({ maxBytes: 8, maxLines: 10 }); + const cappedText = 'SRC and SINK in a long line.'; + const capped = detectRiskSignals({ text: cappedText, config: cappedConfig, languageId: 'javascript' }); + assert.ok(capped, 'expected capped risk result'); + assert.equal(capped.analysisStatus?.status, 'capped'); + assert.ok(capped.analysisStatus?.reason?.includes('maxBytes')); + + const okConfig = buildConfig({ maxBytes: 1024, maxLines: 10, maxMs: 1000 }); + const okText = 'SRC value\nconst x = 1;\nSINK(value)'; + const runA = detectRiskSignals({ text: okText, config: okConfig, languageId: 'javascript' }); + const runB = detectRiskSignals({ text: okText, config: okConfig, languageId: 'javascript' }); + assert.ok(runA, 'expected risk signals in non-capped run'); + assert.equal(JSON.stringify(runA), JSON.stringify(runB), 'expected deterministic risk output'); + + const longLineConfig = buildConfig({ maxBytes: 200000, maxLines: 5, maxMs: 1000 }); + const longLineText = `SRC ${'x'.repeat(10000)} SINK`; + const longLine = detectRiskSignals({ + text: longLineText, + config: longLineConfig, + languageId: 'javascript' + }); + assert.ok(longLine === null || typeof longLine === 'object', 'expected long-line run to complete'); +} + +{ + const taintConfig = buildConfig({ maxBytes: 1024, maxLines: 50, maxMs: 1000 }); + + const sanitizerText = [ + 'const user = SRC;', + 'const admin = SRC;', + 'sanitize(user); SINK(admin);' + ].join('\n'); + const sanitizerRisk = detectRiskSignals({ + text: sanitizerText, + config: taintConfig, + languageId: 'javascript' + }); + assert.ok(sanitizerRisk?.flows?.length, 'expected flow to remain after unrelated sanitizer call'); + + const destructuringText = [ + 'const { token } = SRC;', + 'SINK(token);' + ].join('\n'); + const destructuringRisk = detectRiskSignals({ + text: destructuringText, + config: taintConfig, + languageId: 'javascript' + }); + assert.ok(destructuringRisk?.flows?.length, 'expected destructured assignment to propagate taint'); +} + +console.log('risk contract matrix test passed'); diff --git a/tests/indexing/risk/risk-shared-utils-parity.test.js b/tests/indexing/risk/risk-shared-utils-parity.test.js deleted file mode 100644 index 82eb2f98d..000000000 --- a/tests/indexing/risk/risk-shared-utils-parity.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { - containsIdentifier, - matchRulePatterns, - SEVERITY_RANK -} from '../../../src/index/risk/shared.js'; - -applyTestEnv(); - -assert.equal(SEVERITY_RANK.low, 1); -assert.equal(SEVERITY_RANK.critical, 4); - -assert.equal(containsIdentifier('foo bar', 'foo'), true); -assert.equal(containsIdentifier('foobar', 'foo'), false); -assert.equal(containsIdentifier('foo_bar', 'foo'), false); -assert.equal(containsIdentifier('foo + bar', 'bar'), true); -assert.equal(containsIdentifier('x foo y', 'foo', { start: 2, end: 5 }), true); -assert.equal(containsIdentifier('x foo y', 'foo', { start: 0, end: 2 }), false); - -const pattern = /danger\(/i; -pattern.prefilter = 'danger'; -pattern.prefilterLower = 'danger'; - -const rule = { patterns: [pattern] }; -const lineLowerRef = { value: null }; -const match = matchRulePatterns('if (danger(input)) {}', rule, { - returnMatch: true, - lineLowerRef -}); -assert.equal(typeof lineLowerRef.value, 'string'); -assert.equal(match.index, 4); -assert.equal(match.match, 'danger('); - -const noMatch = matchRulePatterns('safe(input)', rule, { returnMatch: true, lineLowerRef: { value: null } }); -assert.equal(noMatch, null); -assert.equal(matchRulePatterns('safe(input)', rule), false); -assert.equal(matchRulePatterns('danger(input)', rule), true); - -console.log('risk shared utils parity test passed'); diff --git a/tests/indexing/risk/rules/risk-rules-config.test.js b/tests/indexing/risk/rules/risk-config.test.js similarity index 100% rename from tests/indexing/risk/rules/risk-rules-config.test.js rename to tests/indexing/risk/rules/risk-config.test.js diff --git a/tests/indexing/runtime/artifact-write-runtime-helper-extraction.test.js b/tests/indexing/runtime/artifact-write-runtime-helper-extraction.test.js new file mode 100644 index 000000000..7eedaf1f9 --- /dev/null +++ b/tests/indexing/runtime/artifact-write-runtime-helper-extraction.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const artifactsWritePath = path.join(root, 'src', 'index', 'build', 'artifacts-write.js'); +const artifactsWriteIndexPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'index.js'); +const planningPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'planning.js'); +const publicationPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'publication.js'); +const telemetryPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'telemetry.js'); +const runtimePath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'runtime.js'); +const familyDispatchPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'family-dispatch.js'); +const runtimeHelpersPath = path.join(root, 'src', 'index', 'build', 'artifacts', 'write-runtime-helpers.js'); + +for (const target of [ + artifactsWritePath, + artifactsWriteIndexPath, + planningPath, + publicationPath, + telemetryPath, + runtimePath, + familyDispatchPath, + runtimeHelpersPath +]) { + assert.equal(fs.existsSync(target), true, `missing expected artifact write helper module: ${target}`); +} + +const barrelSource = fs.readFileSync(artifactsWritePath, 'utf8'); +const indexSource = fs.readFileSync(artifactsWriteIndexPath, 'utf8'); + +assert.equal( + barrelSource.includes("./artifacts-write/index.js"), + true, + 'expected top-level artifacts-write module to delegate to the modularized index' +); + +for (const marker of [ + "../artifacts/write-runtime-helpers.js", + "./planning.js", + "./publication.js", + "./telemetry.js", + "./runtime.js", + "./family-dispatch.js", + 'buildBoilerplateCatalog(', + 'readStableIndexStateHash(', + 'writeBinaryArtifactAtomically(', + 'normalizeArtifactWriteInput(', + 'resolveArtifactWriteRuntime(', + 'createArtifactWriteExecutionState(', + 'prepareArtifactCleanup(' +]) { + assert.equal( + indexSource.includes(marker), + true, + `expected artifacts-write index to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const readStableIndexStateHash = async (', + 'const buildBoilerplateCatalog = (', + 'const writeBinaryArtifactAtomically = async (' +]) { + assert.equal( + indexSource.includes(legacyInlineMarker), + false, + `expected artifacts-write index to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('artifact write runtime helper extraction test passed'); diff --git a/tests/indexing/runtime/artifacts-write-modularization.test.js b/tests/indexing/runtime/artifacts-write-modularization.test.js new file mode 100644 index 000000000..a47cfc00c --- /dev/null +++ b/tests/indexing/runtime/artifacts-write-modularization.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const barrelPath = path.join(root, 'src', 'index', 'build', 'artifacts-write.js'); +const indexPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'index.js'); +const planningPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'planning.js'); +const publicationPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'publication.js'); +const telemetryPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'telemetry.js'); +const runtimePath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'runtime.js'); +const familyDispatchPath = path.join(root, 'src', 'index', 'build', 'artifacts-write', 'family-dispatch.js'); + +for (const target of [barrelPath, indexPath, planningPath, publicationPath, telemetryPath, runtimePath, familyDispatchPath]) { + assert.equal(fs.existsSync(target), true, `missing expected artifacts-write modularization file: ${target}`); +} + +const barrelSource = fs.readFileSync(barrelPath, 'utf8'); +const indexSource = fs.readFileSync(indexPath, 'utf8'); + +assert.equal( + barrelSource.includes("./artifacts-write/index.js"), + true, + 'expected artifacts-write top-level module to delegate to the modularized index' +); + +for (const marker of [ + './planning.js', + './publication.js', + './telemetry.js', + './runtime.js', + './family-dispatch.js', + 'createArtifactOrderingRecorder(', + 'createArtifactWriteTelemetryContext(', + 'normalizeArtifactWriteInput(', + 'resolveArtifactWriteRuntime(', + 'createArtifactWriteExecutionState(', + 'prepareArtifactCleanup(', + 'resolveQueuedWriteLanes(', + 'dispatchPlannedArtifactWrites(', + 'runArtifactPublicationFinalizers(' +]) { + assert.equal( + indexSource.includes(marker), + true, + `expected artifacts-write index to compose ${marker}` + ); +} + +console.log('artifacts-write modularization test passed'); diff --git a/tests/indexing/runtime/runtime-build-root-resolution.test.js b/tests/indexing/runtime/build-root-resolution.test.js similarity index 100% rename from tests/indexing/runtime/runtime-build-root-resolution.test.js rename to tests/indexing/runtime/build-root-resolution.test.js diff --git a/tests/indexing/runtime/byte-budget-default-policy.test.js b/tests/indexing/runtime/byte-budget-default-policy.test.js deleted file mode 100644 index ad8e7ce98..000000000 --- a/tests/indexing/runtime/byte-budget-default-policy.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - DEFAULT_BYTE_BUDGETS, - resolveByteBudgetMap -} from '../../../src/index/build/byte-budget.js'; - -const resolved = resolveByteBudgetMap({ - indexingConfig: {}, - maxJsonBytes: 1024 -}); - -for (const [artifact, policy] of Object.entries(DEFAULT_BYTE_BUDGETS)) { - const resolvedPolicy = resolved.policies[artifact]; - assert.ok(resolvedPolicy, `missing resolved policy for ${artifact}`); - assert.equal(resolvedPolicy.maxBytes, 1024, `${artifact} should default maxBytes to maxJsonBytes`); - assert.equal(resolvedPolicy.overflow, policy.overflow, `${artifact} overflow should match runtime default`); - assert.equal(resolvedPolicy.strict, false, `${artifact} strict should default to false`); -} - -const specPath = path.join(process.cwd(), 'docs', 'specs', 'byte-budget-policy.md'); -const specText = await fs.readFile(specPath, 'utf8'); -const docBudgetLines = specText - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => /^- [a-z_]+: maxJsonBytes, overflow=[a-z]+$/i.test(line)); - -const docMap = new Map(); -for (const line of docBudgetLines) { - const match = line.match(/^- ([a-z_]+): maxJsonBytes, overflow=([a-z]+)$/i); - if (!match) continue; - docMap.set(match[1], match[2].toLowerCase()); -} - -for (const [artifact, policy] of Object.entries(DEFAULT_BYTE_BUDGETS)) { - assert.equal( - docMap.get(artifact), - policy.overflow, - `docs budget table overflow mismatch for ${artifact}` - ); -} - -console.log('byte budget default policy test passed'); diff --git a/tests/indexing/runtime/byte-budget-enforcement.test.js b/tests/indexing/runtime/byte-budget-enforcement.test.js deleted file mode 100644 index 854e24fb7..000000000 --- a/tests/indexing/runtime/byte-budget-enforcement.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict'; -import { applyByteBudget, resolveByteBudgetMap } from '../../../src/index/build/byte-budget.js'; - -const indexingConfig = { - artifacts: { - byteBudgetPolicy: { - artifacts: { - chunk_meta: { maxBytes: 100, overflow: 'fail', strict: true }, - symbol_edges: { maxBytes: 200, overflow: 'warn' } - } - } - } -}; - -const { policies } = resolveByteBudgetMap({ indexingConfig, maxJsonBytes: 1000 }); -const chunkMetaBudget = policies.chunk_meta; -const symbolEdgesBudget = policies.symbol_edges; - -let threw = false; -try { - applyByteBudget({ - budget: chunkMetaBudget, - totalBytes: 250, - label: 'chunk_meta', - logger: () => {} - }); -} catch (err) { - threw = err?.code === 'ERR_BYTE_BUDGET'; -} -assert.ok(threw, 'expected strict byte budget to throw'); - -let warned = false; -applyByteBudget({ - budget: symbolEdgesBudget, - totalBytes: 250, - label: 'symbol_edges', - logger: () => { warned = true; } -}); -assert.ok(warned, 'expected warning budget to log'); - -console.log('byte budget enforcement test passed'); diff --git a/tests/indexing/runtime/byte-budget-policy-contract.test.js b/tests/indexing/runtime/byte-budget-policy-contract.test.js deleted file mode 100644 index fa3d4cca0..000000000 --- a/tests/indexing/runtime/byte-budget-policy-contract.test.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - applyByteBudget, - resolveByteBudget, - resolveByteBudgetMap -} from '../../../src/index/build/byte-budget.js'; - -const resolved = resolveByteBudgetMap({ - indexingConfig: {}, - maxJsonBytes: 1024 -}); -assert.equal(resolved.strict, false); -assert.equal(resolved.policies.chunk_meta.maxBytes, 1024); -assert.equal(resolved.policies.vfs_manifest.overflow, 'fail'); -assert.equal(resolved.policies.graph_relations.overflow, 'drop'); - -const override = resolveByteBudget({ - artifact: 'token_postings', - maxJsonBytes: 2048, - overrides: { - token_postings: { maxBytes: 512, overflow: 'warn', strict: true } - }, - strict: false -}); -assert.equal(override.maxBytes, 512); -assert.equal(override.overflow, 'warn'); -assert.equal(override.strict, true); - -const warnings = []; -const info = applyByteBudget({ - budget: { artifact: 'repo_map', maxBytes: 100, overflow: 'warn', strict: false }, - totalBytes: 140, - label: 'repo_map', - logger: (line) => warnings.push(line) -}); -assert.equal(info.overBytes, 40); -assert.equal(warnings.length, 1, 'expected warn overflow to log'); - -let threw = null; -try { - applyByteBudget({ - budget: { artifact: 'vfs_manifest', maxBytes: 100, overflow: 'fail', strict: false }, - totalBytes: 140, - label: 'vfs_manifest' - }); -} catch (err) { - threw = err; -} -assert.ok(threw, 'expected fail overflow to throw'); -assert.equal(threw.code, 'ERR_BYTE_BUDGET'); - -console.log('byte budget policy contract test passed'); diff --git a/tests/indexing/runtime/caps-policy-merge.test.js b/tests/indexing/runtime/caps-policy-merge.test.js deleted file mode 100644 index e2f4b8259..000000000 --- a/tests/indexing/runtime/caps-policy-merge.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveFileCapsAndGuardrails } from '../../../src/index/build/runtime/caps.js'; -import { LANGUAGE_CAPS_BASELINES } from '../../../src/index/build/runtime/caps-calibration.js'; - -const MB = 1024 * 1024; - -const { maxFileBytes, fileCaps, guardrails } = resolveFileCapsAndGuardrails({ - maxFileBytes: 8 * MB, - fileCaps: { - default: { maxBytes: 6 * MB, maxLines: 5000 }, - byExt: { '.js': { maxBytes: 2 * MB } }, - byLanguage: { javascript: { maxLines: 1000 } }, - byMode: { prose: { maxBytes: 4 * MB } } - }, - untrusted: { - enabled: true, - maxFileBytes: 1 * MB, - maxLines: 200 - } -}); - -assert.equal(guardrails.enabled, true, 'guardrails should be enabled'); -assert.equal(maxFileBytes, 1 * MB, 'maxFileBytes should clamp to untrusted'); -assert.equal(fileCaps.default.maxBytes, 1 * MB, 'default maxBytes should clamp'); -assert.equal(fileCaps.default.maxLines, 200, 'default maxLines should clamp'); -assert.equal(fileCaps.byExt['.js'].maxBytes, 1 * MB, 'ext maxBytes should clamp'); -assert.equal( - fileCaps.byLanguage.javascript.maxBytes, - LANGUAGE_CAPS_BASELINES.javascript.maxBytes, - 'language maxBytes should preserve calibrated baseline when already below guardrail' -); -assert.equal(fileCaps.byLanguage.javascript.maxLines, 200, 'language maxLines should clamp'); -assert.equal(fileCaps.byMode.prose.maxBytes, 1 * MB, 'mode maxBytes should clamp'); - -console.log('build runtime caps policy merge test passed'); diff --git a/tests/indexing/runtime/chunk-retention-sampling.test.js b/tests/indexing/runtime/chunk-retention-sampling.test.js deleted file mode 100644 index 97aa5819b..000000000 --- a/tests/indexing/runtime/chunk-retention-sampling.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import { createIndexState } from '../../../src/index/build/state.js'; -import { createTokenRetentionState } from '../../../src/index/build/indexer/steps/postings.js'; - -const runtime = { - userConfig: { - indexing: { - chunkTokenMode: 'auto', - chunkTokenMaxTokens: 5, - chunkTokenSampleSize: 2 - } - }, - postingsConfig: {}, - embeddingEnabled: false -}; - -const state = createIndexState(); -const { appendChunkWithRetention } = createTokenRetentionState({ - runtime, - totalFiles: 1, - log: () => {} -}); - -appendChunkWithRetention(state, { - file: 'alpha.js', - tokens: ['a', 'b', 'c'], - seq: ['a', 'b', 'c'], - docmeta: {}, - stats: {}, - minhashSig: [1, 2] -}, state); - -appendChunkWithRetention(state, { - file: 'beta.js', - tokens: ['d', 'e', 'f', 'g'], - seq: ['d', 'e', 'f', 'g'], - docmeta: {}, - stats: {}, - minhashSig: [3, 4] -}, state); - -const tokensA = state.chunks[0]?.tokens || []; -const tokensB = state.chunks[1]?.tokens || []; - -if (tokensA.length > 2 || tokensB.length > 2) { - console.error('chunk retention sampling test failed: tokens not sampled after threshold.'); - process.exit(1); -} - -console.log('chunk retention sampling test passed'); diff --git a/tests/indexing/runtime/content-hash.test.js b/tests/indexing/runtime/content-hash.test.js deleted file mode 100644 index 5302c4148..000000000 --- a/tests/indexing/runtime/content-hash.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import { buildContentConfigHash, normalizeContentConfig } from '../../../src/index/build/runtime/hash.js'; - -const fail = (message) => { - console.error(message); - process.exit(1); -}; - -const config = { - indexing: { - concurrency: 12, - importConcurrency: 4, - workerPool: { enabled: true }, - debugCrash: true, - shards: { enabled: true }, - fileListSampleSize: 123, - maxFileBytes: 2048 - } -}; - -const normalized = normalizeContentConfig(config); -if (!normalized.indexing || normalized.indexing.maxFileBytes !== 2048) { - fail('normalizeContentConfig should preserve relevant indexing fields.'); -} -for (const key of ['concurrency', 'importConcurrency', 'workerPool', 'debugCrash', 'shards', 'fileListSampleSize']) { - if (normalized.indexing[key] !== undefined) { - fail(`normalizeContentConfig should remove indexing.${key}.`); - } -} - -const envA = { cacheRoot: '/tmp/a', stage: 'stage1' }; -const envB = { cacheRoot: '/tmp/b', stage: 'stage1' }; -const hashA = buildContentConfigHash(config, envA); -const hashB = buildContentConfigHash(config, envB); -if (hashA !== hashB) { - fail('buildContentConfigHash should ignore cacheRoot differences.'); -} - -const configVariant = { - indexing: { - concurrency: 1, - importConcurrency: 2, - maxFileBytes: 2048 - } -}; -const hashC = buildContentConfigHash(configVariant, envA); -if (hashA !== hashC) { - fail('buildContentConfigHash should ignore concurrency-only changes.'); -} - -const envC = { cacheRoot: '/tmp/a', stage: 'stage2' }; -const hashD = buildContentConfigHash(config, envC); -if (hashA === hashD) { - fail('buildContentConfigHash should change when env fields change.'); -} - -const configDiff = { - indexing: { - maxFileBytes: 4096 - } -}; -const hashE = buildContentConfigHash(configDiff, envA); -if (hashA === hashE) { - fail('buildContentConfigHash should change when config fields change.'); -} - -console.log('build runtime content hash tests passed'); diff --git a/tests/indexing/runtime/contract-matrix.test.js b/tests/indexing/runtime/contract-matrix.test.js new file mode 100644 index 000000000..bca66dd82 --- /dev/null +++ b/tests/indexing/runtime/contract-matrix.test.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + applyByteBudget, + DEFAULT_BYTE_BUDGETS, + resolveByteBudget, + resolveByteBudgetMap +} from '../../../src/index/build/byte-budget.js'; +import { resolveFileCapsAndGuardrails } from '../../../src/index/build/runtime/caps.js'; +import { LANGUAGE_CAPS_BASELINES } from '../../../src/index/build/runtime/caps-calibration.js'; +import { buildContentConfigHash, normalizeContentConfig } from '../../../src/index/build/runtime/hash.js'; +import { buildStageOverrides, normalizeStage } from '../../../src/index/build/runtime/stage.js'; +import { createStageCheckpointRecorder } from '../../../src/index/build/stage-checkpoints.js'; +import { createIndexState } from '../../../src/index/build/state.js'; +import { createTokenRetentionState } from '../../../src/index/build/indexer/steps/postings.js'; + +{ + assert.equal(normalizeStage('stage1'), 'stage1'); + assert.equal(normalizeStage('embed'), 'stage3'); + assert.equal(normalizeStage('ann'), 'stage4'); + assert.equal(normalizeStage(''), null); + + const stage1Overrides = buildStageOverrides({ stage1: { lint: true } }, 'stage1'); + assert.equal(stage1Overrides?.lint, true); + assert.equal(stage1Overrides?.embeddings?.enabled, false); + assert.equal(stage1Overrides?.treeSitter?.enabled, false); + assert.equal(stage1Overrides?.typeInference, false); + + const stage2Overrides = buildStageOverrides({ stage2: { lint: false, embeddings: { enabled: true } } }, 'stage2'); + assert.equal(stage2Overrides?.embeddings?.enabled, true); + + const stage3Overrides = buildStageOverrides({ stage3: { lint: true } }, 'stage3'); + assert.equal(stage3Overrides?.lint, true); + assert.equal(stage3Overrides?.treeSitter?.enabled, false); + assert.equal(buildStageOverrides({}, 'unknown'), null); +} + +{ + const MB = 1024 * 1024; + const { maxFileBytes, fileCaps, guardrails } = resolveFileCapsAndGuardrails({ + maxFileBytes: 8 * MB, + fileCaps: { + default: { maxBytes: 6 * MB, maxLines: 5000 }, + byExt: { '.js': { maxBytes: 2 * MB } }, + byLanguage: { javascript: { maxLines: 1000 } }, + byMode: { prose: { maxBytes: 4 * MB } } + }, + untrusted: { + enabled: true, + maxFileBytes: 1 * MB, + maxLines: 200 + } + }); + assert.equal(guardrails.enabled, true); + assert.equal(maxFileBytes, 1 * MB); + assert.equal(fileCaps.default.maxBytes, 1 * MB); + assert.equal(fileCaps.default.maxLines, 200); + assert.equal(fileCaps.byExt['.js'].maxBytes, 1 * MB); + assert.equal(fileCaps.byLanguage.javascript.maxBytes, LANGUAGE_CAPS_BASELINES.javascript.maxBytes); + assert.equal(fileCaps.byLanguage.javascript.maxLines, 200); + assert.equal(fileCaps.byMode.prose.maxBytes, 1 * MB); +} + +{ + const config = { + indexing: { + concurrency: 12, + importConcurrency: 4, + workerPool: { enabled: true }, + debugCrash: true, + shards: { enabled: true }, + fileListSampleSize: 123, + maxFileBytes: 2048 + } + }; + const normalized = normalizeContentConfig(config); + assert.equal(normalized.indexing?.maxFileBytes, 2048); + for (const key of ['concurrency', 'importConcurrency', 'workerPool', 'debugCrash', 'shards', 'fileListSampleSize']) { + assert.equal(normalized.indexing?.[key], undefined); + } + const envA = { cacheRoot: '/tmp/a', stage: 'stage1' }; + const envB = { cacheRoot: '/tmp/b', stage: 'stage1' }; + const hashA = buildContentConfigHash(config, envA); + const hashB = buildContentConfigHash(config, envB); + assert.equal(hashA, hashB, 'expected cacheRoot differences to be ignored'); + const hashC = buildContentConfigHash({ indexing: { concurrency: 1, importConcurrency: 2, maxFileBytes: 2048 } }, envA); + assert.equal(hashA, hashC, 'expected concurrency-only changes to be ignored'); + const hashD = buildContentConfigHash(config, { cacheRoot: '/tmp/a', stage: 'stage2' }); + assert.notEqual(hashA, hashD, 'expected stage changes to affect content hash'); + const hashE = buildContentConfigHash({ indexing: { maxFileBytes: 4096 } }, envA); + assert.notEqual(hashA, hashE, 'expected relevant config changes to affect content hash'); +} + +{ + const resolved = resolveByteBudgetMap({ + indexingConfig: {}, + maxJsonBytes: 1024 + }); + for (const [artifact, policy] of Object.entries(DEFAULT_BYTE_BUDGETS)) { + const resolvedPolicy = resolved.policies[artifact]; + assert.ok(resolvedPolicy, `missing resolved policy for ${artifact}`); + assert.equal(resolvedPolicy.maxBytes, 1024); + assert.equal(resolvedPolicy.overflow, policy.overflow); + assert.equal(resolvedPolicy.strict, false); + } + + const override = resolveByteBudget({ + artifact: 'token_postings', + maxJsonBytes: 2048, + overrides: { + token_postings: { maxBytes: 512, overflow: 'warn', strict: true } + }, + strict: false + }); + assert.equal(override.maxBytes, 512); + assert.equal(override.overflow, 'warn'); + assert.equal(override.strict, true); + + const warnings = []; + const info = applyByteBudget({ + budget: { artifact: 'repo_map', maxBytes: 100, overflow: 'warn', strict: false }, + totalBytes: 140, + label: 'repo_map', + logger: (line) => warnings.push(line) + }); + assert.equal(info.overBytes, 40); + assert.equal(warnings.length, 1); + + await assert.rejects( + async () => applyByteBudget({ + budget: { artifact: 'vfs_manifest', maxBytes: 100, overflow: 'fail', strict: false }, + totalBytes: 140, + label: 'vfs_manifest' + }), + (err) => err?.code === 'ERR_BYTE_BUDGET' + ); + + const enforced = resolveByteBudgetMap({ + indexingConfig: { + artifacts: { + byteBudgetPolicy: { + artifacts: { + chunk_meta: { maxBytes: 100, overflow: 'fail', strict: true }, + symbol_edges: { maxBytes: 200, overflow: 'warn' } + } + } + } + }, + maxJsonBytes: 1000 + }).policies; + await assert.rejects( + async () => applyByteBudget({ + budget: enforced.chunk_meta, + totalBytes: 250, + label: 'chunk_meta', + logger: () => {} + }), + (err) => err?.code === 'ERR_BYTE_BUDGET' + ); + let warned = false; + applyByteBudget({ + budget: enforced.symbol_edges, + totalBytes: 250, + label: 'symbol_edges', + logger: () => { warned = true; } + }); + assert.equal(warned, true); + + const specPath = path.join(process.cwd(), 'docs', 'specs', 'byte-budget-policy.md'); + const specText = await fs.readFile(specPath, 'utf8'); + const docBudgetLines = specText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^- [a-z_]+: maxJsonBytes, overflow=[a-z]+$/i.test(line)); + const docMap = new Map(); + for (const line of docBudgetLines) { + const match = line.match(/^- ([a-z_]+): maxJsonBytes, overflow=([a-z]+)$/i); + if (!match) continue; + docMap.set(match[1], match[2].toLowerCase()); + } + for (const [artifact, policy] of Object.entries(DEFAULT_BYTE_BUDGETS)) { + assert.equal(docMap.get(artifact), policy.overflow, `docs budget table overflow mismatch for ${artifact}`); + } +} + +{ + const recorder = createStageCheckpointRecorder({ mode: 'code' }); + recorder.record({ stage: 'stage1', step: 'discovery', extra: { files: 10 } }); + recorder.record({ + stage: 'stage2', + step: 'write', + extra: { + vfsManifest: { + rows: 10, + bytes: 1024, + maxLineBytes: 256, + trimmedRows: 2, + droppedRows: 1, + runsSpilled: 0 + } + } + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + recorder.record({ stage: 'stage1', step: 'postings', extra: { chunks: 5 } }); + recorder.record({ + stage: 'stage2', + step: 'write', + extra: { + vfsManifest: { + rows: 15, + bytes: 900, + maxLineBytes: 512, + trimmedRows: 4, + droppedRows: 3, + runsSpilled: 1 + } + } + }); + + const summary = recorder.buildSummary(); + assert.equal(summary.mode, 'code'); + assert.equal(summary.checkpoints.length, 4); + const memory = summary.checkpoints[0].memory || {}; + for (const field of ['rss', 'heapUsed', 'heapTotal', 'external', 'arrayBuffers']) { + const value = memory[field]; + if (value !== null) assert.equal(Number.isFinite(value), true, `memory.${field} should be finite`); + } + const stage1Summary = summary.stages.stage1; + assert.ok(stage1Summary); + assert.equal(stage1Summary.checkpointCount, 2); + assert.ok(stage1Summary.elapsedMs >= 0); + const elapsedValues = summary.checkpoints.map((entry) => entry.elapsedMs); + assert.ok(elapsedValues[1] >= elapsedValues[0]); + const highWaterChunks = summary.highWater?.extra?.chunks; + assert.equal(highWaterChunks, 5); + const vfsHighWater = summary.stages.stage2?.extraHighWater?.vfsManifest; + assert.ok(vfsHighWater); + assert.equal(vfsHighWater.rows, 15); + assert.equal(vfsHighWater.bytes, 1024); + assert.equal(vfsHighWater.maxLineBytes, 512); + assert.equal(vfsHighWater.trimmedRows, 4); + assert.equal(vfsHighWater.droppedRows, 3); + assert.equal(vfsHighWater.runsSpilled, 1); +} + +{ + const runtime = { + userConfig: { + indexing: { + chunkTokenMode: 'auto', + chunkTokenMaxTokens: 5, + chunkTokenSampleSize: 2 + } + }, + postingsConfig: {}, + embeddingEnabled: false + }; + const state = createIndexState(); + const { appendChunkWithRetention } = createTokenRetentionState({ + runtime, + totalFiles: 1, + log: () => {} + }); + appendChunkWithRetention(state, { + file: 'alpha.js', + tokens: ['a', 'b', 'c'], + seq: ['a', 'b', 'c'], + docmeta: {}, + stats: {}, + minhashSig: [1, 2] + }, state); + appendChunkWithRetention(state, { + file: 'beta.js', + tokens: ['d', 'e', 'f', 'g'], + seq: ['d', 'e', 'f', 'g'], + docmeta: {}, + stats: {}, + minhashSig: [3, 4] + }, state); + const tokensA = state.chunks[0]?.tokens || []; + const tokensB = state.chunks[1]?.tokens || []; + assert.ok(tokensA.length <= 2 && tokensB.length <= 2, 'expected tokens to be sampled after threshold'); +} + +console.log('indexing runtime contract matrix test passed'); diff --git a/tests/indexing/runtime/runtime-dictionaries-code-language-selection.test.js b/tests/indexing/runtime/dictionaries-code-language-selection.test.js similarity index 100% rename from tests/indexing/runtime/runtime-dictionaries-code-language-selection.test.js rename to tests/indexing/runtime/dictionaries-code-language-selection.test.js diff --git a/tests/indexing/runtime/runtime-embedding-warm-key-model-path.test.js b/tests/indexing/runtime/embedding-warm-key-model-path.test.js similarity index 100% rename from tests/indexing/runtime/runtime-embedding-warm-key-model-path.test.js rename to tests/indexing/runtime/embedding-warm-key-model-path.test.js diff --git a/tests/indexing/runtime/pipeline-stage-orchestrator-extraction.test.js b/tests/indexing/runtime/pipeline-stage-orchestrator-extraction.test.js new file mode 100644 index 000000000..b536ddcf1 --- /dev/null +++ b/tests/indexing/runtime/pipeline-stage-orchestrator-extraction.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const pipelinePath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline.js'); +const orchestratorPath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline', 'orchestrator.js'); +const stageSequencerPath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline', 'stage-sequencer.js'); +const pipelineSource = fs.readFileSync(pipelinePath, 'utf8'); +const orchestratorSource = fs.readFileSync(orchestratorPath, 'utf8'); +const stageSequencerSource = fs.readFileSync(stageSequencerPath, 'utf8'); + +assert.match(pipelineSource, /runPipelineStageOrchestrator\(/, 'expected pipeline to delegate stage sequencing to orchestrator'); + +for (const marker of [ + 'runDiscovery(', + 'preScanImports(', + 'processFiles(', + 'postScanImports(', + 'buildIndexPostings(', + 'runCrossFileInference(', + 'writeIndexArtifactsForMode(' +]) { + assert.equal( + pipelineSource.includes(marker), + false, + `expected top-level pipeline module to stop inlining ${marker}` + ); + assert.equal( + orchestratorSource.includes(marker), + true, + `expected orchestrator module to own ${marker}` + ); +} + +const stageOrder = ['discover', 'imports', 'processing', 'relations', 'postings', 'write']; +const stagePositions = stageOrder.map((stageId) => stageSequencerSource.indexOf(`{ id: '${stageId}'`)); +assert.equal(stagePositions.every((value) => value >= 0), true, 'expected all stage ids in shared stage plan'); +for (let index = 1; index < stagePositions.length; index += 1) { + assert.equal( + stagePositions[index] > stagePositions[index - 1], + true, + `expected ${stageOrder[index - 1]} to precede ${stageOrder[index]} in stage plan` + ); +} + +console.log('pipeline stage orchestrator extraction test passed'); diff --git a/tests/indexing/runtime/pipeline-stage-sequencer-extraction.test.js b/tests/indexing/runtime/pipeline-stage-sequencer-extraction.test.js new file mode 100644 index 000000000..7840487cc --- /dev/null +++ b/tests/indexing/runtime/pipeline-stage-sequencer-extraction.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + createPipelineStageAdvancer, + INDEX_STAGE_PLAN +} from '../../../src/index/build/indexer/pipeline/stage-sequencer.js'; + +const root = process.cwd(); +const pipelinePath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline.js'); +const sequencerPath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline', 'stage-sequencer.js'); +const finalizePath = path.join(root, 'src', 'index', 'build', 'indexer', 'pipeline', 'finalize.js'); + +for (const target of [pipelinePath, sequencerPath, finalizePath]) { + assert.equal(fs.existsSync(target), true, `missing expected pipeline modularization file: ${target}`); +} + +const pipelineSource = fs.readFileSync(pipelinePath, 'utf8'); +for (const marker of [ + "./pipeline/stage-sequencer.js", + "./pipeline/finalize.js", + 'createPipelineStageAdvancer(', + 'finalizePipelineModeRun(' +]) { + assert.equal( + pipelineSource.includes(marker), + true, + `expected pipeline module to delegate via ${marker}` + ); +} +assert.equal( + pipelineSource.includes('const advanceStage = (stage) => {'), + false, + 'expected top-level pipeline module to stop inlining stage advancement' +); + +const stageMessages = []; +const telemetryStages = []; +const runtime = { + overallProgress: { + advance({ message }) { + stageMessages.push(message); + } + } +}; +const advanceStage = createPipelineStageAdvancer({ + mode: 'code', + runtime, + stagePlan: INDEX_STAGE_PLAN, + setSchedulerTelemetryStage(stageId) { + telemetryStages.push(stageId); + }, + getSchedulerStats() { + return { queues: [] }; + } +}); + +advanceStage(INDEX_STAGE_PLAN[0]); +advanceStage(INDEX_STAGE_PLAN[1]); +advanceStage(INDEX_STAGE_PLAN[2]); + +assert.deepEqual(telemetryStages, ['discover', 'imports', 'processing']); +assert.deepEqual(stageMessages, ['code discovery', 'code imports']); + +console.log('pipeline stage sequencer extraction test passed'); diff --git a/tests/indexing/runtime/platform-runtime-preset.test.js b/tests/indexing/runtime/platform-preset.test.js similarity index 100% rename from tests/indexing/runtime/platform-runtime-preset.test.js rename to tests/indexing/runtime/platform-preset.test.js diff --git a/tests/indexing/runtime/process-files-hotpath-extraction.test.js b/tests/indexing/runtime/process-files-hotpath-extraction.test.js new file mode 100644 index 000000000..906514045 --- /dev/null +++ b/tests/indexing/runtime/process-files-hotpath-extraction.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const processFilesPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files.js'); +const progressPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'progress.js'); +const stageTimingPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'stage-timing.js'); +const shardExecutionPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'shard-execution.js'); +const resultsPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'results.js'); + +for (const target of [processFilesPath, progressPath, stageTimingPath, shardExecutionPath, resultsPath]) { + assert.equal(fs.existsSync(target), true, `missing expected stage1 hot-path module: ${target}`); +} + +const processFilesSource = fs.readFileSync(processFilesPath, 'utf8'); + +for (const marker of [ + "./process-files/progress.js", + "./process-files/stage-timing.js", + "./process-files/shard-execution.js", + "./process-files/results.js", + 'executeStage1ShardProcessing(', + 'finalizeStage1ProcessingResult(' +]) { + assert.equal( + processFilesSource.includes(marker), + true, + `expected process-files hot path to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const createStage1ProgressTracker = (', + 'const buildStageTimingBreakdownPayload = () => ({', + 'const runShardWorker = async (workerContext) => {' +]) { + assert.equal( + processFilesSource.includes(legacyInlineMarker), + false, + `expected top-level process-files module to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('process-files hotpath extraction test passed'); diff --git a/tests/indexing/runtime/process-files-progress-module.test.js b/tests/indexing/runtime/process-files-progress-module.test.js new file mode 100644 index 000000000..d948c9bc0 --- /dev/null +++ b/tests/indexing/runtime/process-files-progress-module.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildFileProgressHeartbeatText, + createStage1ProgressTracker +} from '../../../src/index/build/indexer/steps/process-files/progress.js'; + +const checkpoint = { + ticks: 0, + tick() { + this.ticks += 1; + } +}; + +const tracker = createStage1ProgressTracker({ + total: 4, + mode: 'code', + checkpoint +}); + +assert.equal(tracker.markOrderedEntryComplete(10), true, 'expected first ordered entry to advance progress'); +assert.equal(tracker.markOrderedEntryComplete(10), false, 'expected duplicate ordered entry to be deduped'); +assert.equal(tracker.markOrderedEntryComplete(null, null, 'fallback:1'), true, 'expected fallback key to advance once'); +assert.equal(tracker.markOrderedEntryComplete(null, null, 'fallback:1'), false, 'expected duplicate fallback key to be deduped'); + +const snapshot = tracker.snapshot(); +assert.equal(snapshot.count, 2); +assert.deepEqual(snapshot.completedOrderIndices, [10]); +assert.deepEqual(snapshot.completedFallbackKeys, ['fallback:1']); +assert.equal(checkpoint.ticks, 2); + +const heartbeat = buildFileProgressHeartbeatText({ + count: 2, + total: 4, + startedAtMs: 1_000, + nowMs: 3_000, + inFlight: 1, + trackedSubprocesses: 2 +}); +assert.match(heartbeat, /progress 2\/4 \(50\.0%\)/); +assert.match(heartbeat, /inFlight=1 trackedSubprocesses=2/); + +console.log('process-files progress module test passed'); diff --git a/tests/indexing/runtime/process-files-results-module.test.js b/tests/indexing/runtime/process-files-results-module.test.js new file mode 100644 index 000000000..3b5bb749b --- /dev/null +++ b/tests/indexing/runtime/process-files-results-module.test.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { finalizeStage1ProcessingResult } from '../../../src/index/build/indexer/steps/process-files/results.js'; + +const timing = {}; +const state = {}; +let checkpointFinished = 0; +const checkpoint = { + async finish() { + checkpointFinished += 1; + } +}; + +const result = await finalizeStage1ProcessingResult({ + mode: 'code', + log() {}, + logLine() {}, + logLexiconFilterAggregate() {}, + timing, + state, + shardSummary: [{ id: 'shard-a', fileCount: 2 }], + shardPlan: [{ id: 'shard-a' }], + shardExecutionMeta: { enabled: true, shardCount: 1 }, + stallRecovery: { softKickAttempts: 1 }, + checkpoint, + processStart: Date.now() - 25, + buildStageTimingBreakdownPayload: () => ({ + watchdog: { + queueDelayMs: { summary: { count: 1, totalMs: 10 } }, + nearThreshold: { anomaly: false } + } + }), + buildExtractedProseLowYieldBailoutSummary: () => ({ enabled: false }), + extractedProseLowYieldBailout: null, + stage1WindowPlannerConfig: { enabled: true }, + stage1WindowReplanIntervalMs: 1000, + stage1WindowReplanMinSeqAdvance: 1, + stage1WindowReplanAttemptCount: 2, + stage1WindowReplanChangedCount: 1, + stage1LastWindowTelemetry: { changed: true }, + stage1SeqWindows: [{ + windowId: 'win-1', + startSeq: 1, + endSeq: 2, + entryCount: 2, + predictedCost: 10, + predictedBytes: 100 + }], + resolveStage1WindowSnapshot: () => ({ activeWindows: [{ windowId: 'win-1' }] }), + expectedOrderIndices: [1, 2], + getStage1ProgressSnapshot: () => ({ + count: 2, + total: 2, + completedOrderIndices: [1, 2] + }), + orderedAppender: { + snapshot() { + return { + terminalCount: 2, + committedCount: 2, + totalSeqCount: 2, + nextCommitSeq: 3 + }; + } + }, + resolveStage1OrderingIntegrity: () => ({ + ok: true, + missingIndices: [], + missingCount: 0, + expectedCount: 2, + progressCount: 2, + progressTotal: 2 + }), + startOrderIndex: 1, + orderIndexToRel: new Map([ + [1, 'src/a.js'], + [2, 'src/b.js'] + ]), + postingsQueue: { + stats() { + return { pendingCount: 0, pendingBytes: 0 }; + } + }, + tokenizationStats: { tokens: 12 } +}); + +assert.equal(checkpointFinished, 1, 'expected finalize helper to await checkpoint completion'); +assert.equal(result.tokenizationStats.tokens, 12); +assert.equal(result.shardExecution.enabled, true); +assert.deepEqual(result.postingsQueueStats, { pendingCount: 0, pendingBytes: 0 }); +assert.deepEqual(state.postingsQueueStats, { pendingCount: 0, pendingBytes: 0 }); +assert.equal(timing.shards.enabled, true); +assert.equal(timing.watchdog.stallRecovery.softKickAttempts, 1); +assert.equal(Array.isArray(state.stage1Windows.windows), true); + +console.log('process-files results module test passed'); diff --git a/tests/indexing/runtime/process-files-stage1-modularization.test.js b/tests/indexing/runtime/process-files-stage1-modularization.test.js new file mode 100644 index 000000000..e62db6ca3 --- /dev/null +++ b/tests/indexing/runtime/process-files-stage1-modularization.test.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const processFilesPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files.js'); +const runtimeStatePath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'runtime-state.js'); +const watchdogPolicyPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'watchdog-policy.js'); +const taskLifecyclePath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'task-lifecycle.js'); +const backpressurePath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'backpressure.js'); +const shardPlanPath = path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files', 'shard-plan.js'); + +for (const target of [ + processFilesPath, + runtimeStatePath, + watchdogPolicyPath, + taskLifecyclePath, + backpressurePath, + shardPlanPath +]) { + assert.equal(fs.existsSync(target), true, `missing expected process-files modularization file: ${target}`); +} + +const source = fs.readFileSync(processFilesPath, 'utf8'); + +for (const marker of [ + "./process-files/runtime-state.js", + "./process-files/watchdog-policy.js", + "./process-files/task-lifecycle.js", + "./process-files/backpressure.js", + "./process-files/shard-plan.js" +]) { + assert.equal( + source.includes(marker), + true, + `expected process-files to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const resolveRuntimeStatePath = (runtime, fileName) => {', + 'export const resolveFileWatchdogConfig = (runtime, { repoFileCount = 0 } = {}) => {', + 'export const runCleanupWithTimeout = async ({', + 'export const shouldBypassPostingsBackpressure = ({', + 'const buildStage1ShardWorkPlan = ({' +]) { + assert.equal( + source.includes(legacyInlineMarker), + false, + `expected process-files to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('process-files stage1 modularization test passed'); diff --git a/tests/indexing/runtime/runtime-queue-throughput-defaults.test.js b/tests/indexing/runtime/queue-throughput-defaults.test.js similarity index 100% rename from tests/indexing/runtime/runtime-queue-throughput-defaults.test.js rename to tests/indexing/runtime/queue-throughput-defaults.test.js diff --git a/tests/indexing/runtime/runtime-teardown-order.test.js b/tests/indexing/runtime/runtime-teardown-order.test.js new file mode 100644 index 000000000..b3df22a90 --- /dev/null +++ b/tests/indexing/runtime/runtime-teardown-order.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { teardownRuntime } from '../../../src/integrations/core/build-index/runtime.js'; + +applyTestEnv(); + +const events = []; +await teardownRuntime({ + log: () => {}, + scheduler: { + async shutdown() { + events.push('scheduler.shutdown'); + } + }, + workerPools: { + async destroy() { + events.push('workerPools.destroy'); + } + } +}); + +assert.deepEqual( + events, + ['scheduler.shutdown', 'workerPools.destroy'], + 'expected scheduler shutdown before worker pool destruction' +); + +console.log('runtime teardown order test passed'); diff --git a/tests/indexing/runtime/scheduler-nested-embedding-queue-no-deadlock.test.js b/tests/indexing/runtime/scheduler-nested-embedding-queue-no-deadlock.test.js new file mode 100644 index 000000000..fa5ccaac9 --- /dev/null +++ b/tests/indexing/runtime/scheduler-nested-embedding-queue-no-deadlock.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; +import { createRuntimeQueues } from '../../../src/index/build/runtime/workers.js'; + +const scheduler = createBuildScheduler({ + enabled: true, + lowResourceMode: false, + cpuTokens: 1, + ioTokens: 1, + memoryTokens: 1, + queues: { + 'stage1.cpu': { priority: 40, weight: 5 }, + 'embeddings.compute': { priority: 30, weight: 4 } + } +}); + +const runtime = createRuntimeQueues({ + ioConcurrency: 1, + cpuConcurrency: 1, + fileConcurrency: 1, + embeddingConcurrency: 1, + pendingLimits: null, + scheduler, + memoryPolicy: { + maxGlobalRssMb: 4096, + reserveRssMb: 512, + queueHeadroomScale: 1 + } +}); + +assert.ok(runtime.queues.cpu, 'expected cpu queue'); +assert.ok(runtime.queues.embedding, 'expected embedding queue'); + +const withTimeout = (promise, timeoutMs, message) => { + let timeout = null; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeout) clearTimeout(timeout); + }); +}; + +try { + const nested = runtime.queues.cpu.add(async () => ( + runtime.queues.embedding.add(async () => 'embedded-ok') + )); + + const result = await withTimeout( + nested, + 1500, + 'nested cpu->embedding schedule stalled (deadlock regression)' + ); + assert.equal(result, 'embedded-ok', 'expected nested embedding work to complete'); + + await runtime.queues.embedding.onIdle(); + await runtime.queues.cpu.onIdle(); + console.log('scheduler nested embedding queue no-deadlock test passed'); +} finally { + scheduler.shutdown(); +} diff --git a/tests/indexing/runtime/stage-checkpoint-runtime-snapshot-sanitize.test.js b/tests/indexing/runtime/stage-checkpoint-snapshot-sanitize.test.js similarity index 100% rename from tests/indexing/runtime/stage-checkpoint-runtime-snapshot-sanitize.test.js rename to tests/indexing/runtime/stage-checkpoint-snapshot-sanitize.test.js diff --git a/tests/indexing/runtime/stage-memory-checkpoints.test.js b/tests/indexing/runtime/stage-memory-checkpoints.test.js deleted file mode 100644 index 3491ce5d1..000000000 --- a/tests/indexing/runtime/stage-memory-checkpoints.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createStageCheckpointRecorder } from '../../../src/index/build/stage-checkpoints.js'; - -const recorder = createStageCheckpointRecorder({ mode: 'code' }); -recorder.record({ stage: 'stage1', step: 'discovery', extra: { files: 10 } }); - -const summary = recorder.buildSummary(); -assert.equal(summary.mode, 'code'); -assert.equal(summary.checkpoints.length, 1); - -const checkpoint = summary.checkpoints[0]; -assert.equal(checkpoint.stage, 'stage1'); - -const memory = checkpoint.memory || {}; -const fields = ['rss', 'heapUsed', 'heapTotal', 'external', 'arrayBuffers']; -for (const field of fields) { - const value = memory[field]; - if (value !== null) { - assert.equal(Number.isFinite(value), true, `memory.${field} should be finite`); - } -} - -const stageSummary = summary.stages.stage1; -assert(stageSummary, 'stage summary should exist'); -assert.equal(stageSummary.checkpointCount, 1); - -console.log('stage memory checkpoints ok'); diff --git a/tests/indexing/runtime/stage-overrides.test.js b/tests/indexing/runtime/stage-overrides.test.js deleted file mode 100644 index 0655959a5..000000000 --- a/tests/indexing/runtime/stage-overrides.test.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import { buildStageOverrides, normalizeStage } from '../../../src/index/build/runtime/stage.js'; - -const fail = (message) => { - console.error(message); - process.exit(1); -}; - -if (normalizeStage('stage1') !== 'stage1') { - fail('normalizeStage should match stage1.'); -} -if (normalizeStage('embed') !== 'stage3') { - fail('normalizeStage should map embed to stage3.'); -} -if (normalizeStage('ann') !== 'stage4') { - fail('normalizeStage should map ann to stage4.'); -} -if (normalizeStage('') !== null) { - fail('normalizeStage should return null for empty input.'); -} - -const stage1Overrides = buildStageOverrides({ stage1: { lint: true } }, 'stage1'); -if (!stage1Overrides || stage1Overrides.lint !== true) { - fail('stage1 overrides should merge explicit values.'); -} -if (stage1Overrides.embeddings?.enabled !== false) { - fail('stage1 overrides should disable embeddings.'); -} -if (stage1Overrides.treeSitter?.enabled !== false) { - fail('stage1 overrides should disable tree-sitter.'); -} -if (stage1Overrides.typeInference !== false) { - fail('stage1 overrides should disable type inference.'); -} - -const stage2Overrides = buildStageOverrides({ stage2: { lint: false, embeddings: { enabled: true } } }, 'stage2'); -if (!stage2Overrides || stage2Overrides.embeddings?.enabled !== true) { - fail('stage2 overrides should preserve explicit embeddings config.'); -} - -const stage3Overrides = buildStageOverrides({ stage3: { lint: true } }, 'stage3'); -if (!stage3Overrides || stage3Overrides.lint !== true) { - fail('stage3 overrides should merge explicit values.'); -} -if (stage3Overrides.treeSitter?.enabled !== false) { - fail('stage3 overrides should disable tree-sitter.'); -} - -if (buildStageOverrides({}, 'unknown') !== null) { - fail('buildStageOverrides should return null for unknown stages.'); -} - -console.log('build runtime stage overrides tests passed'); diff --git a/tests/indexing/runtime/stage-timing-checkpoints.test.js b/tests/indexing/runtime/stage-timing-checkpoints.test.js deleted file mode 100644 index 913248a26..000000000 --- a/tests/indexing/runtime/stage-timing-checkpoints.test.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createStageCheckpointRecorder } from '../../../src/index/build/stage-checkpoints.js'; - -const recorder = createStageCheckpointRecorder({ mode: 'code' }); -recorder.record({ stage: 'stage1', step: 'start', extra: { chunks: 1 } }); -await new Promise((resolve) => setTimeout(resolve, 5)); -recorder.record({ stage: 'stage1', step: 'postings', extra: { chunks: 5 } }); - -const summary = recorder.buildSummary(); -const stageSummary = summary.stages.stage1; -assert(stageSummary, 'stage summary should exist'); -assert.equal(stageSummary.checkpointCount, 2); -assert(stageSummary.elapsedMs >= 0, 'elapsedMs should be non-negative'); - -const highWaterChunks = summary.highWater?.extra?.chunks; -assert.equal(highWaterChunks, 5); - -const elapsedValues = summary.checkpoints.map((entry) => entry.elapsedMs); -assert(elapsedValues[1] >= elapsedValues[0], 'elapsedMs should be monotonic'); - -console.log('stage timing checkpoints ok'); diff --git a/tests/indexing/runtime/runtime-telemetry-collector.test.js b/tests/indexing/runtime/telemetry-collector.test.js similarity index 100% rename from tests/indexing/runtime/runtime-telemetry-collector.test.js rename to tests/indexing/runtime/telemetry-collector.test.js diff --git a/tests/indexing/runtime/two-stage-state.test.js b/tests/indexing/runtime/two-stage-state.test.js index ef22facfb..a49cebdcc 100644 --- a/tests/indexing/runtime/two-stage-state.test.js +++ b/tests/indexing/runtime/two-stage-state.test.js @@ -3,8 +3,8 @@ import { applyTestEnv } from '../../helpers/test-env.js'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -20,18 +20,43 @@ await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\n' const env = applyTestEnv({ cacheRoot: cacheRoot, - embeddings: 'stub' + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + workerPool: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } }); const runBuild = (label, args) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); if (result.status !== 0) { console.error(`Failed: ${label}`); process.exit(result.status ?? 1); } }; -runBuild('stage1', [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--stage', 'stage1', '--repo', repoRoot]); +runBuild('stage1', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot +]); const userConfig = loadUserConfig(repoRoot); const resolveStagePaths = () => { const codeDir = getIndexDir(repoRoot, 'code', userConfig); @@ -69,7 +94,18 @@ if (enrichmentStage1.status !== 'pending') { process.exit(1); } -runBuild('stage2', [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--stage', 'stage2', '--repo', repoRoot]); +runBuild('stage2', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage2', + '--mode', + 'code', + '--repo', + repoRoot +]); ({ codeDir, statePath, relationsPath } = resolveStagePaths()); const stateStage2 = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); @@ -87,12 +123,19 @@ if (enrichmentStage2.status !== 'done') { process.exit(1); } -runBuild('stage3', [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--stage', 'stage3', '--repo', repoRoot]); +runBuild('stage3 embeddings', [ + path.join(root, 'tools', 'build', 'embeddings.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot +]); ({ codeDir, statePath, relationsPath } = resolveStagePaths()); const stateStage3 = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); if (stateStage3.embeddings?.ready !== true) { - console.error('Expected stage3 to mark embeddings ready'); + console.error('Expected stage3 embeddings build to mark embeddings ready'); process.exit(1); } const denseArtifacts = resolveDenseArtifacts(codeDir); diff --git a/tests/indexing/runtime/vfs-checkpoint-stats.test.js b/tests/indexing/runtime/vfs-checkpoint-stats.test.js deleted file mode 100644 index ee0cc7968..000000000 --- a/tests/indexing/runtime/vfs-checkpoint-stats.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createStageCheckpointRecorder } from '../../../src/index/build/stage-checkpoints.js'; - -const recorder = createStageCheckpointRecorder({ mode: 'code' }); - -recorder.record({ - stage: 'stage2', - step: 'write', - extra: { - vfsManifest: { - rows: 10, - bytes: 1024, - maxLineBytes: 256, - trimmedRows: 2, - droppedRows: 1, - runsSpilled: 0 - } - } -}); - -recorder.record({ - stage: 'stage2', - step: 'write', - extra: { - vfsManifest: { - rows: 15, - bytes: 900, - maxLineBytes: 512, - trimmedRows: 4, - droppedRows: 3, - runsSpilled: 1 - } - } -}); - -const summary = recorder.buildSummary(); -const stageSummary = summary.stages.stage2; -assert(stageSummary, 'stage summary should exist'); - -const vfsHighWater = stageSummary.extraHighWater?.vfsManifest; -assert(vfsHighWater, 'vfsManifest high water should exist'); - -assert.equal(vfsHighWater.rows, 15); -assert.equal(vfsHighWater.bytes, 1024); -assert.equal(vfsHighWater.maxLineBytes, 512); -assert.equal(vfsHighWater.trimmedRows, 4); -assert.equal(vfsHighWater.droppedRows, 3); -assert.equal(vfsHighWater.runsSpilled, 1); - -console.log('vfs checkpoint stats ok'); diff --git a/tests/indexing/scheduler/planned-segments-reuse-without-rediscovery.test.js b/tests/indexing/scheduler/planned-segments-reuse-without-rediscovery.test.js index d54a6a21d..6714ce87b 100644 --- a/tests/indexing/scheduler/planned-segments-reuse-without-rediscovery.test.js +++ b/tests/indexing/scheduler/planned-segments-reuse-without-rediscovery.test.js @@ -1,110 +1,19 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { normalizeCommentConfig } from '../../../src/index/comments.js'; -import { getLanguageForFile } from '../../../src/index/language-registry.js'; -import { assignSegmentUids, discoverSegments, normalizeSegmentsConfig } from '../../../src/index/segments.js'; import { processFileCpu } from '../../../src/index/build/file-processor/cpu.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv({ testing: '1' }); - -const noop = () => {}; -const root = process.cwd(); -const abs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); -const rel = path.relative(root, abs); -const relKey = rel.split(path.sep).join('/'); -const ext = '.js'; -const text = await fs.readFile(abs, 'utf8'); -const fileStat = await fs.stat(abs); -const languageHint = getLanguageForFile(ext, relKey); - -const timing = { - metricsCollector: null, - addSettingMetric: noop, - addLineSpan: noop, - addParseDuration: noop, - addTokenizeDuration: noop, - addEnrichDuration: noop, - addEmbeddingDuration: noop, - addLintDuration: noop, - addComplexityDuration: noop, - setGitDuration: noop, - setPythonAstDuration: noop -}; +import { assignSegmentUids, discoverSegments, normalizeSegmentsConfig } from '../../../src/index/segments.js'; +import { createTreeSitterProcessFileCpuFixture } from '../file-processor/tree-sitter-process-file-cpu-fixture.js'; -const baseContext = { - abs, - root, - mode: 'code', - fileEntry: { abs, rel: relKey }, - fileIndex: 1, +const { ext, - rel, relKey, text, - fileStat, - fileHash: 'scheduler-planned-segments-test', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - tokenizeEnabled: true, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: noop, - showLineProgress: false, - toolInfo: null, - timing, languageHint, - crashLogger: { enabled: false, updateFile: noop }, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' -}; - -const createContext = (overrides = {}) => ({ ...baseContext, ...overrides }); + createContext +} = await createTreeSitterProcessFileCpuFixture({ + fileHash: 'scheduler-planned-segments-test' +}); const chunkFingerprint = (chunk) => ({ chunkUid: chunk.chunkUid, diff --git a/tests/indexing/scm/build-runtime-timeout-policy.test.js b/tests/indexing/scm/build-runtime-timeout-policy.test.js new file mode 100644 index 000000000..d6b1be399 --- /dev/null +++ b/tests/indexing/scm/build-runtime-timeout-policy.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parseBuildArgs } from '../../../src/index/build/args.js'; +import { createBuildRuntime } from '../../../src/index/build/runtime.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'build-runtime-scm-timeout-policy'); +const repoRoot = path.join(tempRoot, 'repo'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'index.js'), 'export const answer = 42;\n'); + +applyTestEnv({ + cacheRoot: tempRoot, + embeddings: 'off', + testConfig: { + indexing: { + scm: { + provider: 'none' + }, + embeddings: { + enabled: false, + hnsw: { enabled: false }, + lancedb: { enabled: false } + }, + treeSitter: { enabled: false }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + } + } +}); + +const defaults = parseBuildArgs([]).argv; +const runtime = await createBuildRuntime({ + root: repoRoot, + argv: defaults, + rawArgv: [] +}); + +assert.equal(runtime?.scmConfig?.workload, 'batch', 'expected build runtime to mark SCM workload as batch'); +assert.equal( + runtime?.scmConfig?.allowSlowTimeouts, + true, + 'expected build runtime SCM policy to allow slower timeout defaults' +); +assert.equal( + runtime?.scmConfig?.annotate?.allowSlowTimeouts, + true, + 'expected build runtime SCM annotate policy to allow slower timeout defaults' +); + +console.log('build runtime SCM timeout policy test passed'); diff --git a/tests/indexing/scm/config-bench-annotate-default.test.js b/tests/indexing/scm/config-bench-annotate-default.test.js new file mode 100644 index 000000000..4e28c59c3 --- /dev/null +++ b/tests/indexing/scm/config-bench-annotate-default.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveScmConfig } from '../../../src/index/scm/registry.js'; + +const benchDefault = resolveScmConfig({ + indexingConfig: {}, + analysisPolicy: null, + benchRun: true +}); +assert.equal(benchDefault.annotate.enabled, false, 'expected bench runs to default scm annotate off'); + +const benchExplicitEnable = resolveScmConfig({ + indexingConfig: { scm: { annotate: { enabled: true } } }, + analysisPolicy: null, + benchRun: true +}); +assert.equal(benchExplicitEnable.annotate.enabled, true, 'expected explicit annotate enable to override bench default'); + +const benchPolicyEnable = resolveScmConfig({ + indexingConfig: {}, + analysisPolicy: { git: { blame: true } }, + benchRun: true +}); +assert.equal(benchPolicyEnable.annotate.enabled, true, 'expected analysis policy blame=true to override bench default'); + +const normalDefault = resolveScmConfig({ + indexingConfig: {}, + analysisPolicy: null, + benchRun: false +}); +assert.equal(normalDefault.annotate.enabled, true, 'expected non-bench runs to keep annotate enabled by default'); + +const explicitGitBlameDisable = resolveScmConfig({ + indexingConfig: { gitBlame: false }, + analysisPolicy: null, + benchRun: false +}); +assert.equal(explicitGitBlameDisable.annotate.enabled, false, 'expected explicit gitBlame=false to disable annotate'); + +const interactiveDefault = resolveScmConfig({ + indexingConfig: {}, + analysisPolicy: null, + workload: 'interactive' +}); +assert.equal( + interactiveDefault.allowSlowTimeouts === true, + false, + 'expected interactive SCM config to keep aggressive timeout caps by default' +); + +const batchDefault = resolveScmConfig({ + indexingConfig: {}, + analysisPolicy: null, + workload: 'batch' +}); +assert.equal(batchDefault.allowSlowTimeouts, true, 'expected batch SCM config to enable slow-timeout path by default'); +assert.equal( + batchDefault.annotate.allowSlowTimeouts, + true, + 'expected batch SCM annotate config to enable slow-timeout path by default' +); + +const batchExplicitDisable = resolveScmConfig({ + indexingConfig: { scm: { allowSlowTimeouts: false, annotate: { allowSlowTimeouts: false } } }, + analysisPolicy: null, + workload: 'batch' +}); +assert.equal( + batchExplicitDisable.allowSlowTimeouts, + false, + 'expected batch SCM config to respect explicit allowSlowTimeouts=false' +); +assert.equal( + batchExplicitDisable.annotate.allowSlowTimeouts, + false, + 'expected batch SCM annotate config to respect explicit allowSlowTimeouts=false' +); + +console.log('scm config bench annotate default test passed'); diff --git a/tests/indexing/scm/file-meta-snapshot-churn-optin-contract.test.js b/tests/indexing/scm/file-meta-snapshot-churn-optin-contract.test.js new file mode 100644 index 000000000..443c13744 --- /dev/null +++ b/tests/indexing/scm/file-meta-snapshot-churn-optin-contract.test.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const source = fs.readFileSync( + path.join(root, 'src', 'index', 'build', 'indexer', 'steps', 'process-files.js'), + 'utf8' +); + +assert.match( + source, + /includeChurn:\s*scmSnapshotConfig\.includeChurn\s*===\s*true/, + 'expected SCM file-meta snapshot churn collection to remain opt-in' +); + +console.log('scm file-meta snapshot churn opt-in contract test passed'); diff --git a/tests/indexing/scm/file-meta-snapshot-partial-fallback.test.js b/tests/indexing/scm/file-meta-snapshot-partial-fallback.test.js new file mode 100644 index 000000000..8e843e83b --- /dev/null +++ b/tests/indexing/scm/file-meta-snapshot-partial-fallback.test.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { prepareScmFileMetaSnapshot } from '../../../src/index/scm/file-meta-snapshot.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const cacheRoot = resolveTestCachePath(process.cwd(), 'scm-file-meta-snapshot-partial-fallback'); +const repoRoot = path.join(cacheRoot, 'repo'); +const files = ['src/a.js', 'src/b.js']; + +fs.rmSync(cacheRoot, { recursive: true, force: true }); +fs.mkdirSync(repoRoot, { recursive: true }); +for (const rel of files) { + const abs = path.join(repoRoot, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `// ${rel}\n`, 'utf8'); +} + +const batchCalls = []; +const perFileCalls = []; +const providerImpl = { + async getFileMetaBatch({ filesPosix, includeChurn }) { + batchCalls.push({ + filesPosix: Array.isArray(filesPosix) ? filesPosix.slice().sort() : [], + includeChurn + }); + return { + fileMetaByPath: { + 'src/a.js': { + lastCommitId: 'commit-a', + lastModifiedAt: '2026-03-06T00:00:00Z', + lastAuthor: 'batch-author-a', + churn: 7, + churnAdded: 5, + churnDeleted: 2, + churnCommits: 2 + }, + 'src/b.js': { + lastCommitId: 'commit-b', + lastModifiedAt: '2026-03-06T00:00:00Z', + lastAuthor: 'batch-author-b', + churn: null, + churnAdded: null, + churnDeleted: null, + churnCommits: null + } + } + }; + }, + async getFileMeta({ filePosix, includeChurn }) { + perFileCalls.push({ filePosix, includeChurn }); + return { + lastCommitId: 'commit-b', + lastModifiedAt: '2026-03-06T00:00:00Z', + lastAuthor: 'fallback-author-b', + churn: 11, + churnAdded: 8, + churnDeleted: 3, + churnCommits: 3 + }; + } +}; + +try { + const snapshot = await prepareScmFileMetaSnapshot({ + repoCacheRoot: cacheRoot, + provider: 'git', + providerImpl, + repoRoot, + repoProvenance: { head: { commitId: 'headA' }, dirty: false }, + filesPosix: files, + includeChurn: true, + timeoutMs: 15000 + }); + + assert.equal(batchCalls.length, 1, 'expected one batch fetch'); + assert.deepEqual(batchCalls[0], { + filesPosix: ['src/a.js', 'src/b.js'], + includeChurn: true + }); + assert.deepEqual(perFileCalls, [ + { filePosix: 'src/b.js', includeChurn: true } + ]); + assert.equal(snapshot?.stats?.source, 'fresh'); + assert.equal(snapshot?.fileMetaByPath?.['src/a.js']?.lastAuthor, 'batch-author-a'); + assert.equal(snapshot?.fileMetaByPath?.['src/b.js']?.lastAuthor, 'fallback-author-b'); + assert.equal(snapshot?.fileMetaByPath?.['src/b.js']?.churnAdded, 8); + assert.equal(snapshot?.fileMetaByPath?.['src/b.js']?.churnDeleted, 3); + assert.equal(snapshot?.fileMetaByPath?.['src/b.js']?.churnCommits, 3); + assert.equal(snapshot?.stats?.reuse?.countsByCause?.cache_miss, 1, 'expected successful recovery to classify as cache miss, not provider fallback'); +} finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); +} + +console.log('scm file-meta snapshot partial fallback test passed'); diff --git a/tests/indexing/scm/file-meta-snapshot-reuse.test.js b/tests/indexing/scm/file-meta-snapshot-reuse.test.js index 3da6c285c..cc5c7132b 100644 --- a/tests/indexing/scm/file-meta-snapshot-reuse.test.js +++ b/tests/indexing/scm/file-meta-snapshot-reuse.test.js @@ -58,6 +58,7 @@ try { }); assert.equal(first.stats.fetched, 3, 'expected first snapshot to fetch all files'); assert.equal(first.stats.reused, 0, 'expected first snapshot to reuse nothing'); + assert.equal(first.stats.reuse?.countsByCause?.cache_miss, 1, 'expected fresh snapshot reuse summary'); assert.equal(first.fileMetaByPath['src/a.js'].lastAuthor, 'headA:src/a.js'); assert.equal(first.fileMetaByPath.get('src/a.js')?.lastAuthor, 'headA:src/a.js'); @@ -74,6 +75,7 @@ try { }); assert.equal(second.stats.reused, 2, 'expected changed-files reuse for unchanged entries'); assert.equal(second.stats.fetched, 1, 'expected only changed file to be refetched'); + assert.equal(second.stats.reuse?.countsByCause?.scm_state_prevents_reuse, 1, 'expected partial reuse summary'); assert.equal(second.fileMetaByPath['src/a.js'].lastAuthor, 'headA:src/a.js'); assert.equal(second.fileMetaByPath['src/b.js'].lastAuthor, 'headB:src/b.js'); @@ -89,6 +91,7 @@ try { }); assert.equal(third.stats.source, 'cache', 'expected full cache reuse at same head'); assert.equal(third.stats.fetched, 0, 'expected no fetches at same head'); + assert.equal(third.stats.reuse?.countsByCause?.cache_hit, 1, 'expected full cache-hit summary'); assert.equal(third.fileMetaByPath.get('src/a.js')?.lastAuthor, 'headA:src/a.js'); setScmRuntimeConfig({ repoHeadId: 'headB', snapshotSalt: 'v2' }); diff --git a/tests/indexing/scm/file-meta-snapshot-unavailable-fallback-persists.test.js b/tests/indexing/scm/file-meta-snapshot-unavailable-fallback-persists.test.js new file mode 100644 index 000000000..19fd39365 --- /dev/null +++ b/tests/indexing/scm/file-meta-snapshot-unavailable-fallback-persists.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { prepareScmFileMetaSnapshot } from '../../../src/index/scm/file-meta-snapshot.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const cacheRoot = resolveTestCachePath(process.cwd(), 'scm-file-meta-snapshot-unavailable-fallback-persists'); +const repoRoot = path.join(cacheRoot, 'repo'); +const files = ['src/a.js', 'src/b.js']; + +fs.rmSync(cacheRoot, { recursive: true, force: true }); +fs.mkdirSync(repoRoot, { recursive: true }); +for (const rel of files) { + const abs = path.join(repoRoot, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `// ${rel}\n`, 'utf8'); +} + +const providerImpl = { + async getFileMetaBatch() { + return { + ok: false, + reason: 'unavailable' + }; + }, + async getFileMeta({ filePosix }) { + if (filePosix === 'src/b.js') { + return { ok: false, reason: 'unavailable' }; + } + return { + lastCommitId: 'commit-a', + lastModifiedAt: '2026-03-06T00:00:00Z', + lastAuthor: 'fallback-author-a', + churn: 7, + churnAdded: 5, + churnDeleted: 2, + churnCommits: 2 + }; + } +}; + +try { + const snapshot = await prepareScmFileMetaSnapshot({ + repoCacheRoot: cacheRoot, + provider: 'git', + providerImpl, + repoRoot, + repoProvenance: { head: { commitId: 'headA' }, dirty: false }, + filesPosix: files, + includeChurn: true, + timeoutMs: 15000 + }); + + assert.equal(snapshot?.stats?.source, 'fallback'); + assert.equal(snapshot?.stats?.reuse?.countsByCause?.provider_unavailable, 1, 'expected unresolved provider failures to remain fallback debt'); + assert.equal(snapshot?.fileMetaByPath?.['src/a.js']?.lastAuthor, 'fallback-author-a'); + assert.equal(Object.prototype.hasOwnProperty.call(snapshot?.fileMetaByPath || {}, 'src/b.js'), false); +} finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); +} + +console.log('scm file-meta snapshot unavailable fallback persists test passed'); diff --git a/tests/indexing/scm/git-provider-meta-batch-parallel.test.js b/tests/indexing/scm/git-provider-meta-batch-parallel.test.js index f02c90ebf..2b8668cd2 100644 --- a/tests/indexing/scm/git-provider-meta-batch-parallel.test.js +++ b/tests/indexing/scm/git-provider-meta-batch-parallel.test.js @@ -4,7 +4,7 @@ import path from 'node:path'; import { gitProvider } from '../../../src/index/scm/providers/git.js'; import { getScmRuntimeConfig, setScmRuntimeConfig } from '../../../src/index/scm/runtime.js'; import { getScmCommandRunner, setScmCommandRunner } from '../../../src/index/scm/runner.js'; -import { setProgressHandlers } from '../../../src/shared/progress.js'; +import { setProgressHandlers } from '../../../src/shared/progress-runtime.js'; const defaultRunner = getScmCommandRunner(); const defaultScmConfig = getScmRuntimeConfig(); diff --git a/tests/indexing/scm/index-build-git-provider.test.js b/tests/indexing/scm/index-build-git-provider.test.js index 285768e64..ee22def0b 100644 --- a/tests/indexing/scm/index-build-git-provider.test.js +++ b/tests/indexing/scm/index-build-git-provider.test.js @@ -7,6 +7,7 @@ import { getCurrentBuildInfo, getIndexDir, loadUserConfig, toRealPathSync } from import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; const gitCheck = spawnSync('git', ['--version'], { encoding: 'utf8' }); if (gitCheck.status !== 0) { @@ -53,8 +54,7 @@ try { } } }); - const buildResult = spawnSync( - process.execPath, + const buildResult = runNode( [ path.join(process.cwd(), 'build_index.js'), '--stub-embeddings', @@ -64,7 +64,10 @@ try { '--mode', 'code' ], - { cwd: repoRoot, env, stdio: 'inherit' } + 'git provider build index', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } ); if (buildResult.status !== 0) { console.error('git provider build test failed: build_index failed'); diff --git a/tests/indexing/scm/index-build-no-scm.test.js b/tests/indexing/scm/index-build-no-scm.test.js deleted file mode 100644 index 1b3957019..000000000 --- a/tests/indexing/scm/index-build-no-scm.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCurrentBuildInfo, getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const tempRoot = await makeTempDir('poc-scm-none-provider-'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; - -try { - await fsPromises.mkdir(repoRootRaw, { recursive: true }); - await fsPromises.mkdir(cacheRoot, { recursive: true }); - const repoRoot = toRealPathSync(repoRootRaw); - - const trackedFile = path.join(repoRoot, 'alpha.js'); - await fsPromises.writeFile(trackedFile, 'export const alpha = 1;\n'); - - const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' - }; - const buildResult = spawnSync( - process.execPath, - [ - path.join(process.cwd(), 'build_index.js'), - '--stub-embeddings', - '--repo', - repoRoot, - '--mode', - 'code', - '--scm-provider', - 'none' - ], - { cwd: repoRoot, env, stdio: 'inherit' } - ); - if (buildResult.status !== 0) { - console.error('no-scm build test failed: build_index failed'); - process.exit(buildResult.status ?? 1); - } - - const userConfig = loadUserConfig(repoRoot); - const buildInfo = getCurrentBuildInfo(repoRoot, userConfig, { mode: 'code' }); - assert(buildInfo?.buildRoot, 'expected current build info'); - const buildState = JSON.parse( - await fsPromises.readFile(path.join(buildInfo.buildRoot, 'build_state.json'), 'utf8') - ); - assert.equal(buildState?.repo?.provider, 'none'); - assert.equal(buildState?.repo?.head, null); - - const codeDir = getIndexDir(repoRoot, 'code', userConfig); - const fileMetaResult = await loadJsonArrayArtifact(codeDir, 'file_meta'); - const fileMeta = Array.isArray(fileMetaResult?.records) - ? fileMetaResult.records - : (Array.isArray(fileMetaResult) ? fileMetaResult : []); - const files = new Set(fileMeta.map((entry) => entry?.file).filter(Boolean)); - assert(files.has('alpha.js'), 'expected alpha.js to be indexed'); -} finally { - await rmDirRecursive(tempRoot); -} - -console.log('no-scm build ok'); diff --git a/tests/indexing/scm/index-build-no.test.js b/tests/indexing/scm/index-build-no.test.js new file mode 100644 index 000000000..385fad843 --- /dev/null +++ b/tests/indexing/scm/index-build-no.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getCurrentBuildInfo, getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const tempRoot = await makeTempDir('poc-scm-none-provider-'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +try { + await fsPromises.mkdir(repoRootRaw, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + const repoRoot = toRealPathSync(repoRootRaw); + + const trackedFile = path.join(repoRoot, 'alpha.js'); + await fsPromises.writeFile(trackedFile, 'export const alpha = 1;\n'); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false, + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } + }); + const buildResult = runNode( + [ + path.join(process.cwd(), 'build_index.js'), + '--stub-embeddings', + '--repo', + repoRoot, + '--stage', + 'stage1', + '--mode', + 'code', + '--scm-provider', + 'none' + ], + 'no-scm provider build index', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } + ); + if (buildResult.status !== 0) { + console.error('no-scm build test failed: build_index failed'); + process.exit(buildResult.status ?? 1); + } + + await withTemporaryEnv({ PAIROFCLEATS_CACHE_ROOT: cacheRoot }, async () => { + const userConfig = loadUserConfig(repoRoot); + const buildInfo = getCurrentBuildInfo(repoRoot, userConfig, { mode: 'code' }); + assert(buildInfo?.buildRoot, 'expected current build info'); + const buildState = JSON.parse( + await fsPromises.readFile(path.join(buildInfo.buildRoot, 'build_state.json'), 'utf8') + ); + assert.equal(buildState?.repo?.provider, 'none'); + assert.equal(buildState?.repo?.head, null); + + const codeDir = getIndexDir(repoRoot, 'code', userConfig); + const fileMetaResult = await loadJsonArrayArtifact(codeDir, 'file_meta'); + const fileMeta = Array.isArray(fileMetaResult?.records) + ? fileMetaResult.records + : (Array.isArray(fileMetaResult) ? fileMetaResult : []); + const files = new Set(fileMeta.map((entry) => entry?.file).filter(Boolean)); + assert(files.has('alpha.js'), 'expected alpha.js to be indexed'); + }); +} finally { + await rmDirRecursive(tempRoot); +} + +console.log('no-scm build ok'); diff --git a/tests/indexing/scm/no-build-id.test.js b/tests/indexing/scm/no-build-id.test.js new file mode 100644 index 000000000..3fa83185a --- /dev/null +++ b/tests/indexing/scm/no-build-id.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getCurrentBuildInfo, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const tempRoot = await makeTempDir('poc-scm-noscm-buildid-'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; + +try { + await fsPromises.mkdir(repoRootRaw, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + const repoRoot = toRealPathSync(repoRootRaw); + + const filePath = path.join(repoRoot, 'alpha.js'); + await fsPromises.writeFile(filePath, 'export const alpha = 1;\n'); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + extraEnv: { + PAIROFCLEATS_THREADS: '1', + PAIROFCLEATS_WORKER_POOL: 'auto' + } + }); + const runBuild = async () => { + const buildResult = runNode( + [ + path.join(process.cwd(), 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage2', + '--repo', + repoRoot, + '--mode', + 'code', + '--scm-provider', + 'none' + ], + 'no-scm buildId build index', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } + ); + if (buildResult.status !== 0) { + console.error('no-scm buildId test failed: build_index failed'); + process.exit(buildResult.status ?? 1); + } + const userConfig = loadUserConfig(repoRoot); + const buildInfo = getCurrentBuildInfo(repoRoot, userConfig, { mode: 'code' }); + assert(buildInfo?.buildRoot, 'expected current build info'); + const buildState = JSON.parse( + await fsPromises.readFile(path.join(buildInfo.buildRoot, 'build_state.json'), 'utf8') + ); + return { + buildId: String(buildState?.buildId || ''), + cacheSignature: buildState?.signatures?.code?.cacheSignature || null, + repo: buildState?.repo || null + }; + }; + + const first = await runBuild(); + const second = await runBuild(); + + assert(first.buildId.includes('_noscm_'), 'expected buildId to include noscm marker'); + assert.equal(first.repo?.provider, 'none'); + assert.equal(first.repo?.head ?? null, null); + assert.equal(first.repo?.commit ?? null, null); + assert.equal(first.cacheSignature, second.cacheSignature, 'expected cacheSignature to be stable'); +} finally { + await rmDirRecursive(tempRoot); +} + +console.log('no-scm buildId ok'); diff --git a/tests/indexing/scm/no-scm-build-id.test.js b/tests/indexing/scm/no-scm-build-id.test.js deleted file mode 100644 index f0d048dda..000000000 --- a/tests/indexing/scm/no-scm-build-id.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCurrentBuildInfo, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const tempRoot = await makeTempDir('poc-scm-noscm-buildid-'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; - -try { - await fsPromises.mkdir(repoRootRaw, { recursive: true }); - await fsPromises.mkdir(cacheRoot, { recursive: true }); - const repoRoot = toRealPathSync(repoRootRaw); - - const filePath = path.join(repoRoot, 'alpha.js'); - await fsPromises.writeFile(filePath, 'export const alpha = 1;\n'); - - const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub', - PAIROFCLEATS_THREADS: '1', - PAIROFCLEATS_WORKER_POOL: 'auto' - }; - const runBuild = async () => { - const buildResult = spawnSync( - process.execPath, - [ - path.join(process.cwd(), 'build_index.js'), - '--stub-embeddings', - '--stage', - 'stage2', - '--repo', - repoRoot, - '--mode', - 'code', - '--scm-provider', - 'none' - ], - { cwd: repoRoot, env, stdio: 'inherit' } - ); - if (buildResult.status !== 0) { - console.error('no-scm buildId test failed: build_index failed'); - process.exit(buildResult.status ?? 1); - } - const userConfig = loadUserConfig(repoRoot); - const buildInfo = getCurrentBuildInfo(repoRoot, userConfig, { mode: 'code' }); - assert(buildInfo?.buildRoot, 'expected current build info'); - const buildState = JSON.parse( - await fsPromises.readFile(path.join(buildInfo.buildRoot, 'build_state.json'), 'utf8') - ); - return { - buildId: String(buildState?.buildId || ''), - cacheSignature: buildState?.signatures?.code?.cacheSignature || null, - repo: buildState?.repo || null - }; - }; - - const first = await runBuild(); - const second = await runBuild(); - - assert(first.buildId.includes('_noscm_'), 'expected buildId to include noscm marker'); - assert.equal(first.repo?.provider, 'none'); - assert.equal(first.repo?.head ?? null, null); - assert.equal(first.repo?.commit ?? null, null); - assert.equal(first.cacheSignature, second.cacheSignature, 'expected cacheSignature to be stable'); -} finally { - await rmDirRecursive(tempRoot); -} - -console.log('no-scm buildId ok'); diff --git a/tests/indexing/scm/paths-dotdot-prefix.test.js b/tests/indexing/scm/paths-dotdot-prefix.test.js new file mode 100644 index 000000000..a90316eab --- /dev/null +++ b/tests/indexing/scm/paths-dotdot-prefix.test.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + normalizeScmFileKey, + toRepoPosixPath, + toUniqueRepoPosixFiles +} from '../../../src/index/scm/paths.js'; + +const repoRoot = path.resolve('repo-root'); +const insideDotDotPrefixed = path.join(repoRoot, '..metadata', 'file.txt'); +const outsidePath = path.resolve(repoRoot, '..', 'outside', 'file.txt'); + +assert.equal(toRepoPosixPath(insideDotDotPrefixed, repoRoot), '..metadata/file.txt'); +assert.equal(toRepoPosixPath('..metadata/file.txt', repoRoot), '..metadata/file.txt'); +assert.equal(toRepoPosixPath(outsidePath, repoRoot), null); +assert.equal(normalizeScmFileKey('./src/file.js'), 'src/file.js'); +assert.equal(normalizeScmFileKey('../outside.js'), null); +assert.deepEqual( + toUniqueRepoPosixFiles(['src/a.js', './src/a.js', 'src/b.js', '../escape.js'], { repoRoot }), + ['src/a.js', 'src/b.js'], + 'expected shared SCM unique path helper to dedupe and reject escapes' +); + +console.log('scm paths dotdot-prefix test passed'); diff --git a/tests/indexing/scm/scm-provider-selection.test.js b/tests/indexing/scm/provider-selection.test.js similarity index 100% rename from tests/indexing/scm/scm-provider-selection.test.js rename to tests/indexing/scm/provider-selection.test.js diff --git a/tests/indexing/scm/scm-runner-killtree-default.test.js b/tests/indexing/scm/runner-killtree-default.test.js similarity index 100% rename from tests/indexing/scm/scm-runner-killtree-default.test.js rename to tests/indexing/scm/runner-killtree-default.test.js diff --git a/tests/indexing/scm/scm-runner-max-output-default.test.js b/tests/indexing/scm/runner-max-output-default.test.js similarity index 100% rename from tests/indexing/scm/scm-runner-max-output-default.test.js rename to tests/indexing/scm/runner-max-output-default.test.js diff --git a/tests/indexing/scm/scm-config-bench-annotate-default.test.js b/tests/indexing/scm/scm-config-bench-annotate-default.test.js deleted file mode 100644 index 5c6599a2b..000000000 --- a/tests/indexing/scm/scm-config-bench-annotate-default.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveScmConfig } from '../../../src/index/scm/registry.js'; - -const benchDefault = resolveScmConfig({ - indexingConfig: {}, - analysisPolicy: null, - benchRun: true -}); -assert.equal(benchDefault.annotate.enabled, false, 'expected bench runs to default scm annotate off'); - -const benchExplicitEnable = resolveScmConfig({ - indexingConfig: { scm: { annotate: { enabled: true } } }, - analysisPolicy: null, - benchRun: true -}); -assert.equal(benchExplicitEnable.annotate.enabled, true, 'expected explicit annotate enable to override bench default'); - -const benchPolicyEnable = resolveScmConfig({ - indexingConfig: {}, - analysisPolicy: { git: { blame: true } }, - benchRun: true -}); -assert.equal(benchPolicyEnable.annotate.enabled, true, 'expected analysis policy blame=true to override bench default'); - -const normalDefault = resolveScmConfig({ - indexingConfig: {}, - analysisPolicy: null, - benchRun: false -}); -assert.equal(normalDefault.annotate.enabled, true, 'expected non-bench runs to keep annotate enabled by default'); - -const explicitGitBlameDisable = resolveScmConfig({ - indexingConfig: { gitBlame: false }, - analysisPolicy: null, - benchRun: false -}); -assert.equal(explicitGitBlameDisable.annotate.enabled, false, 'expected explicit gitBlame=false to disable annotate'); - -console.log('scm config bench annotate default test passed'); diff --git a/tests/indexing/scm/scm-paths-dotdot-prefix.test.js b/tests/indexing/scm/scm-paths-dotdot-prefix.test.js deleted file mode 100644 index 0f2f91468..000000000 --- a/tests/indexing/scm/scm-paths-dotdot-prefix.test.js +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { toRepoPosixPath } from '../../../src/index/scm/paths.js'; - -const repoRoot = path.resolve('repo-root'); -const insideDotDotPrefixed = path.join(repoRoot, '..metadata', 'file.txt'); -const outsidePath = path.resolve(repoRoot, '..', 'outside', 'file.txt'); - -assert.equal(toRepoPosixPath(insideDotDotPrefixed, repoRoot), '..metadata/file.txt'); -assert.equal(toRepoPosixPath('..metadata/file.txt', repoRoot), '..metadata/file.txt'); -assert.equal(toRepoPosixPath(outsidePath, repoRoot), null); - -console.log('scm paths dotdot-prefix test passed'); diff --git a/tests/indexing/shards/shard-merge.test.js b/tests/indexing/shards/shard-merge.test.js index 6077ef3cb..60071293f 100644 --- a/tests/indexing/shards/shard-merge.test.js +++ b/tests/indexing/shards/shard-merge.test.js @@ -3,11 +3,11 @@ import { applyTestEnv } from '../../helpers/test-env.js'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; import { MAX_JSON_BYTES, loadChunkMeta, loadTokenPostings } from '../../../src/shared/artifact-io.js'; import { stableStringify } from '../../../src/shared/stable-json.js'; import { rmDirRecursive } from '../../helpers/temp.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -31,12 +31,28 @@ const runBuild = (cacheRoot, label, testConfig) => { const env = applyTestEnv({ cacheRoot, embeddings: 'stub', - testConfig: testConfig ?? null + testConfig: testConfig ?? null, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } }); - const result = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } + const result = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--scm-provider', + 'none', + '--repo', + repoRoot + ], + `shard merge ${label}`, + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } ); if (result.status !== 0) { console.error(`Failed: ${label}`); @@ -273,10 +289,16 @@ runBuild(cacheRootA, 'baseline build', { indexing: { fileListSampleSize: 10, shards: { enabled: false }, - treeSitter: { enabled: false } + treeSitter: { enabled: false }, + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false }, tooling: { - autoEnableOnDetect: false + autoEnableOnDetect: false, + lsp: { enabled: false } } }); const baseline = await readIndex(cacheRootA); @@ -290,10 +312,16 @@ runBuild(cacheRootB, 'sharded build', { maxWorkers: 1, minFiles: 1 }, - treeSitter: { enabled: false } + treeSitter: { enabled: false }, + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false }, tooling: { - autoEnableOnDetect: false + autoEnableOnDetect: false, + lsp: { enabled: false } } }); const sharded = await readIndex(cacheRootB); diff --git a/tests/indexing/shards/shard-progress-determinism.test.js b/tests/indexing/shards/shard-progress-determinism.test.js index 2c69db615..84e35d60d 100644 --- a/tests/indexing/shards/shard-progress-determinism.test.js +++ b/tests/indexing/shards/shard-progress-determinism.test.js @@ -1,11 +1,11 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { parseProgressEventLine } from '../../../src/shared/cli/progress-events.js'; import { getCombinedOutput } from '../../helpers/stdio.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -30,8 +30,7 @@ const env = applyTestEnv({ } }); -const result = spawnSync( - process.execPath, +const result = runNode( [ path.join(root, 'build_index.js'), '--repo', @@ -46,7 +45,10 @@ const result = spawnSync( 'jsonl', '--verbose' ], - { encoding: 'utf8', env } + 'shard progress determinism build index', + repoRoot, + env, + { encoding: 'utf8', stdio: 'pipe', allowFailure: true } ); if (result.status !== 0) { diff --git a/tests/indexing/stage1/barrier-keepalive.test.js b/tests/indexing/stage1/barrier-keepalive.test.js new file mode 100644 index 000000000..96ed5b2a6 --- /dev/null +++ b/tests/indexing/stage1/barrier-keepalive.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { waitForChildExit } from '../../helpers/process-lifecycle.js'; + +ensureTestingEnv(process.env); + +const keepaliveScript = [ + "import { awaitStage1Barrier } from './src/index/build/indexer/steps/process-files.js';", + 'await awaitStage1Barrier(new Promise(() => {}));' +].join('\n'); + +const keepaliveChild = spawn( + process.execPath, + ['--input-type=module', '-e', keepaliveScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let keepaliveStderr = ''; +keepaliveChild.stderr.on('data', (chunk) => { + keepaliveStderr += String(chunk); +}); +await new Promise((resolve) => setTimeout(resolve, 200)); +assert.equal( + keepaliveChild.exitCode, + null, + `expected stage1 barrier child to remain alive while promise is pending; stderr=${keepaliveStderr || ''}` +); +keepaliveChild.kill(); +const keepaliveExitCode = await waitForChildExit(keepaliveChild, { + timeoutMs: 5000, + forceSignal: 'SIGKILL' +}); +assert.notEqual( + keepaliveExitCode, + 13, + `expected stage1 barrier keepalive to avoid unsettled top-level await exit 13; stderr=${keepaliveStderr || ''}` +); + +const allSettledScript = [ + "import { awaitStage1Barrier } from './src/index/build/indexer/steps/process-files.js';", + 'await awaitStage1Barrier(Promise.allSettled([new Promise(() => {})]));' +].join('\n'); + +const allSettledChild = spawn( + process.execPath, + ['--input-type=module', '-e', allSettledScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); +let allSettledStderr = ''; +allSettledChild.stderr.on('data', (chunk) => { + allSettledStderr += String(chunk); +}); +await new Promise((resolve) => setTimeout(resolve, 200)); +assert.equal( + allSettledChild.exitCode, + null, + `expected Promise.allSettled stage1 barrier child to remain alive while promise is pending; stderr=${allSettledStderr || ''}` +); +allSettledChild.kill(); +const allSettledExitCode = await waitForChildExit(allSettledChild, { + timeoutMs: 5000, + forceSignal: 'SIGKILL' +}); +assert.notEqual( + allSettledExitCode, + 13, + `expected stage1 barrier Promise.allSettled keepalive to avoid unsettled top-level await exit 13; stderr=${allSettledStderr || ''}` +); + +console.log('stage1 barrier keepalive test passed'); diff --git a/tests/indexing/stage1/commit-journal-replay-idempotence.test.js b/tests/indexing/stage1/commit-journal-replay-idempotence.test.js index 2a9586904..bb77f7ff9 100644 --- a/tests/indexing/stage1/commit-journal-replay-idempotence.test.js +++ b/tests/indexing/stage1/commit-journal-replay-idempotence.test.js @@ -25,6 +25,15 @@ assert.deepEqual(replayA.committedSeqs, expectedSeqs, 'expected replay to recove assert.equal(replayA.nextCommitSeq, 4, 'expected replay cursor at terminal seq tail'); assert.deepEqual(replayB, replayA, 'expected replay idempotence under duplicate journal records'); +const sparseReplay = replayCommitJournal( + [ + { seq: 10, recordType: 'terminal', terminalOutcome: 'success' }, + { seq: 10, recordType: 'commit', terminalOutcome: 'success' } + ], + { expectedSeqs: [10, 20, 30] } +); +assert.equal(sparseReplay.nextCommitSeq, 20, 'expected sparse replay cursor to advance to next expected seq'); + assert.throws( () => replayCommitJournal( [ diff --git a/tests/indexing/stage1/duplicate-terminal-after-commit.test.js b/tests/indexing/stage1/duplicate-terminal-after-commit.test.js new file mode 100644 index 000000000..862e6c7f3 --- /dev/null +++ b/tests/indexing/stage1/duplicate-terminal-after-commit.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildOrderedAppender } from '../../../src/index/build/indexer/steps/process-files/ordered.js'; + +const withTimeout = async (promise, timeoutMs = 500) => { + let timeoutId = null; + try { + return await Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new Error(`timed out after ${timeoutMs}ms`); + err.code = 'TEST_TIMEOUT'; + reject(err); + }, timeoutMs); + }) + ]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +}; + +const appender = buildOrderedAppender(async () => {}, { + chunks: [], + fileMeta: [], + symbols: [] +}, { + expectedIndices: [0] +}); + +await withTimeout(appender.enqueue(0, { id: 0 }, null), 800); + +// Duplicate terminalization after commit should resolve immediately and never +// create a stranded completion waiter. +await withTimeout(appender.skip(0, 123), 800); + +const snapshot = appender.snapshot(); +assert.equal(snapshot.committedCount, 1, 'expected committed count to remain stable after duplicate terminalization'); +assert.equal(snapshot.pendingCount, 0, 'expected no pending completion promises'); + +console.log('stage1 duplicate terminal-after-commit test passed'); diff --git a/tests/indexing/stage1/postings-queue-byte-accounting.test.js b/tests/indexing/stage1/postings-queue-byte-accounting.test.js index f787f0b12..487fa3d2d 100644 --- a/tests/indexing/stage1/postings-queue-byte-accounting.test.js +++ b/tests/indexing/stage1/postings-queue-byte-accounting.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { estimateJsonBytes } from '../../../src/shared/cache.js'; +import { estimateJsonBytes } from '../../../src/shared/cache/size.js'; import { createPostingsQueue, estimatePostingsPayload diff --git a/tests/indexing/stage1/postings-queue-capacity-keepalive.test.js b/tests/indexing/stage1/postings-queue-capacity-keepalive.test.js new file mode 100644 index 000000000..bff8b544f --- /dev/null +++ b/tests/indexing/stage1/postings-queue-capacity-keepalive.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +ensureTestingEnv(process.env); + +const childScript = [ + "import { createPostingsQueue } from './src/index/build/indexer/steps/process-files/postings-queue.js';", + 'const queue = createPostingsQueue({ maxPending: 1 });', + 'const reservation = await queue.reserve({ rows: 1, bytes: 1 });', + 'void reservation;', + 'await queue.reserve({ rows: 1, bytes: 1 });' +].join('\n'); + +const child = spawn( + process.execPath, + ['--input-type=module', '-e', childScript], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + } +); + +let stderr = ''; +child.stderr.on('data', (chunk) => { + stderr += String(chunk); +}); + +await new Promise((resolve) => setTimeout(resolve, 200)); +assert.equal( + child.exitCode, + null, + `expected postings queue child to remain alive while blocked on capacity wait; stderr=${stderr || ''}` +); + +child.kill(); +const closeResult = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => resolve({ exitCode, signal })); +}); +assert.notEqual( + closeResult.exitCode, + 13, + `expected postings queue keepalive to avoid unsettled top-level await exit 13; stderr=${stderr || ''}` +); + +console.log('postings queue capacity keepalive test passed'); diff --git a/tests/indexing/stage1/process-files-cleanup-timeout.test.js b/tests/indexing/stage1/process-files-cleanup-timeout.test.js index 3b7c45492..e35a40314 100644 --- a/tests/indexing/stage1/process-files-cleanup-timeout.test.js +++ b/tests/indexing/stage1/process-files-cleanup-timeout.test.js @@ -5,10 +5,10 @@ import { ensureTestingEnv } from '../../helpers/test-env.js'; import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; import { getTrackedSubprocessCount, - spawnSubprocess, terminateTrackedSubprocesses, withTrackedSubprocessSignalScope -} from '../../../src/shared/subprocess.js'; +} from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; import { buildStage1FileSubprocessOwnershipId, resolveProcessCleanupTimeoutMs, diff --git a/tests/indexing/stage1/process-files-inflight-drain.test.js b/tests/indexing/stage1/process-files-inflight-drain.test.js new file mode 100644 index 000000000..093a2b897 --- /dev/null +++ b/tests/indexing/stage1/process-files-inflight-drain.test.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + createTrackedProcessFileTaskRegistry, + drainTrackedProcessFileTasks, + runStage1TailCleanupTasks +} from '../../../src/index/build/indexer/steps/process-files.js'; + +ensureTestingEnv(process.env); + +const createDeferred = () => { + let resolve = null; + let reject = null; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const timeoutLogs = []; +const timedOutRegistry = createTrackedProcessFileTaskRegistry({ + name: 'process-files-inflight-drain-timeout' +}); +const timedOutTask = createDeferred(); +timedOutRegistry.track(timedOutTask.promise, { + file: 'src/stuck.rb', + fileIndex: 42, + orderIndex: 7, + shardId: 'shard-a', + startedAtMs: Date.now() - 75 +}); +const timedOutResult = await drainTrackedProcessFileTasks({ + registry: timedOutRegistry, + timeoutMs: 25, + log: (line, meta = {}) => timeoutLogs.push({ + line: String(line), + meta + }) +}); +assert.equal(timedOutResult.timedOut, true, 'expected pending process-file drain to time out'); +assert.ok( + timeoutLogs.some((entry) => entry.line.includes('src/stuck.rb')), + 'expected timeout log to include pending file path' +); +assert.ok( + timeoutLogs.some((entry) => entry.line.includes('seq=7')), + 'expected timeout log to include pending order index' +); +assert.ok( + timeoutLogs.some((entry) => entry.line.includes('shard=shard-a')), + 'expected timeout log to include pending shard id' +); +timedOutTask.resolve(); +await timedOutRegistry.drain(); + +const sequencingRegistry = createTrackedProcessFileTaskRegistry({ + name: 'process-files-inflight-drain-sequencing' +}); +const sequencingTask = createDeferred(); +sequencingRegistry.track(sequencingTask.promise, { + file: 'src/slow.swift', + fileIndex: 3, + orderIndex: 11, + startedAtMs: Date.now() +}); +const cleanupOrder = []; +const cleanupPromise = runStage1TailCleanupTasks({ + sequential: true, + tasks: [ + { + label: 'stage1.process-file-drain', + run: async () => { + cleanupOrder.push('drain:start'); + const result = await drainTrackedProcessFileTasks({ + registry: sequencingRegistry, + timeoutMs: 1000 + }); + cleanupOrder.push('drain:end'); + return result; + } + }, + { + label: 'tree-sitter-scheduler.close', + run: async () => { + cleanupOrder.push('scheduler:close'); + return { + skipped: false, + timedOut: false, + elapsedMs: 0 + }; + } + } + ] +}); +await sleep(40); +assert.deepEqual( + cleanupOrder, + ['drain:start'], + 'expected sequential cleanup to wait for process-file drain before closing scheduler' +); +sequencingTask.resolve(); +await cleanupPromise; +assert.deepEqual( + cleanupOrder, + ['drain:start', 'drain:end', 'scheduler:close'], + 'expected scheduler close to happen only after tracked process-file work settled' +); + +const sealedRegistry = createTrackedProcessFileTaskRegistry({ + name: 'process-files-inflight-drain-sealed' +}); +sealedRegistry.seal('stage1 tail cleanup'); +assert.throws( + () => sealedRegistry.track(Promise.resolve(), { file: 'src/late.js' }), + (err) => err?.code === 'ERR_STAGE1_PROCESS_FILE_TASK_REGISTRY_SEALED', + 'expected sealed registry to reject late process-file tracking' +); + +console.log('process files inflight drain test passed'); diff --git a/tests/indexing/stage1/process-files-stall-snapshot-policy.test.js b/tests/indexing/stage1/process-files-stall-snapshot-policy.test.js index 0b447cc00..20488d133 100644 --- a/tests/indexing/stage1/process-files-stall-snapshot-policy.test.js +++ b/tests/indexing/stage1/process-files-stall-snapshot-policy.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { ensureTestingEnv } from '../../helpers/test-env.js'; -import { captureProcessSnapshot } from '../../../src/shared/subprocess.js'; +import { captureProcessSnapshot } from '../../../src/shared/subprocess/snapshot.js'; import { resolveStage1HangPolicy, resolveStage1StallAction, diff --git a/tests/indexing/stage1/seq-ledger-state-machine.test.js b/tests/indexing/stage1/seq-ledger-state-machine.test.js index 9dbed3d6d..766a4d8ad 100644 --- a/tests/indexing/stage1/seq-ledger-state-machine.test.js +++ b/tests/indexing/stage1/seq-ledger-state-machine.test.js @@ -48,5 +48,24 @@ leaseLedger.transition(50, S.IN_FLIGHT, { ownerId: 90, nowMs: 2000 }); const reclaimed = leaseLedger.reclaimExpiredLeases(2010); assert.deepEqual(reclaimed, [50], 'expected expired in-flight lease reclaim'); assert.equal(leaseLedger.getState(50), S.TERMINAL_FAIL, 'expected reclaimed seq to hard-terminalize as fail'); +assert.equal(leaseLedger.getTerminalReason(50), 910, 'expected in-flight reclaim reason code'); + +const sparseLedger = createSeqLedger({ expectedSeqs: [10, 20, 30], leaseTimeoutMs: 5 }); +sparseLedger.transition(10, S.DISPATCHED, { ownerId: 1, nowMs: 3000 }); +sparseLedger.transition(10, S.IN_FLIGHT, { ownerId: 1, nowMs: 3001 }); +sparseLedger.transition(10, S.TERMINAL_SUCCESS, { nowMs: 3002 }); +sparseLedger.transition(10, S.COMMITTED, { nowMs: 3003 }); +assert.equal(sparseLedger.snapshot().nextCommitSeq, 20, 'expected sparse cursor to skip non-expected seq holes'); + +sparseLedger.transition(20, S.DISPATCHED, { ownerId: 2, nowMs: 3004 }); +const dispatchedReclaimDisabled = sparseLedger.reclaimExpiredLeases(3012); +assert.deepEqual(dispatchedReclaimDisabled, [], 'expected dispatched reclaim disabled by default'); +const dispatchedReclaimEnabled = sparseLedger.reclaimExpiredLeases(3012, { + includeDispatched: true, + dispatchedGraceMs: 5 +}); +assert.deepEqual(dispatchedReclaimEnabled, [20], 'expected dispatched reclaim when explicitly enabled'); +assert.equal(sparseLedger.getState(20), S.TERMINAL_FAIL, 'expected reclaimed dispatched seq to hard-fail'); +assert.equal(sparseLedger.getTerminalReason(20), 911, 'expected dispatched reclaim reason code'); console.log('stage1 seq ledger state machine test passed'); diff --git a/tests/indexing/state/build-state-lightweight-main-file.test.js b/tests/indexing/state/build-lightweight-main-file.test.js similarity index 100% rename from tests/indexing/state/build-state-lightweight-main-file.test.js rename to tests/indexing/state/build-lightweight-main-file.test.js diff --git a/tests/indexing/state/build-state-contract-matrix.test.js b/tests/indexing/state/build-state-contract-matrix.test.js new file mode 100644 index 000000000..8f984777b --- /dev/null +++ b/tests/indexing/state/build-state-contract-matrix.test.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { + flushBuildState, + initBuildState, + updateBuildState +} from '../../../src/index/build/build-state.js'; +import { + applyStatePatch, + isBuildStateLockUnavailableResult +} from '../../../src/index/build/build-state/store.js'; +import { BUILD_STATE_DURABILITY_CLASS } from '../../../src/index/build/build-state/durability.js'; + +const withTempBuildRoot = async (prefix, run) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const buildRoot = path.join(tempRoot, 'build'); + await fs.mkdir(buildRoot, { recursive: true }); + try { + await run({ tempRoot, buildRoot }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +await withTempBuildRoot('poc-build-state-lock-retryable-', async ({ buildRoot }) => { + const lockPath = path.join(buildRoot, 'build_state.write.lock'); + const heldLock = await acquireFileLock({ + lockPath, + waitMs: 0, + pollMs: 1, + staleMs: 30000, + timeoutBehavior: 'null', + metadata: { scope: 'build-state-lock-unavailable-test' } + }); + assert.ok(heldLock); + try { + const result = await applyStatePatch( + buildRoot, + { + heartbeat: { + stage: 'test', + lastHeartbeatAt: new Date().toISOString() + } + }, + [], + { durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT } + ); + assert.equal(isBuildStateLockUnavailableResult(result), true); + assert.equal(result?.code, 'ERR_BUILD_STATE_LOCK_UNAVAILABLE'); + assert.equal(result?.retryable, true); + assert.equal(result?.buildState?.retryable, true); + assert.equal(result?.buildState?.reason, 'lock-unavailable'); + assert.equal(result?.buildState?.lockOwner?.pid, process.pid); + assert.equal(result?.buildState?.lockOwner?.scope, 'build-state-lock-unavailable-test'); + assert.equal(result?.lockOwner?.pid, process.pid); + } finally { + await heldLock.release({ force: true }); + } +}); + +await withTempBuildRoot('poc-build-state-lock-required-', async ({ buildRoot }) => { + const lockPath = path.join(buildRoot, 'build_state.write.lock'); + const heldLock = await acquireFileLock({ + lockPath, + waitMs: 0, + pollMs: 1, + staleMs: 30000, + timeoutBehavior: 'null', + metadata: { scope: 'build-state-lock-required-test' } + }); + assert.ok(heldLock); + try { + await assert.rejects( + applyStatePatch( + buildRoot, + { + heartbeat: { + stage: 'test', + lastHeartbeatAt: new Date().toISOString() + } + }, + [], + { durabilityClass: BUILD_STATE_DURABILITY_CLASS.REQUIRED } + ), + (error) => { + assert.equal(error?.code, 'ERR_BUILD_STATE_LOCK_UNAVAILABLE'); + assert.equal(error?.retryable, true); + assert.equal(error?.buildState?.durabilityClass, BUILD_STATE_DURABILITY_CLASS.REQUIRED); + assert.equal(error?.buildState?.lockOwner?.pid, process.pid); + assert.equal(error?.buildState?.lockOwner?.scope, 'build-state-lock-required-test'); + assert.equal(error?.lockOwner?.pid, process.pid); + assert.match(error?.message || '', /owner: pid=/); + return true; + } + ); + } finally { + await heldLock.release({ force: true }); + } +}); + +await withTempBuildRoot('poc-build-state-logs-', async ({ tempRoot, buildRoot }) => { + const eventsPath = path.join(buildRoot, 'build_state.events.jsonl'); + const deltasPath = path.join(buildRoot, 'build_state.deltas.jsonl'); + const event = { type: 'checkpoint', stage: 'stage1', ts: '2026-03-12T00:00:00.000Z' }; + const patch = { + currentPhase: 'processing', + progress: { + code: { + processed: 1, + total: 2 + } + } + }; + + await initBuildState({ + buildRoot, + buildId: 'state-log-behavior', + repoRoot: tempRoot, + modes: ['code'], + stage: 'stage1', + configHash: 'cfg', + toolVersion: 'test', + repoProvenance: { provider: 'none' }, + signatureVersion: 1 + }); + + await applyStatePatch(buildRoot, patch, [event]); + await applyStatePatch(buildRoot, patch, [event]); + + let [eventsText, deltasText] = await Promise.all([ + fs.readFile(eventsPath, 'utf8'), + fs.readFile(deltasPath, 'utf8') + ]); + assert.equal(eventsText.trim().split('\n').length, 2); + assert.ok(deltasText.trim().split('\n').length >= 2); + + await fs.rm(eventsPath, { force: true }); + await fs.rm(deltasPath, { force: true }); + await applyStatePatch(buildRoot, patch, [event]); + + [eventsText, deltasText] = await Promise.all([ + fs.readFile(eventsPath, 'utf8'), + fs.readFile(deltasPath, 'utf8') + ]); + assert.match(eventsText, /"type":"checkpoint"/); + assert.match(deltasText, /"op":"snapshot"/); + assert.match(deltasText, /"path":"\/currentPhase"/); +}); + +await withTempBuildRoot('poc-build-state-skip-write-', async ({ tempRoot, buildRoot }) => { + const statePath = path.join(buildRoot, 'build_state.json'); + const progressPath = path.join(buildRoot, 'build_state.progress.json'); + + await initBuildState({ + buildRoot, + buildId: 'state-skip-rewrite', + repoRoot: tempRoot, + modes: ['code'], + stage: 'stage1', + configHash: 'cfg', + toolVersion: 'test', + repoProvenance: { provider: 'none' }, + signatureVersion: 1 + }); + + await updateBuildState(buildRoot, { + stage: 'stage1', + progress: { + code: { + processed: 1, + total: 10 + } + } + }); + await flushBuildState(buildRoot); + + await fs.rm(statePath, { force: true }); + await fs.rm(progressPath, { force: true }); + + await updateBuildState(buildRoot, { + stage: 'stage1', + progress: { + code: { + processed: 1, + total: 10 + } + } + }); + await flushBuildState(buildRoot); + + const [stateText, progressText] = await Promise.all([ + fs.readFile(statePath, 'utf8'), + fs.readFile(progressPath, 'utf8') + ]); + const state = JSON.parse(stateText); + const progress = JSON.parse(progressText); + assert.equal(state.stage, 'stage1'); + assert.equal(progress.code.processed, 1); +}); + +console.log('indexing state build state contract matrix test passed'); diff --git a/tests/indexing/state/build-state-current-pointer-fail-open.test.js b/tests/indexing/state/build-state-current-pointer-fail-open.test.js new file mode 100644 index 000000000..3b95ce869 --- /dev/null +++ b/tests/indexing/state/build-state-current-pointer-fail-open.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { hydrateStateDefaults } from '../../../src/index/build/build-state/store.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-build-state-current-')); +const buildsRoot = path.join(tempRoot, 'builds'); +const buildRoot = path.join(buildsRoot, 'build-1'); +await fs.mkdir(buildRoot, { recursive: true }); + +const currentPath = path.join(buildsRoot, 'current.json'); +await fs.writeFile(currentPath, '{invalid-json', 'utf8'); + +const recovered = await hydrateStateDefaults({}, buildRoot); +assert.equal(recovered.buildId, 'build-1', 'expected buildId fallback from build root name'); +assert.equal(path.resolve(recovered.buildRoot), path.resolve(buildRoot), 'expected buildRoot fallback'); +assert.equal(recovered.repoRoot, null, 'malformed current pointer should not set repoRoot'); +assert.equal(recovered.repo, null, 'malformed current pointer should not set repo metadata'); + +await fs.writeFile(currentPath, JSON.stringify({ + repo: { + root: path.join(tempRoot, 'repo'), + commit: 'abc123' + } +}), 'utf8'); +const hydrated = await hydrateStateDefaults({}, buildRoot); +assert.equal( + path.resolve(hydrated.repoRoot || ''), + path.resolve(path.join(tempRoot, 'repo')), + 'expected valid current pointer repo root hydration' +); +assert.equal(hydrated.repo?.commit, 'abc123', 'expected valid current pointer repo metadata hydration'); + +console.log('build state current pointer fail-open test passed'); diff --git a/tests/indexing/state/build-state-heartbeat-nonblocking.test.js b/tests/indexing/state/build-state-heartbeat-nonblocking.test.js new file mode 100644 index 000000000..c95ec46d7 --- /dev/null +++ b/tests/indexing/state/build-state-heartbeat-nonblocking.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setProgressHandlers } from '../../../src/shared/progress-runtime.js'; +import { startHeartbeat } from '../../../src/index/build/build-state/heartbeat.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-build-state-heartbeat-nonblocking-')); +const buildRoot = path.join(tempRoot, 'build'); +await fs.mkdir(buildRoot, { recursive: true }); + +const calls = []; +const logs = []; +const restoreHandlers = setProgressHandlers({ + logLine(msg, meta) { + logs.push({ msg, meta }); + } +}); + +try { + const stop = startHeartbeat({ + buildRoot, + stage: 'stage1', + intervalMs: 10, + updateBuildStateOutcome: async (_buildRoot, _patch, options) => { + calls.push(options); + return { + status: 'flushed', + value: null, + queued: true, + pendingLagMs: 50000, + pendingSinceMs: 50000, + pendingPatchBytes: 256, + pendingWaiterCount: 0, + coalescedPatches: 3, + lastFlushDurationMs: 120 + }; + }, + flushBuildState: async () => ({ status: 'flushed', value: null }), + buildRootExists: async () => true + }); + + await sleep(25); + await stop(); + + assert.equal(calls.length >= 1, true, 'expected heartbeat to enqueue at least one update'); + assert.equal(calls[0]?.waitForFlush, false, 'expected heartbeat writes to enqueue without waiting'); + const lagLog = logs.find((entry) => entry?.meta?.buildState?.event === 'heartbeat-write-lag'); + assert.ok(lagLog, 'expected heartbeat lag warning under queued lag telemetry'); + assert.match(String(lagLog?.msg || ''), /heartbeat durability lag/i); +} finally { + restoreHandlers(); + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('build-state heartbeat nonblocking test passed'); diff --git a/tests/indexing/state/build-state-progress-nonblocking.test.js b/tests/indexing/state/build-state-progress-nonblocking.test.js new file mode 100644 index 000000000..1da4729db --- /dev/null +++ b/tests/indexing/state/build-state-progress-nonblocking.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBuildCheckpointTracker } from '../../../src/index/build/build-state/progress.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const calls = []; +const tracker = createBuildCheckpointTracker({ + buildRoot: 'C:\\tmp\\build-state-progress-nonblocking', + mode: 'code', + totalFiles: 4, + batchSize: 1, + intervalMs: 60000, + updateBuildStateOutcome: async (_buildRoot, _patch, options) => { + calls.push(options); + return options?.waitForFlush === false + ? { status: 'flushed', value: null, queued: true, pendingLagMs: 1500 } + : { status: 'flushed', value: { ok: true } }; + } +}); + +tracker.tick(); +await sleep(0); +assert.equal(calls.length >= 1, true, 'expected tick-triggered progress enqueue'); +assert.equal(calls[0]?.waitForFlush, false, 'expected non-forced progress writes to avoid flush waiting'); + +await tracker.finish(); +assert.equal(calls.length >= 2, true, 'expected finish to force a final progress flush'); +assert.equal(calls.at(-1)?.waitForFlush, true, 'expected finish to await the final flush'); + +console.log('build-state progress nonblocking test passed'); diff --git a/tests/indexing/state/checkpoint-index-path-traversal-guard.test.js b/tests/indexing/state/checkpoint-index-path-traversal-guard.test.js new file mode 100644 index 000000000..a7ed8ad6c --- /dev/null +++ b/tests/indexing/state/checkpoint-index-path-traversal-guard.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { loadCheckpointSlices } from '../../../src/index/build/build-state/checkpoints.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-checkpoint-index-guard-')); +const buildRoot = path.join(tempRoot, 'build'); +await fs.mkdir(buildRoot, { recursive: true }); + +const outsidePayloadPath = path.join(tempRoot, 'escape.json'); +await fs.writeFile( + outsidePayloadPath, + JSON.stringify({ stage1: { checkpoints: [{ stage: 'stage1', step: 'discover' }] } }), + 'utf8' +); + +const indexPath = path.join(buildRoot, 'stage_checkpoints.v1.index.json'); +await fs.writeFile(indexPath, JSON.stringify({ + version: 1, + updatedAt: new Date().toISOString(), + modes: { + code: { + path: '../escape.json', + updatedAt: new Date().toISOString() + } + } +}), 'utf8'); + +const loaded = await loadCheckpointSlices(buildRoot); +assert.equal(loaded, null, 'expected unsafe checkpoint descriptor path to be ignored'); +await fs.access(outsidePayloadPath); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('checkpoint index traversal guard test passed'); diff --git a/tests/indexing/state/checkpoint-slice-write-and-recover.test.js b/tests/indexing/state/checkpoint-slice-write-and-recover.test.js index 8c385c596..c0fddb3a1 100644 --- a/tests/indexing/state/checkpoint-slice-write-and-recover.test.js +++ b/tests/indexing/state/checkpoint-slice-write-and-recover.test.js @@ -114,6 +114,29 @@ assert.deepEqual( 'slice sidecars should reconstruct deterministic checkpoint state after reload' ); +await fs.rm(codePath, { force: true }); +await updateBuildState(buildRoot, { + stageCheckpoints: { + code: { + stage1: { + generatedAt: new Date(1700000000000).toISOString(), + checkpoints: [{ stage: 'stage1', step: 'discover', elapsedMs: 1 }] + }, + stage2: { + generatedAt: new Date(1700000002000).toISOString(), + checkpoints: [{ stage: 'stage2', step: 'write', elapsedMs: 3 }] + } + } + } +}); +await flushBuildState(buildRoot); +const restoredCode = JSON.parse(await fs.readFile(codePath, 'utf8')); +assert.deepEqual( + restoredCode, + recovered.code, + 'identical checkpoint updates should rewrite a missing mode sidecar when the warm cache is still populated' +); + await updateBuildState(buildRoot, { stageCheckpoints: { prose: null diff --git a/tests/indexing/state/patch-queue-contract-matrix.test.js b/tests/indexing/state/patch-queue-contract-matrix.test.js new file mode 100644 index 000000000..ee6d1126a --- /dev/null +++ b/tests/indexing/state/patch-queue-contract-matrix.test.js @@ -0,0 +1,319 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createPatchQueue, PATCH_QUEUE_WAIT_STATUS } from '../../../src/index/build/build-state/patch-queue.js'; +import { BUILD_STATE_DURABILITY_CLASS } from '../../../src/index/build/build-state/durability.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const withTempBuildRoot = async (prefix, run) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const buildRoot = path.join(tempRoot, 'build'); + await fs.mkdir(buildRoot, { recursive: true }); + try { + await run(buildRoot); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +await withTempBuildRoot('poc-patch-queue-best-effort-', async (buildRoot) => { + let applyCount = 0; + const applied = []; + const observedErrors = []; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch, events, context) => { + applyCount += 1; + if (applyCount <= 2) { + const err = new Error('synthetic lock unavailable'); + err.code = 'ERR_BUILD_STATE_LOCK_UNAVAILABLE'; + err.retryable = true; + err.buildState = { retryable: true, reason: 'lock-unavailable' }; + throw err; + } + applied.push({ + patch, + events, + durabilityClass: context?.durabilityClass || null + }); + return { ok: true }; + }, + recordStateError: (_buildRoot, err) => { + observedErrors.push(err?.code || err?.message || String(err)); + }, + waiterTimeoutMs: 1000 + }); + + const firstOutcome = await queue.queueStatePatch( + buildRoot, + { first: true }, + [{ type: 'first' }], + { + flushNow: true, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT + } + ); + assert.equal(firstOutcome?.status, PATCH_QUEUE_WAIT_STATUS.TIMED_OUT); + + const deferredFlushOutcome = await queue.flushBuildState(buildRoot); + assert.equal(deferredFlushOutcome?.status, PATCH_QUEUE_WAIT_STATUS.TIMED_OUT); + + const secondOutcome = await queue.queueStatePatch( + buildRoot, + { second: true }, + [{ type: 'second' }], + { + flushNow: true, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT + } + ); + assert.equal(secondOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + + await queue.flushBuildState(buildRoot); + assert.equal(applyCount, 3); + assert.equal(applied.length, 1); + assert.deepEqual(applied[0]?.patch, { first: true, second: true }); + assert.deepEqual((applied[0]?.events || []).map((event) => event?.type), ['first', 'second']); + assert.equal(applied[0]?.durabilityClass, BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT); + assert.deepEqual(observedErrors, []); +}); + +await withTempBuildRoot('poc-patch-queue-mixed-', async (buildRoot) => { + let applyCount = 0; + const observedErrors = []; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch, events, context) => { + applyCount += 1; + if (applyCount === 1) { + const err = new Error('synthetic lock unavailable'); + err.code = 'ERR_BUILD_STATE_LOCK_UNAVAILABLE'; + err.retryable = true; + err.buildState = { + retryable: true, + reason: 'lock-unavailable', + durabilityClass: context?.durabilityClass || null + }; + throw err; + } + return { patch, events, durabilityClass: context?.durabilityClass || null }; + }, + recordStateError: (_buildRoot, error) => { + observedErrors.push(error?.code || error?.message || String(error)); + } + }); + + const bestEffortWait = queue.queueStatePatch( + buildRoot, + { bestEffort: true }, + [{ type: 'best-effort' }], + { + flushNow: false, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT + } + ); + const requiredWait = queue.queueStatePatch( + buildRoot, + { required: true }, + [{ type: 'required' }], + { + flushNow: true, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.REQUIRED + } + ).then(() => null, (error) => error); + + const [bestEffortOutcome, requiredError] = await Promise.all([bestEffortWait, requiredWait]); + assert.equal(bestEffortOutcome?.status, PATCH_QUEUE_WAIT_STATUS.TIMED_OUT); + assert.equal(requiredError?.code, 'ERR_BUILD_STATE_LOCK_UNAVAILABLE'); + assert.deepEqual(observedErrors, []); + const flushOutcome = await queue.flushBuildState(buildRoot); + assert.equal(flushOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + assert.equal(applyCount, 2); +}); + +await withTempBuildRoot('poc-patch-queue-owner-', async (buildRoot) => { + const originalWrite = process.stderr.write.bind(process.stderr); + const observedLogs = []; + process.stderr.write = ((chunk, encoding, callback) => { + observedLogs.push(String(chunk)); + if (typeof callback === 'function') callback(); + return true; + }); + + let applyCount = 0; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async () => { + applyCount += 1; + if (applyCount === 1) { + return { + ok: false, + deferred: true, + retryable: true, + code: 'ERR_BUILD_STATE_LOCK_UNAVAILABLE', + lockOwner: { + pid: 4242, + lockId: 'holder-123', + scope: 'build-state-write', + startedAt: '2026-03-10T00:00:00.000Z' + }, + buildState: { + retryable: true, + reason: 'lock-unavailable', + durabilityClass: 'best_effort', + lockOwner: { + pid: 4242, + lockId: 'holder-123', + scope: 'build-state-write', + startedAt: '2026-03-10T00:00:00.000Z' + } + } + }; + } + return { ok: true }; + }, + recordStateError: () => {} + }); + + try { + const outcome = await queue.queueStatePatch( + buildRoot, + { heartbeat: true }, + [{ type: 'heartbeat' }], + { + flushNow: true, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT + } + ); + assert.equal(outcome?.status, PATCH_QUEUE_WAIT_STATUS.TIMED_OUT); + assert.match( + observedLogs.join('\n'), + /owner: pid=4242, lockId=holder-123, scope=build-state-write, startedAt=2026-03-10T00:00:00.000Z/ + ); + } finally { + process.stderr.write = originalWrite; + } +}); + +await withTempBuildRoot('poc-patch-queue-forwarding-', async (buildRoot) => { + const observed = []; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch, _events, context) => { + observed.push({ + patch, + durabilityClass: context?.durabilityClass || null + }); + return { ok: true, patch }; + }, + recordStateError: () => {}, + waiterTimeoutMs: 1000 + }); + + const bestEffortOutcome = await queue.queueStatePatch( + buildRoot, + { bestEffort: true }, + [], + { + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT, + flushNow: true + } + ); + assert.equal(bestEffortOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + assert.equal(observed[0]?.durabilityClass, BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT); + + const escalated = []; + const queueB = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch, _events, context) => { + escalated.push({ + patch, + durabilityClass: context?.durabilityClass || null + }); + return { ok: true, patch }; + }, + recordStateError: () => {}, + waiterTimeoutMs: 1000 + }); + const [firstOutcome, secondOutcome] = await Promise.all([ + queueB.queueStatePatch(buildRoot, { first: true }, [], { + durabilityClass: BUILD_STATE_DURABILITY_CLASS.BEST_EFFORT, + flushNow: false + }), + queueB.queueStatePatch(buildRoot, { second: true }, [], { + durabilityClass: BUILD_STATE_DURABILITY_CLASS.REQUIRED, + flushNow: false + }) + ]); + assert.equal(firstOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + assert.equal(secondOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + assert.deepEqual(escalated[0]?.patch, { first: true, second: true }); + assert.equal(escalated[0]?.durabilityClass, BUILD_STATE_DURABILITY_CLASS.REQUIRED); +}); + +await withTempBuildRoot('poc-patch-queue-required-', async (buildRoot) => { + let applyCount = 0; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch) => { + applyCount += 1; + await sleep(60); + return { ok: true, patch }; + }, + recordStateError: () => {}, + waiterTimeoutMs: 15 + }); + + const startedAtMs = Date.now(); + const outcome = await queue.queueStatePatch( + buildRoot, + { requiredWrite: true }, + [], + { + flushNow: true, + durabilityClass: BUILD_STATE_DURABILITY_CLASS.REQUIRED + } + ); + const elapsedMs = Date.now() - startedAtMs; + assert.equal(outcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED); + assert.deepEqual(outcome?.value?.patch, { requiredWrite: true }); + assert.equal(applyCount, 1); + assert.ok(elapsedMs >= 40); +}); + +await withTempBuildRoot('poc-patch-queue-retry-', async (buildRoot) => { + let applyCount = 0; + const applied = []; + const observedErrors = []; + const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch, events) => { + applyCount += 1; + if (applyCount === 1) { + throw new Error('synthetic apply failure'); + } + applied.push({ patch, events }); + return { ok: true }; + }, + recordStateError: (_buildRoot, err) => { + observedErrors.push(err?.message || String(err)); + } + }); + + await assert.rejects( + queue.queueStatePatch(buildRoot, { first: true }, [{ type: 'first' }], { flushNow: true }), + /synthetic apply failure/ + ); + await queue.queueStatePatch(buildRoot, { second: true }, [{ type: 'second' }], { flushNow: true }); + await queue.flushBuildState(buildRoot); + assert.equal(observedErrors.length, 1); + assert.equal(applied.length, 1); + assert.deepEqual(applied[0].patch, { first: true, second: true }); + assert.deepEqual(applied[0].events.map((event) => event.type), ['first', 'second']); +}); + +console.log('indexing state patch queue contract matrix test passed'); diff --git a/tests/indexing/state/patch-queue-no-wait-telemetry.test.js b/tests/indexing/state/patch-queue-no-wait-telemetry.test.js new file mode 100644 index 000000000..7bd2a9466 --- /dev/null +++ b/tests/indexing/state/patch-queue-no-wait-telemetry.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createPatchQueue, PATCH_QUEUE_WAIT_STATUS } from '../../../src/index/build/build-state/patch-queue.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-patch-queue-nowait-')); +const buildRoot = path.join(tempRoot, 'build'); +await fs.mkdir(buildRoot, { recursive: true }); + +let applyCount = 0; +let releaseApply = null; +const applyBlocked = new Promise((resolve) => { + releaseApply = resolve; +}); + +const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch) => { + applyCount += 1; + await applyBlocked; + return { ok: true, patch }; + }, + recordStateError: () => {}, + waiterTimeoutMs: 25 +}); + +const first = await queue.queueStatePatch( + buildRoot, + { heartbeat: { stage: 'stage1', lastHeartbeatAt: '2026-03-21T00:00:00.000Z' } }, + [], + { waitForFlush: false } +); +assert.equal(first?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED, 'expected no-wait enqueue outcome'); +assert.equal(first?.queued, true, 'expected no-wait enqueue to report queued telemetry'); +assert.equal(first?.pendingWaiterCount, 0, 'expected no waiter allocation for no-wait heartbeat'); +assert.equal(first?.coalescedPatches, 0, 'expected first enqueue to start without coalescing'); + +const second = await queue.queueStatePatch( + buildRoot, + { heartbeat: { stage: 'stage1', lastHeartbeatAt: '2026-03-21T00:00:05.000Z' } }, + [], + { waitForFlush: false } +); +assert.equal(second?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED, 'expected second no-wait enqueue outcome'); +assert.equal(second?.queued, true, 'expected second no-wait enqueue to report queued telemetry'); +assert.equal(second?.pendingWaiterCount, 0, 'expected no waiter allocation after coalescing'); +assert.equal(second?.coalescedPatches >= 1, true, 'expected second enqueue to coalesce over pending patch'); +assert.equal(second?.pendingPatchBytes > 0, true, 'expected pending patch byte estimate'); + +releaseApply(); + +const flushed = await queue.flushBuildState(buildRoot); +assert.equal(flushed?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED, 'expected explicit flush to complete'); +assert.equal(applyCount, 1, 'expected coalesced no-wait updates to flush once'); +await sleep(20); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('patch queue no-wait telemetry test passed'); diff --git a/tests/indexing/state/patch-queue-wait-timeout-outcome.test.js b/tests/indexing/state/patch-queue-wait-timeout-outcome.test.js new file mode 100644 index 000000000..d4915c27a --- /dev/null +++ b/tests/indexing/state/patch-queue-wait-timeout-outcome.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createPatchQueue, + PATCH_QUEUE_WAIT_STATUS +} from '../../../src/index/build/build-state/patch-queue.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-patch-queue-timeout-')); +const buildRoot = path.join(tempRoot, 'build'); +await fs.mkdir(buildRoot, { recursive: true }); + +let applyCount = 0; +let resolveApply = null; +const applyDone = new Promise((resolve) => { + resolveApply = resolve; +}); + +const queue = createPatchQueue({ + mergeState: (base, patch) => ({ ...(base || {}), ...(patch || {}) }), + applyStatePatch: async (_root, patch) => { + applyCount += 1; + await sleep(60); + resolveApply({ patch }); + return { ok: true, patch }; + }, + recordStateError: () => {}, + waiterTimeoutMs: 15 +}); + +const outcome = await queue.queueStatePatch(buildRoot, { slow: true }, [], { flushNow: true }); +assert.equal(outcome?.status, PATCH_QUEUE_WAIT_STATUS.TIMED_OUT, 'expected waiter timeout outcome'); +assert.equal(outcome?.value, null, 'timed out outcome should not include flushed value'); +assert.equal(outcome?.timeoutMs, 15, 'timed out outcome should include configured timeout'); +assert.ok(Number.isFinite(outcome?.elapsedMs), 'timed out outcome should include elapsed duration'); + +const applied = await applyDone; +assert.equal(applyCount, 1, 'flush should complete once in the background after timeout'); +assert.deepEqual(applied?.patch, { slow: true }, 'background flush should apply queued patch'); + +const flushOutcome = await queue.flushBuildState(buildRoot); +assert.equal(flushOutcome?.status, PATCH_QUEUE_WAIT_STATUS.FLUSHED, 'explicit flush should resolve as flushed'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('patch queue wait timeout outcome test passed'); diff --git a/tests/indexing/tokenization/tokenization-buffering.test.js b/tests/indexing/tokenization/buffering.test.js similarity index 100% rename from tests/indexing/tokenization/tokenization-buffering.test.js rename to tests/indexing/tokenization/buffering.test.js diff --git a/tests/indexing/tokenization/tokenization-file-stream-reuse.test.js b/tests/indexing/tokenization/file-stream-reuse.test.js similarity index 100% rename from tests/indexing/tokenization/tokenization-file-stream-reuse.test.js rename to tests/indexing/tokenization/file-stream-reuse.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-adaptive-budget.test.js b/tests/indexing/tree-sitter/adaptive-budget.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-adaptive-budget.test.js rename to tests/indexing/tree-sitter/adaptive-budget.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-batch-by-language.test.js b/tests/indexing/tree-sitter/batch-by-language.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-batch-by-language.test.js rename to tests/indexing/tree-sitter/batch-by-language.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-calibration-baseline.test.js b/tests/indexing/tree-sitter/calibration-baseline.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-calibration-baseline.test.js rename to tests/indexing/tree-sitter/calibration-baseline.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-chunk-cache-persistent.test.js b/tests/indexing/tree-sitter/chunk-cache-persistent.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-chunk-cache-persistent.test.js rename to tests/indexing/tree-sitter/chunk-cache-persistent.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-chunk-cache-reuse.test.js b/tests/indexing/tree-sitter/chunk-cache-reuse.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-chunk-cache-reuse.test.js rename to tests/indexing/tree-sitter/chunk-cache-reuse.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-chunks.test.js b/tests/indexing/tree-sitter/chunks.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-chunks.test.js rename to tests/indexing/tree-sitter/chunks.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-config-coverage.test.js b/tests/indexing/tree-sitter/config-coverage.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-config-coverage.test.js rename to tests/indexing/tree-sitter/config-coverage.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-eviction-determinism.test.js b/tests/indexing/tree-sitter/eviction-determinism.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-eviction-determinism.test.js rename to tests/indexing/tree-sitter/eviction-determinism.test.js diff --git a/tests/indexing/tree-sitter/js-maxbytes.test.js b/tests/indexing/tree-sitter/js-maxbytes.test.js new file mode 100644 index 000000000..60728252c --- /dev/null +++ b/tests/indexing/tree-sitter/js-maxbytes.test.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { LANGUAGE_CAPS_BASELINES } from '../../../src/index/build/runtime/caps-calibration.js'; +import { resolveFileCapsAndGuardrails } from '../../../src/index/build/runtime/caps.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'js-tree-sitter-maxbytes'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const repoRoot = toRealPathSync(repoRootRaw); +const srcDir = path.join(repoRoot, 'src'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(srcDir, { recursive: true }); + +const expectedConfig = loadUserConfig(repoRoot); +const expectedCaps = resolveFileCapsAndGuardrails(expectedConfig?.indexing || {}); +const maxBytes = Number(expectedCaps?.fileCaps?.byLanguage?.javascript?.maxBytes) + || Number(LANGUAGE_CAPS_BASELINES.javascript?.maxBytes); +if (!Number.isFinite(maxBytes) || maxBytes <= 0) { + console.error('JS tree-sitter maxBytes test setup failed: unresolved calibrated maxBytes.'); + process.exit(1); +} + +const payload = 'a'.repeat(maxBytes + 64); +const bigFilePath = path.join(srcDir, 'big.js'); +await fsPromises.writeFile(bigFilePath, `const data = "${payload}";\n`); + +const stats = await fsPromises.stat(bigFilePath); +if (stats.size <= maxBytes) { + console.error('JS tree-sitter maxBytes test setup failed: file too small.'); + process.exit(1); +} + +const env = applyTestEnv({ + cacheRoot: path.join(tempRoot, 'cache'), + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +const result = runNode( + [path.join(root, 'build_index.js'), '--repo', repoRoot, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code'], + 'js tree-sitter maxBytes build', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (result.status !== 0) { + const stderr = String(result.stderr || ''); + if (/better-sqlite3/i.test(stderr) && /Could not locate the bindings file/i.test(stderr)) { + console.log('better-sqlite3 bindings unavailable; skipping js tree-sitter maxBytes test.'); + process.exit(0); + } + console.error('JS tree-sitter maxBytes test failed: build_index error.'); + if (stderr) console.error(stderr.trim()); + process.exit(result.status ?? 1); +} + +const userConfig = loadUserConfig(repoRoot); +const codeDir = getIndexDir(repoRoot, 'code', userConfig); +const fileListsPath = path.join(codeDir, '.filelists.json'); +if (!fs.existsSync(fileListsPath)) { + console.error('JS tree-sitter maxBytes test failed: missing .filelists.json.'); + process.exit(1); +} +const fileLists = JSON.parse(fs.readFileSync(fileListsPath, 'utf8')); +const skipped = Array.isArray(fileLists?.skipped?.sample) ? fileLists.skipped.sample : []; +const bigFileCanonical = toRealPathSync(bigFilePath); +const skippedEntry = skipped.find((entry) => { + if (!entry?.file || typeof entry.file !== 'string') return false; + try { + return toRealPathSync(entry.file) === bigFileCanonical; + } catch { + return path.resolve(entry.file).toLowerCase() === path.resolve(bigFilePath).toLowerCase(); + } +}); +if (!skippedEntry) { + console.error('JS tree-sitter maxBytes test failed: expected skip entry missing.'); + process.exit(1); +} +if (skippedEntry.reason !== 'oversize') { + console.error(`JS tree-sitter maxBytes test failed: unexpected reason ${skippedEntry.reason}.`); + process.exit(1); +} +if (skippedEntry.maxBytes !== maxBytes) { + console.error('JS tree-sitter maxBytes test failed: maxBytes mismatch.'); + process.exit(1); +} + +console.log('js tree-sitter maxBytes test passed'); + + diff --git a/tests/indexing/tree-sitter/js-tree-sitter-maxbytes.test.js b/tests/indexing/tree-sitter/js-tree-sitter-maxbytes.test.js deleted file mode 100644 index 924e69fed..000000000 --- a/tests/indexing/tree-sitter/js-tree-sitter-maxbytes.test.js +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_CAPS_BASELINES } from '../../../src/index/build/runtime/caps-calibration.js'; -import { resolveFileCapsAndGuardrails } from '../../../src/index/build/runtime/caps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'js-tree-sitter-maxbytes'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const repoRoot = toRealPathSync(repoRootRaw); -const srcDir = path.join(repoRoot, 'src'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(srcDir, { recursive: true }); - -const expectedConfig = loadUserConfig(repoRoot); -const expectedCaps = resolveFileCapsAndGuardrails(expectedConfig?.indexing || {}); -const maxBytes = Number(expectedCaps?.fileCaps?.byLanguage?.javascript?.maxBytes) - || Number(LANGUAGE_CAPS_BASELINES.javascript?.maxBytes); -if (!Number.isFinite(maxBytes) || maxBytes <= 0) { - console.error('JS tree-sitter maxBytes test setup failed: unresolved calibrated maxBytes.'); - process.exit(1); -} - -const payload = 'a'.repeat(maxBytes + 64); -const bigFilePath = path.join(srcDir, 'big.js'); -await fsPromises.writeFile(bigFilePath, `const data = "${payload}";\n`); - -const stats = await fsPromises.stat(bigFilePath); -if (stats.size <= maxBytes) { - console.error('JS tree-sitter maxBytes test setup failed: file too small.'); - process.exit(1); -} - -const env = applyTestEnv({ - cacheRoot: path.join(tempRoot, 'cache'), - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - }, - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const result = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--repo', repoRoot, '--stub-embeddings'], - { env, encoding: 'utf8' } -); -if (result.status !== 0) { - const stderr = String(result.stderr || ''); - if (/better-sqlite3/i.test(stderr) && /Could not locate the bindings file/i.test(stderr)) { - console.log('better-sqlite3 bindings unavailable; skipping js tree-sitter maxBytes test.'); - process.exit(0); - } - console.error('JS tree-sitter maxBytes test failed: build_index error.'); - if (stderr) console.error(stderr.trim()); - process.exit(result.status ?? 1); -} - -const userConfig = loadUserConfig(repoRoot); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -const fileListsPath = path.join(codeDir, '.filelists.json'); -if (!fs.existsSync(fileListsPath)) { - console.error('JS tree-sitter maxBytes test failed: missing .filelists.json.'); - process.exit(1); -} -const fileLists = JSON.parse(fs.readFileSync(fileListsPath, 'utf8')); -const skipped = Array.isArray(fileLists?.skipped?.sample) ? fileLists.skipped.sample : []; -const bigFileCanonical = toRealPathSync(bigFilePath); -const skippedEntry = skipped.find((entry) => { - if (!entry?.file || typeof entry.file !== 'string') return false; - try { - return toRealPathSync(entry.file) === bigFileCanonical; - } catch { - return path.resolve(entry.file).toLowerCase() === path.resolve(bigFilePath).toLowerCase(); - } -}); -if (!skippedEntry) { - console.error('JS tree-sitter maxBytes test failed: expected skip entry missing.'); - process.exit(1); -} -if (skippedEntry.reason !== 'oversize') { - console.error(`JS tree-sitter maxBytes test failed: unexpected reason ${skippedEntry.reason}.`); - process.exit(1); -} -if (skippedEntry.maxBytes !== maxBytes) { - console.error('JS tree-sitter maxBytes test failed: maxBytes mismatch.'); - process.exit(1); -} - -console.log('js tree-sitter maxBytes test passed'); - - diff --git a/tests/indexing/tree-sitter/load-bench-contract.test.js b/tests/indexing/tree-sitter/load-bench-contract.test.js new file mode 100644 index 000000000..f2b3bdd2a --- /dev/null +++ b/tests/indexing/tree-sitter/load-bench-contract.test.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const testEnv = applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const benchScript = path.join(root, 'tools', 'bench', 'index', 'tree-sitter-load.js'); + +const runBenchPayload = () => { + const result = runNode( + [ + benchScript, + '--languages', + 'javascript,go,rust', + '--files-per-language', + '10', + '--repeats', + '1', + '--json' + ], + 'tree-sitter load bench payload', + root, + testEnv, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); + } + return JSON.parse(String(result.stdout || '{}')); +}; + +const payload = runBenchPayload(); +const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : []; +assert.equal(scenarios.length, 4, 'expected 4 scenarios'); + +if (scenarios.every((scenario) => scenario && scenario.skipped)) { + console.log('tree-sitter runtime unavailable; skipping tree-sitter-load bench contract.'); + process.exit(0); +} + +const findScenario = ({ cacheMode, policy }) => ( + scenarios.find((scenario) => ( + scenario + && scenario.cacheMode === cacheMode + && scenario.policy === policy + )) || null +); + +const cold = findScenario({ cacheMode: 'cold', policy: 'file-order' }); +const warm = findScenario({ cacheMode: 'warm', policy: 'file-order' }); +assert.ok(cold && !cold.skipped, 'expected cold warm/cold scenario'); +assert.ok(warm && !warm.skipped, 'expected warm warm/cold scenario'); + +assert.ok(Number(cold.treeSitter?.grammarLoads) > 0, 'expected cold run to load grammars'); +assert.equal(Number(warm.treeSitter?.grammarLoads), 0, 'expected warm run to avoid grammar loads'); +assert.ok( + Number.isFinite(Number(cold.totalMs)) && Number.isFinite(Number(warm.totalMs)), + 'expected warm/cold scenarios to report totalMs' +); +const coldMs = Number(cold.totalMs); +const warmMs = Number(warm.totalMs); +const regressionThreshold = 1.35; +let warmWithinThreshold = warmMs <= (coldMs * regressionThreshold); +let retrySample = null; +if (!warmWithinThreshold) { + retrySample = runBenchPayload(); + const retryScenarios = Array.isArray(retrySample.scenarios) ? retrySample.scenarios : []; + const retryCold = retryScenarios.find((scenario) => ( + scenario + && scenario.cacheMode === 'cold' + && scenario.policy === 'file-order' + )) || null; + const retryWarm = retryScenarios.find((scenario) => ( + scenario + && scenario.cacheMode === 'warm' + && scenario.policy === 'file-order' + )) || null; + if (retryCold && retryWarm && !retryCold.skipped && !retryWarm.skipped) { + warmWithinThreshold = Number(retryWarm.totalMs) <= (Number(retryCold.totalMs) * regressionThreshold); + retrySample = { + coldMs: Number(retryCold.totalMs), + warmMs: Number(retryWarm.totalMs) + }; + } +} +assert.ok( + warmWithinThreshold, + retrySample + ? `expected warm run to avoid major regression vs cold (sample1 warmMs=${warmMs} coldMs=${coldMs}; sample2 warmMs=${retrySample.warmMs} coldMs=${retrySample.coldMs})` + : `expected warm run to avoid major regression vs cold (warmMs=${warmMs} coldMs=${coldMs})` +); + +const fileOrderCold = findScenario({ cacheMode: 'cold', policy: 'file-order' }); +const batchCold = findScenario({ cacheMode: 'cold', policy: 'batch-by-language' }); +assert.ok(fileOrderCold && !fileOrderCold.skipped, 'expected cold file-order scenario'); +assert.ok(batchCold && !batchCold.skipped, 'expected cold batch-by-language scenario'); + +assert.ok( + Number(fileOrderCold.treeSitter?.parserActivations) >= Number(batchCold.treeSitter?.parserActivations), + 'expected batch-by-language to avoid extra parser language switching' +); + +console.log('tree-sitter load bench contract test passed'); + diff --git a/tests/indexing/tree-sitter/tree-sitter-memory-plateau.test.js b/tests/indexing/tree-sitter/memory-plateau.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-memory-plateau.test.js rename to tests/indexing/tree-sitter/memory-plateau.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-parse-determinism.test.js b/tests/indexing/tree-sitter/parse-determinism.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-parse-determinism.test.js rename to tests/indexing/tree-sitter/parse-determinism.test.js diff --git a/tests/indexing/tree-sitter/perl-native-reset-regression.test.js b/tests/indexing/tree-sitter/perl-native-reset-regression.test.js index 82e96c2b9..56654c7f5 100644 --- a/tests/indexing/tree-sitter/perl-native-reset-regression.test.js +++ b/tests/indexing/tree-sitter/perl-native-reset-regression.test.js @@ -1,10 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv({ testing: '1' }); +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const fixtureDir = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'perl-reset-regression'); @@ -43,11 +41,13 @@ for (const filePath of files) { console.log('perl-native-reset-ok'); `; -const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { - cwd: root, - env: { ...process.env, PAIROFCLEATS_TESTING: '1' }, - encoding: 'utf8' -}); +const result = runNode( + ['--input-type=module', '-e', script], + 'perl native tree-sitter reset regression', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe' } +); assert.equal( result.status, diff --git a/tests/indexing/tree-sitter/tree-sitter-plan-stale-file-resilience.test.js b/tests/indexing/tree-sitter/plan-stale-file-resilience.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-plan-stale-file-resilience.test.js rename to tests/indexing/tree-sitter/plan-stale-file-resilience.test.js diff --git a/tests/indexing/tree-sitter/preload-and-planning-contract-matrix.test.js b/tests/indexing/tree-sitter/preload-and-planning-contract-matrix.test.js new file mode 100644 index 000000000..d606fe2f6 --- /dev/null +++ b/tests/indexing/tree-sitter/preload-and-planning-contract-matrix.test.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildTreeSitterChunks, + getTreeSitterCacheSnapshot, + getTreeSitterStats, + initTreeSitterRuntime, + preloadTreeSitterLanguages +} from '../../../src/lang/tree-sitter.js'; +import { pruneTreeSitterLanguages, resetTreeSitterStats } from '../../../src/lang/tree-sitter.js'; +import { treeSitterState } from '../../../src/lang/tree-sitter/state.js'; +import { LANGUAGE_GRAMMAR_KEYS } from '../../../src/lang/tree-sitter/config.js'; +import { + preflightNativeTreeSitterGrammars, + warmupNativeTreeSitterParsers +} from '../../../src/lang/tree-sitter/native-runtime.js'; +import { resolveTreeSitterPreloadPlan } from '../../../src/index/build/indexer/steps/process-files/tree-sitter.js'; +import { resolveTreeSitterRuntime } from '../../../src/index/build/runtime/tree-sitter.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv({ testing: '1' }); + +const resetCaches = () => { + treeSitterState.languageCache?.clear?.(); + treeSitterState.grammarCache?.clear?.(); + treeSitterState.languageLoadPromises?.clear?.(); + treeSitterState.queryCache?.clear?.(); + treeSitterState.loggedQueryFailures?.clear?.(); + treeSitterState.sharedParser = null; + treeSitterState.sharedParserLanguageId = null; +}; + +const ok = await initTreeSitterRuntime({ log: () => {} }); +if (!ok) { + console.log('tree-sitter runtime unavailable; skipping preload/planning matrix test.'); + process.exit(0); +} + +resetCaches(); + +await preloadTreeSitterLanguages(['javascript', 'python'], { + skipDispose: true +}); +{ + const snapshot = getTreeSitterCacheSnapshot(); + assert.ok(snapshot.loadedLanguages.includes('javascript')); + assert.ok(snapshot.loadedLanguages.includes('python')); + assert.ok(snapshot.loadedLanguages.length >= 2); +} + +{ + const defaults = resolveTreeSitterRuntime({}); + assert.equal(defaults.treeSitterScheduler.transport, 'disk'); + assert.equal(defaults.treeSitterScheduler.sharedCache, false); + assert.equal(defaults.treeSitterScheduler.closeTimeoutMs, null); + assert.equal(defaults.treeSitterScheduler.closeForceAfterMs, null); + + const shmConfig = resolveTreeSitterRuntime({ + treeSitter: { + scheduler: { + transport: 'shm', + sharedCache: true, + lookup: { + maxOpenReaders: 12, + closeTimeoutMs: 7000, + closeForceAfterMs: 1500 + } + } + } + }); + assert.equal(shmConfig.treeSitterScheduler.transport, 'shm'); + assert.equal(shmConfig.treeSitterScheduler.sharedCache, true); + assert.equal(shmConfig.treeSitterScheduler.maxOpenReaders, 12); + assert.equal(shmConfig.treeSitterScheduler.closeTimeoutMs, 7000); + assert.equal(shmConfig.treeSitterScheduler.closeForceAfterMs, 1500); + assert.equal(shmConfig.treeSitterScheduler.lookup.maxOpenReaders, 12); + assert.equal(shmConfig.treeSitterScheduler.lookup.closeTimeoutMs, 7000); + assert.equal(shmConfig.treeSitterScheduler.lookup.closeForceAfterMs, 1500); + + const invalidTransport = resolveTreeSitterRuntime({ + treeSitter: { + scheduler: { + transport: 'invalid' + } + } + }); + assert.equal(invalidTransport.treeSitterScheduler.transport, 'disk'); + assert.equal(invalidTransport.treeSitterScheduler.closeTimeoutMs, null); +} + +{ + const entries = [ + { treeSitterBatchLanguages: ['javascript', 'html'] }, + { treeSitterBatchLanguages: ['javascript'] }, + { treeSitterBatchLanguages: ['python'] }, + { treeSitterBatchLanguages: ['html'] } + ]; + const plan = resolveTreeSitterPreloadPlan(entries); + assert.deepStrictEqual(plan.languages, ['html', 'javascript', 'python']); +} + +{ + const malformedPreflight = preflightNativeTreeSitterGrammars({ javascript: true }); + assert.equal(malformedPreflight.ok, true); + assert.deepEqual(malformedPreflight.missing, []); + assert.deepEqual(malformedPreflight.unavailable, []); + const malformedWarmup = warmupNativeTreeSitterParsers({ javascript: true }); + assert.deepEqual(malformedWarmup, { warmed: [], failed: [] }); +} + +{ + const options = { + treeSitter: { + enabled: true, + useQueries: true + }, + log: () => {} + }; + + const text = 'export class Widget { greet() {} }'; + const first = buildTreeSitterChunks({ text, languageId: 'javascript', options }); + if (!Array.isArray(first) || !first.length) { + console.log('tree-sitter chunking unavailable; skipping query cache assertions.'); + } else { + assert.ok(treeSitterState.queryCache.has('javascript')); + const firstQuery = treeSitterState.queryCache.get('javascript'); + const second = buildTreeSitterChunks({ text, languageId: 'javascript', options }); + assert.ok(second && second.length); + assert.strictEqual(treeSitterState.queryCache.get('javascript'), firstQuery); + } +} + +{ + resetTreeSitterStats(); + treeSitterState.disabledLanguages = new Set(['javascript']); + const options = { + treeSitter: { enabled: true, useQueries: false }, + log: () => {} + }; + const text = 'function demo() { return 1; }'; + const before = getTreeSitterStats(); + const result = buildTreeSitterChunks({ text, languageId: 'javascript', options }); + const after = getTreeSitterStats(); + assert.equal(result, null, 'expected disabled language to fall back'); + assert.equal(Number(after.fallbacks) - Number(before.fallbacks), 1); + treeSitterState.disabledLanguages = new Set(); +} + +{ + const missing = new Set(); + const result = buildTreeSitterChunks({ + text: 'function demo() {}', + languageId: 'unsupported-language', + options: { + treeSitter: { enabled: true }, + treeSitterMissingLanguages: missing, + log: () => {} + } + }); + assert.equal(result, null, 'expected missing grammar to fall back to heuristic chunking'); + assert.ok(missing.has('unsupported-language')); +} + +{ + treeSitterState.TreeSitter = treeSitterState.TreeSitter || {}; + treeSitterState.grammarCache.clear(); + treeSitterState.languageCache.clear(); + for (const lang of ['javascript', 'python', 'go']) { + const runtimeKey = LANGUAGE_GRAMMAR_KEYS[lang]; + treeSitterState.grammarCache.set(runtimeKey, { language: null, error: null }); + treeSitterState.languageCache.set(lang, { language: null, error: null }); + } + const result = pruneTreeSitterLanguages(['python'], { skipDispose: true }); + assert.equal(result.removed, 0, 'prune should not evict native runtime entries'); + const remaining = Array.from(treeSitterState.grammarCache.keys()); + assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.javascript)); + assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.python)); + assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.go)); +} + +console.log('tree-sitter preload and planning contract matrix test passed'); diff --git a/tests/indexing/tree-sitter/tree-sitter-runtime.test.js b/tests/indexing/tree-sitter/runtime.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-runtime.test.js rename to tests/indexing/tree-sitter/runtime.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-adaptive-planner.test.js b/tests/indexing/tree-sitter/scheduler-adaptive-planner.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-adaptive-planner.test.js rename to tests/indexing/tree-sitter/scheduler-adaptive-planner.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-adaptive-profile.test.js b/tests/indexing/tree-sitter/scheduler-adaptive-profile.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-adaptive-profile.test.js rename to tests/indexing/tree-sitter/scheduler-adaptive-profile.test.js diff --git a/tests/indexing/tree-sitter/scheduler-contracts-and-failure-classes.test.js b/tests/indexing/tree-sitter/scheduler-contracts-and-failure-classes.test.js new file mode 100644 index 000000000..8a36c5f20 --- /dev/null +++ b/tests/indexing/tree-sitter/scheduler-contracts-and-failure-classes.test.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildVfsVirtualPath } from '../../../src/index/tooling/vfs.js'; +import { + assertTreeSitterScheduledGroupsContract, + assertTreeSitterScheduledJobContract, + assertTreeSitterSchedulerTaskContracts, + buildTreeSitterPlannerFailureSnapshot +} from '../../../src/index/build/tree-sitter-scheduler/contracts.js'; +import { + classifyTreeSitterSchedulerFailure, + TREE_SITTER_SCHEDULER_FAILURE_CLASSES +} from '../../../src/index/build/tree-sitter-scheduler/runner/failure-classification.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv({ testing: '1' }); + +const validJob = { + schemaVersion: '1.0.0', + grammarKey: 'native:javascript', + runtimeKind: 'native', + languageId: 'javascript', + containerPath: 'src/example.js', + containerExt: '.js', + effectiveExt: '.js', + segmentStart: 0, + segmentEnd: 24, + virtualPath: buildVfsVirtualPath({ + containerPath: 'src/example.js', + segmentUid: 'seg:base', + effectiveExt: '.js' + }), + fileVersionSignature: { + hash: 'abc123', + size: 24, + mtimeMs: 123456 + }, + segment: { + segmentId: 'segment-1', + segmentUid: 'seg:base', + type: 'code', + languageId: 'javascript', + start: 0, + end: 24, + ext: '.js', + meta: {} + } +}; + +const identity = assertTreeSitterScheduledJobContract(validJob, { phase: 'test:valid-job' }); +assert.equal(identity.segmentUid, 'seg:base', 'expected stable segment uid on planned job'); + +const validGroups = [{ + grammarKey: 'native:javascript', + baseGrammarKey: 'native:javascript', + bucketKey: 'native:javascript', + wave: 1, + shard: 1, + jobs: [validJob] +}]; +assert.equal( + assertTreeSitterScheduledGroupsContract(validGroups, { phase: 'test:groups' }), + true, + 'expected valid scheduled groups to satisfy the contract' +); + +const validTasks = [{ + taskId: 'native:javascript#pool1', + baseGrammarKey: 'native:javascript', + laneIndex: 1, + laneCount: 1, + timeoutMs: 30000, + grammarKeys: ['native:javascript'] +}]; +assert.equal( + assertTreeSitterSchedulerTaskContracts(validTasks, { + executionOrder: ['native:javascript'], + groupByGrammarKey: new Map([['native:javascript', validGroups[0]]]), + phase: 'test:tasks' + }), + true, + 'expected valid scheduler tasks to satisfy the contract' +); + +assert.throws( + () => assertTreeSitterScheduledJobContract({ + ...validJob, + virtualPath: buildVfsVirtualPath({ containerPath: 'src/example.js', effectiveExt: '.js' }), + segment: { + ...validJob.segment, + segmentUid: null + } + }, { phase: 'test:missing-segmentuid' }), + /ERR_TREE_SITTER_SCHEDULER_CONTRACT|missing segmentUid/i, + 'expected scheduler contract to reject planned jobs without stable identity' +); + +const snapshot = buildTreeSitterPlannerFailureSnapshot({ + plan: { + mode: 'code', + jobs: 1, + executionOrder: ['native:javascript'], + requiredNativeLanguages: ['javascript'] + }, + groups: validGroups, + tasks: validTasks, + failureSummary: { + parserCrashSignatures: 1, + failedGrammarKeys: ['native:javascript'], + degradedVirtualPaths: [validJob.virtualPath], + quarantineDecisions: [{ + signature: 'tscrash:test', + scope: 'virtual_path', + target: validJob.virtualPath, + occurrences: 1, + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserCrash, + fallbackConsequence: 'degrade_virtual_paths', + grammarKeys: ['native:javascript'], + virtualPaths: [validJob.virtualPath] + }], + failureClasses: { + [TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserCrash]: 1 + } + } +}); +assert.equal(snapshot.mode, 'code', 'expected planner snapshot mode'); +assert.equal(snapshot.scheduledJobs.length, 1, 'expected one scheduled job in snapshot'); +assert.equal(snapshot.scheduledJobs[0].laneIndex, 1, 'expected lane assignment in snapshot'); +assert.equal(snapshot.scheduledJobs[0].timeoutMs, 30000, 'expected timeout budget in snapshot'); +assert.deepEqual( + snapshot.failureSummary.failureClasses, + { [TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserCrash]: 1 }, + 'expected failure classes in snapshot' +); +assert.equal(snapshot.failureSummary.quarantineDecisions.length, 1, 'expected quarantine decisions in snapshot'); +assert.equal(snapshot.failureSummary.quarantineDecisions[0].scope, 'virtual_path', 'expected normalized quarantine scope'); + +assert.deepEqual( + classifyTreeSitterSchedulerFailure({ + error: { code: 'SUBPROCESS_TIMEOUT', message: 'timed out' } + }), + { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserTimeout, + fallbackConsequence: 'degrade_virtual_paths' + }, + 'expected timeout classification' +); +assert.deepEqual( + classifyTreeSitterSchedulerFailure({ + error: { stage: 'scheduler-stale-plan', message: 'stale plan for src/example.js' } + }), + { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.stalePlan, + fallbackConsequence: 'fail_closed' + }, + 'expected stale-plan classification' +); +assert.deepEqual( + classifyTreeSitterSchedulerFailure({ + error: { stage: 'scheduler-build-tree-sitter-chunks', message: 'No tree-sitter chunks produced' } + }), + { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserShapeRejection, + fallbackConsequence: 'degrade_virtual_paths' + }, + 'expected parser shape rejection classification' +); +assert.deepEqual( + classifyTreeSitterSchedulerFailure({ + error: { + code: 'ERR_TREE_SITTER_SCHEDULER_CONTRACT', + message: '[tree-sitter:schedule] scheduler-runner:tasks: missing segmentUid' + } + }), + { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.contractViolation, + fallbackConsequence: 'fail_closed' + }, + 'expected scheduler contract violation classification' +); +assert.deepEqual( + classifyTreeSitterSchedulerFailure({ + error: { result: { exitCode: 0xC0000005, signal: null } }, + crashEvent: { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserCrash, + fallbackConsequence: 'degrade_virtual_paths' + } + }), + { + failureClass: TREE_SITTER_SCHEDULER_FAILURE_CLASSES.parserCrash, + fallbackConsequence: 'degrade_virtual_paths' + }, + 'expected explicit crash event classification to win' +); + +console.log('tree-sitter scheduler contracts and failure classes ok'); diff --git a/tests/indexing/tree-sitter/scheduler-crash-fallback.test.js b/tests/indexing/tree-sitter/scheduler-crash-fallback.test.js new file mode 100644 index 000000000..9a8d8e8d9 --- /dev/null +++ b/tests/indexing/tree-sitter/scheduler-crash-fallback.test.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { processFileCpu } from '../../../src/index/build/file-processor/cpu.js'; +import { createCrashLogger } from '../../../src/index/build/crash-log.js'; +import { runTreeSitterScheduler } from '../../../src/index/build/tree-sitter-scheduler/runner.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { createTreeSitterProcessFileCpuFixture } from '../file-processor/tree-sitter-process-file-cpu-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tree-sitter-scheduler-crash-fallback'); +const outDir = path.join(tempRoot, 'index-code'); +const repoCacheRoot = path.join(tempRoot, 'repo-cache'); +const perlAbs = path.join(root, 'tests', 'fixtures', 'languages', 'src', 'perl_advanced.pl'); +const perlSiblingAbs = path.join(tempRoot, 'perl_sibling.pl'); +const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); +await fs.mkdir(repoCacheRoot, { recursive: true }); +await fs.copyFile(perlAbs, perlSiblingAbs); + +const runtime = { + root, + repoCacheRoot, + buildRoot: tempRoot, + buildId: 'ub001-tree-sitter-crash', + segmentsConfig: null, + languageOptions: { + treeSitter: { + enabled: true, + strict: true + } + } +}; +const crashLogger = await createCrashLogger({ + repoCacheRoot, + enabled: true +}); + +try { + let scheduler = null; + let schedulerError = null; + let skippedForMissingGrammar = false; + await withTemporaryEnv({ PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH: 'perl' }, async () => { + try { + scheduler = await runTreeSitterScheduler({ + mode: 'code', + runtime, + entries: [perlAbs, perlSiblingAbs, jsAbs], + outDir, + abortSignal: null, + log: () => {}, + crashLogger + }); + } catch (err) { + schedulerError = err; + } + }); + if (schedulerError) { + const message = schedulerError?.message || String(schedulerError); + if (/\bgrammar preflight failed unavailable=/.test(message)) { + skippedForMissingGrammar = true; + } else { + throw schedulerError; + } + } + if (skippedForMissingGrammar) { + console.log('tree-sitter scheduler crash fallback skipped (native grammars unavailable)'); + } else { + assert.ok(scheduler, 'expected scheduler result'); + assert.ok(scheduler.index instanceof Map, 'expected scheduler index map'); + assert.ok( + scheduler.index.size > 0, + 'expected scheduler to continue processing unaffected files after injected parser crash' + ); + const schedulerStats = scheduler.stats(); + assert.ok( + Number(schedulerStats?.parserCrashSignatures) >= 1, + 'expected parser crash signature to be recorded' + ); + assert.ok( + Number(schedulerStats?.degradedVirtualPaths) >= 1, + 'expected degraded virtual paths to be tracked' + ); + assert.ok( + typeof scheduler?.isDegradedVirtualPath === 'function', + 'expected degraded virtual path checker' + ); + const crashSummary = scheduler.getCrashSummary(); + assert.equal( + crashSummary.failureClasses?.parser_crash, + 1, + 'expected parser crash classification count in crash summary' + ); + const degradedPerlVirtualPaths = crashSummary.degradedVirtualPaths.filter((virtualPath) => ( + virtualPath.includes('perl_advanced.pl') || virtualPath.includes('perl_sibling.pl') + )); + assert.ok(degradedPerlVirtualPaths.length >= 1, 'expected a failed perl virtual path to be marked degraded'); + assert.ok( + degradedPerlVirtualPaths.length <= 1, + `expected per-file degradation containment; got ${degradedPerlVirtualPaths.length} perl paths` + ); + assert.ok(Array.isArray(crashSummary.quarantineDecisions), 'expected quarantine decisions in crash summary'); + assert.ok(crashSummary.quarantineDecisions.length >= 1, 'expected at least one quarantine decision'); + assert.equal( + crashSummary.quarantineDecisions[0].scope, + 'virtual_path', + 'expected first isolated crash to quarantine exact virtual paths' + ); + await fs.access(scheduler.crashForensicsBundlePath); + await fs.access(scheduler.plannerFailureSnapshotPath); + await fs.access(path.join(repoCacheRoot, 'logs', 'index-crash-forensics-index.json')); + const plannerSnapshot = JSON.parse(await fs.readFile(scheduler.plannerFailureSnapshotPath, 'utf8')); + assert.ok( + Array.isArray(plannerSnapshot?.scheduledJobs) && plannerSnapshot.scheduledJobs.length > 0, + 'expected planner snapshot to capture scheduled jobs for degraded run' + ); + assert.equal( + plannerSnapshot?.failureSummary?.failureClasses?.parser_crash, + 1, + 'expected planner snapshot failure classes' + ); + assert.ok( + Array.isArray(plannerSnapshot?.failureSummary?.quarantineDecisions) + && plannerSnapshot.failureSummary.quarantineDecisions.length >= 1, + 'expected planner snapshot quarantine decisions' + ); + + const schedulerNoLoad = { + ...scheduler, + loadChunks: async () => { + throw new Error('scheduler loadChunks should not run for degraded virtual paths'); + }, + loadChunksBatch: async () => { + throw new Error('scheduler loadChunksBatch should not run for degraded virtual paths'); + } + }; + const { createContext } = await createTreeSitterProcessFileCpuFixture({ + fileHash: 'tree-sitter-crash-fallback', + fixtureParts: ['tests', 'fixtures', 'languages', 'src', 'perl_advanced.pl'] + }); + + const cpuResult = await processFileCpu(createContext({ + languageOptions: { + treeSitter: { + enabled: true, + strict: true + } + }, + treeSitterScheduler: schedulerNoLoad, + crashLogger + })); + + assert.ok(Array.isArray(cpuResult?.chunks) && cpuResult.chunks.length > 0, 'expected fallback chunks'); + assert.equal(cpuResult?.skip, null, 'expected no skip despite injected parser crash'); + } +} finally { + await crashLogger.close?.(); +} + +console.log('tree-sitter scheduler crash fallback ok'); diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-crash-inference.test.js b/tests/indexing/tree-sitter/scheduler-crash-inference.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-crash-inference.test.js rename to tests/indexing/tree-sitter/scheduler-crash-inference.test.js diff --git a/tests/indexing/tree-sitter/scheduler-crash-quarantine-threshold.test.js b/tests/indexing/tree-sitter/scheduler-crash-quarantine-threshold.test.js new file mode 100644 index 000000000..db843bede --- /dev/null +++ b/tests/indexing/tree-sitter/scheduler-crash-quarantine-threshold.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createSchedulerCrashTracker } from '../../../src/index/build/tree-sitter-scheduler/runner/crash-tracker.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tree-sitter-scheduler-crash-quarantine-threshold-${process.pid}-${Date.now()}`); +const outDir = path.join(tempRoot, 'index-code'); +const paths = { baseDir: outDir }; +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); + +const job = { + grammarKey: 'native:json', + languageId: 'json', + containerPath: 'src/config.json', + virtualPath: '.poc-vfs/src/config.json#seg:test.txt', + fileVersionSignature: { + hash: 'json-crash-shape', + size: 128, + mtimeMs: 1234 + } +}; + +const tracker = createSchedulerCrashTracker({ + runtime: { + root, + repoCacheRoot: path.join(tempRoot, 'repo-cache'), + buildRoot: tempRoot, + buildId: 'tree-sitter-quarantine-threshold' + }, + outDir, + paths, + groupByGrammarKey: new Map([[ + 'native:json', + { + grammarKey: 'native:json', + languages: ['json'], + jobs: [job] + } + ]]), + log: () => {} +}); + +const error = new Error('synthetic parser crash'); +error.result = { + exitCode: 0xC0000005, + signal: null, + stdout: '', + stderr: '' +}; + +await tracker.recordFailure({ + grammarKey: 'native:json', + stage: 'scheduler-subprocess', + error, + taskId: 'native:json#pool1', + markFailed: true, + taskGrammarKeys: ['native:json'], + inferredFailedGrammarKeys: ['native:json'], + failureClass: 'parser_crash', + fallbackConsequence: 'degrade_virtual_paths' +}); +await tracker.recordFailure({ + grammarKey: 'native:json', + stage: 'scheduler-subprocess', + error, + taskId: 'native:json#pool1', + markFailed: true, + taskGrammarKeys: ['native:json'], + inferredFailedGrammarKeys: ['native:json'], + failureClass: 'parser_crash', + fallbackConsequence: 'degrade_virtual_paths' +}); +await tracker.waitForPersistence(); + +const summary = tracker.summarize(); +assert.equal(summary.parserCrashSignatures, 1, 'expected one repeated crash signature'); +assert.equal(summary.quarantineDecisions.length, 1, 'expected one quarantine decision'); +assert.equal(summary.quarantineDecisions[0].scope, 'signature', 'expected repeated crash to escalate to signature quarantine'); +assert.equal(summary.quarantineDecisions[0].occurrences, 2, 'expected repeated crash count on quarantine decision'); +assert.deepEqual(summary.quarantineDecisions[0].grammarKeys, ['native:json'], 'expected grammar scope on quarantine decision'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('tree-sitter scheduler crash quarantine threshold test passed'); diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-exec-concurrency-cap.test.js b/tests/indexing/tree-sitter/scheduler-exec-concurrency-cap.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-exec-concurrency-cap.test.js rename to tests/indexing/tree-sitter/scheduler-exec-concurrency-cap.test.js diff --git a/tests/indexing/tree-sitter/scheduler-lookup-reader-lease.test.js b/tests/indexing/tree-sitter/scheduler-lookup-reader-lease.test.js index 4019e7e33..7c917b436 100644 --- a/tests/indexing/tree-sitter/scheduler-lookup-reader-lease.test.js +++ b/tests/indexing/tree-sitter/scheduler-lookup-reader-lease.test.js @@ -81,17 +81,56 @@ const rowB = createRow({ languageId: 'typescript', ext: '.ts' }); +const grammarC = 'native:python'; +const grammarD = 'native:ruby'; +const rowC = createRow({ + virtualPath: '.poc-vfs/src/c.py#seg:c.py', + grammarKey: grammarC, + languageId: 'python', + ext: '.py' +}); +const rowD = createRow({ + virtualPath: '.poc-vfs/src/d.rb#seg:d.rb', + grammarKey: grammarD, + languageId: 'ruby', + ext: '.rb' +}); const entryA = await writeBinaryRow({ grammarKey: grammarA, row: rowA }); const entryB = await writeBinaryRow({ grammarKey: grammarB, row: rowB }); +const entryC = await writeBinaryRow({ grammarKey: grammarC, row: rowC }); +const entryD = await writeBinaryRow({ grammarKey: grammarD, row: rowD }); const index = new Map(); index.set(rowA.virtualPath, entryA); index.set(rowB.virtualPath, entryB); +index.set(rowC.virtualPath, entryC); +index.set(rowD.virtualPath, entryD); const resultsPathA = paths.resultsPathForGrammarKey(grammarA, 'binary-v1'); const resultsPathB = paths.resultsPathForGrammarKey(grammarB, 'binary-v1'); +const resultsPathC = paths.resultsPathForGrammarKey(grammarC, 'binary-v1'); +const resultsPathD = paths.resultsPathForGrammarKey(grammarD, 'binary-v1'); const originalOpen = fs.open; +const installDelayedCloseForPath = (delayedPath, delayMs = 75) => { + fs.open = async (...args) => { + const [targetPath] = args; + const handle = await originalOpen(...args); + const shouldDelayClose = String(targetPath) === String(delayedPath); + return new Proxy(handle, { + get(target, prop, receiver) { + if (prop === 'close' && shouldDelayClose) { + return async () => { + await sleep(delayMs); + return target.close(); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }; +}; try { // Regression: overlapping reads with maxOpenReaders=1 must not close an // in-use reader and surface "file closed". @@ -136,6 +175,63 @@ try { await lookup.close(); } + // Regression: an entry that is already closing due to eviction must never be + // leased again for a same-manifest request. The retried request should wait + // for close settlement and reopen cleanly instead of surfacing "reader is + // closed" from the retiring reader instance. + installDelayedCloseForPath(resultsPathA); + const sameManifestRetryLookup = createTreeSitterSchedulerLookup({ + outDir, + index: new Map([ + [rowA.virtualPath, entryA], + [rowB.virtualPath, entryB] + ]), + maxOpenReaders: 1 + }); + try { + const firstA = await sameManifestRetryLookup.loadRow(rowA.virtualPath); + assert.equal(firstA?.virtualPath, rowA.virtualPath, 'expected initial A lookup to succeed'); + const pendingB = sameManifestRetryLookup.loadRow(rowB.virtualPath); + await sleep(10); + const retriedA = await sameManifestRetryLookup.loadRow(rowA.virtualPath); + const loadedB = await pendingB; + assert.equal(loadedB?.virtualPath, rowB.virtualPath, 'expected B lookup to succeed while A is retiring'); + assert.equal(retriedA?.virtualPath, rowA.virtualPath, 'expected same-manifest retry to reopen cleanly'); + } finally { + await sameManifestRetryLookup.close(); + } + + // Regression: idle-reader eviction must be identity-based. Concurrent reader + // creation can keep map size unchanged even after the targeted idle entry was + // evicted, which used to trip a false "eviction did not settle" failure. + installDelayedCloseForPath(resultsPathA); + const identityEvictionLookup = createTreeSitterSchedulerLookup({ + outDir, + index: new Map([ + [rowA.virtualPath, entryA], + [rowB.virtualPath, entryB], + [rowC.virtualPath, entryC], + [rowD.virtualPath, entryD] + ]), + maxOpenReaders: 2 + }); + try { + await identityEvictionLookup.loadRow(rowA.virtualPath); + await identityEvictionLookup.loadRow(rowB.virtualPath); + const pendingC = identityEvictionLookup.loadRow(rowC.virtualPath); + await sleep(10); + const loadedD = await identityEvictionLookup.loadRow(rowD.virtualPath); + const loadedC = await pendingC; + assert.equal(loadedC?.virtualPath, rowC.virtualPath, 'expected C lookup to succeed during concurrent eviction churn'); + assert.equal(loadedD?.virtualPath, rowD.virtualPath, 'expected D lookup to succeed during concurrent eviction churn'); + assert.ok( + identityEvictionLookup.stats().readerEvictions >= 2, + 'expected concurrent churn scenario to record idle reader evictions' + ); + } finally { + await identityEvictionLookup.close(); + } + // Regression: treat EBADF/"file closed" as transient for scheduler row reads. let injectedEbadf = 0; fs.open = async (...args) => { @@ -179,7 +275,8 @@ try { ); assert.ok(injectedEbadf >= 1, 'expected injected EBADF/file closed path to be exercised'); - // Regression: a wedged reader close should not stall scheduler lookup close. + // Regression: a wedged reader close should keep lookup shutdown bounded but + // fail deterministically instead of force-closing the shared reader entry. fs.open = async (...args) => { const [targetPath] = args; const handle = await originalOpen(...args); @@ -200,16 +297,29 @@ try { closeTimeoutMs: 25, forceCloseAfterMs: 25 }); + const loaded = await hangingCloseLookup.loadRow(rowA.virtualPath); + assert.equal(loaded?.virtualPath, rowA.virtualPath, 'expected lookup row load before close-timeout regression check'); + const closeStartAtMs = Date.now(); + let hangingCloseError = null; try { - const loaded = await hangingCloseLookup.loadRow(rowA.virtualPath); - assert.equal(loaded?.virtualPath, rowA.virtualPath, 'expected lookup row load before close-timeout regression check'); - const closeStartAtMs = Date.now(); - await hangingCloseLookup.close(); - const closeElapsedMs = Date.now() - closeStartAtMs; - assert.ok(closeElapsedMs < 1500, `expected lookup close to remain bounded (elapsed=${closeElapsedMs}ms)`); - } finally { await hangingCloseLookup.close(); + assert.fail('expected wedged reader close to raise deterministic timeout'); + } catch (err) { + hangingCloseError = err; } + const closeElapsedMs = Date.now() - closeStartAtMs; + assert.ok(closeElapsedMs < 1500, `expected lookup close to remain bounded (elapsed=${closeElapsedMs}ms)`); + assert.equal( + hangingCloseError?.code, + 'ERR_TREE_SITTER_LOOKUP_CLOSE_TIMEOUT', + 'expected wedged reader close to fail with typed lookup close timeout' + ); + const hangingStats = hangingCloseLookup.stats(); + assert.equal( + hangingStats.closeErrorCode, + 'ERR_TREE_SITTER_LOOKUP_CLOSE_TIMEOUT', + 'expected lookup stats to retain close timeout diagnostics' + ); // Regression: non-positive force-close config must still stay bounded. const nonPositiveForceCloseLookup = createTreeSitterSchedulerLookup({ diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-native-determinism.test.js b/tests/indexing/tree-sitter/scheduler-native-determinism.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-native-determinism.test.js rename to tests/indexing/tree-sitter/scheduler-native-determinism.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-native-language-contract.test.js b/tests/indexing/tree-sitter/scheduler-native-language-contract.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-native-language-contract.test.js rename to tests/indexing/tree-sitter/scheduler-native-language-contract.test.js diff --git a/tests/indexing/tree-sitter/scheduler-native-plan-contract.test.js b/tests/indexing/tree-sitter/scheduler-native-plan-contract.test.js new file mode 100644 index 000000000..7de68e91c --- /dev/null +++ b/tests/indexing/tree-sitter/scheduler-native-plan-contract.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildTreeSitterSchedulerPlan } from '../../../src/index/build/tree-sitter-scheduler/plan.js'; +import { + getNativeTreeSitterParser, + preflightNativeTreeSitterGrammars, + resolveNativeTreeSitterTarget +} from '../../../src/lang/tree-sitter/native-runtime.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { skipIfNativeGrammarsUnavailable } from './native-availability.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'tree-sitter-scheduler-native-plan-contract', 'index-code'); +const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); + +await fs.access(jsAbs); +await fs.rm(outDir, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); + +const target = resolveNativeTreeSitterTarget('javascript', '.js'); +assert.ok(target, 'expected native target for javascript'); +assert.equal(target.grammarKey, 'native:javascript'); +assert.equal(target.runtimeKind, 'native'); +assert.equal(target.languageId, 'javascript'); + +const jsxTarget = resolveNativeTreeSitterTarget('jsx', '.jsx'); +assert.ok(jsxTarget, 'expected native target for jsx'); +assert.equal(jsxTarget.grammarKey, 'native:javascript'); +assert.equal(jsxTarget.runtimeKind, 'native'); +assert.equal(jsxTarget.languageId, 'jsx'); + +const luaTarget = resolveNativeTreeSitterTarget('lua', '.lua'); +assert.ok(luaTarget, 'expected native target for lua'); +assert.equal(luaTarget.grammarKey, 'native:lua'); +assert.equal(luaTarget.runtimeKind, 'native'); +assert.equal(luaTarget.languageId, 'lua'); + +const missingTarget = resolveNativeTreeSitterTarget('this-language-does-not-exist', '.xyz'); +assert.equal(missingTarget, null, 'expected null target for unsupported language'); + +const preflightFail = preflightNativeTreeSitterGrammars(['javascript', 'this-language-does-not-exist']); +assert.equal(preflightFail.ok, false, 'expected preflight failure'); +assert.ok( + Array.isArray(preflightFail.missing) && preflightFail.missing.includes('this-language-does-not-exist'), + 'expected missing language reported in preflight result' +); +if (skipIfNativeGrammarsUnavailable(['javascript', 'lua'], 'tree-sitter scheduler native plan contract')) { + process.exit(0); +} + +const luaPreflight = preflightNativeTreeSitterGrammars(['lua']); +const luaParser = getNativeTreeSitterParser('lua', { + treeSitter: { enabled: true, nativeOnly: true, strict: true }, + log: () => {} +}); +assert.ok(luaParser, 'expected lua parser to activate in native runtime'); +assert.ok( + !luaPreflight.unavailable.includes('lua'), + 'expected lua preflight to stay available when parser activation succeeds' +); + +const runtime = { + root, + segmentsConfig: null, + languageOptions: { + treeSitter: { + enabled: true, + strict: true + } + } +}; + +const planResult = await buildTreeSitterSchedulerPlan({ + mode: 'code', + runtime, + entries: [jsAbs], + outDir, + fileTextCache: null, + abortSignal: null, + log: () => {} +}); + +assert.ok(planResult, 'expected scheduler plan result'); +assert.ok(planResult.plan, 'expected scheduler plan'); +assert.ok( + Array.isArray(planResult.plan.grammarKeys) && planResult.plan.grammarKeys.includes('native:javascript'), + 'expected native grammar key in plan' +); +assert.ok( + Array.isArray(planResult.plan.requiredNativeLanguages) + && planResult.plan.requiredNativeLanguages.includes('javascript'), + 'expected required native language in plan' +); +for (const group of planResult.groups || []) { + for (const job of group.jobs || []) { + const signature = job?.fileVersionSignature; + assert.ok(signature && typeof signature === 'object', 'expected file version signature on scheduler jobs'); + assert.equal(typeof signature.hash, 'string', 'expected file signature hash'); + assert.equal(Number.isFinite(signature.size), true, 'expected file signature size'); + assert.equal(Number.isFinite(signature.mtimeMs), true, 'expected file signature mtimeMs'); + assert.equal(typeof job?.segment?.segmentUid, 'string', 'expected stable segmentUid on scheduler jobs'); + assert.ok(job.segment.segmentUid.length > 0, 'expected non-empty scheduler segmentUid'); + } +} + +console.log('tree-sitter scheduler native plan contract ok'); + diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-native-smoke.test.js b/tests/indexing/tree-sitter/scheduler-native-smoke.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-native-smoke.test.js rename to tests/indexing/tree-sitter/scheduler-native-smoke.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-plan-path-policy.test.js b/tests/indexing/tree-sitter/scheduler-plan-path-policy.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-plan-path-policy.test.js rename to tests/indexing/tree-sitter/scheduler-plan-path-policy.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-plan-skip-on-parse-error.test.js b/tests/indexing/tree-sitter/scheduler-plan-skip-on-parse-error.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-plan-skip-on-parse-error.test.js rename to tests/indexing/tree-sitter/scheduler-plan-skip-on-parse-error.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-skip-log-aggregation.test.js b/tests/indexing/tree-sitter/scheduler-skip-log-aggregation.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-skip-log-aggregation.test.js rename to tests/indexing/tree-sitter/scheduler-skip-log-aggregation.test.js diff --git a/tests/indexing/tree-sitter/scheduler-stage1-contract.test.js b/tests/indexing/tree-sitter/scheduler-stage1-contract.test.js new file mode 100644 index 000000000..feb55ee52 --- /dev/null +++ b/tests/indexing/tree-sitter/scheduler-stage1-contract.test.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { processFileCpu } from '../../../src/index/build/file-processor/cpu.js'; +import { createTreeSitterProcessFileCpuFixture } from '../file-processor/tree-sitter-process-file-cpu-fixture.js'; + +const noop = () => {}; +const { createContext } = await createTreeSitterProcessFileCpuFixture({ + fileHash: 'scheduler-stage1-contract' +}); + +function createMissingChunkScheduler(onLoad, extras = {}) { + return { + ...extras, + loadChunks: async () => { + onLoad(); + return null; + } + }; +} + +function createProcessFileOptions({ + mode = 'code', + treeSitter = { enabled: true, strict: false }, + treeSitterScheduler = null, + logLine = noop +} = {}) { + return createContext({ + mode, + languageOptions: { + treeSitter + }, + logLine, + treeSitterScheduler + }); +} + +let schedulerCalls = 0; +const treeSitterScheduler = createMissingChunkScheduler(() => { + schedulerCalls += 1; +}); + +await assert.rejects( + async () => + processFileCpu( + createProcessFileOptions({ + treeSitter: { + enabled: true, + strict: true + }, + treeSitterScheduler + }) + ), + /Missing scheduled chunks/ +); +assert.ok(schedulerCalls > 0, 'expected scheduler to be consulted for tree-sitter chunks'); + +let fallbackSchedulerCalls = 0; +const fallbackScheduler = createMissingChunkScheduler(() => { + fallbackSchedulerCalls += 1; +}, { + index: new Map() +}); +const fallbackResult = await processFileCpu( + createProcessFileOptions({ + treeSitter: { + enabled: true, + strict: false + }, + treeSitterScheduler: fallbackScheduler + }) +); +assert.ok(fallbackSchedulerCalls > 0, 'expected scheduler lookup attempts in non-strict mode'); +assert.ok(Array.isArray(fallbackResult?.chunks) && fallbackResult.chunks.length > 0, 'expected fallback chunking to produce chunks'); + +let unsupportedLanguageSchedulerCalls = 0; +const unsupportedLanguageWarnings = []; +const unsupportedLanguageScheduler = createMissingChunkScheduler(() => { + unsupportedLanguageSchedulerCalls += 1; +}, { + index: new Map(), + scheduledLanguageIds: new Set(['lua']) +}); +const unsupportedLanguageResult = await processFileCpu( + createProcessFileOptions({ + treeSitter: { + enabled: true, + strict: false + }, + treeSitterScheduler: unsupportedLanguageScheduler, + logLine: (line) => unsupportedLanguageWarnings.push(String(line || '')) + }) +); +assert.equal( + unsupportedLanguageSchedulerCalls, + 0, + 'expected scheduler lookup to be skipped when scheduler has no coverage for the language' +); +assert.ok( + Array.isArray(unsupportedLanguageResult?.chunks) && unsupportedLanguageResult.chunks.length > 0, + 'expected fallback chunking to produce chunks when scheduler lacks language coverage' +); +assert.equal( + unsupportedLanguageWarnings.some((line) => line.includes('[tree-sitter:schedule] scheduler missing')), + false, + 'expected no scheduler-missing warning spam when language coverage is absent' +); + +const proseResult = await processFileCpu( + createProcessFileOptions({ + mode: 'prose', + treeSitter: { + enabled: false + } + }) +); +assert.ok(Array.isArray(proseResult?.chunks), 'expected prose mode to complete without scheduler'); + +console.log('tree-sitter scheduler stage1 contract ok'); + diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-swift-subprocess.test.js b/tests/indexing/tree-sitter/scheduler-swift-subprocess.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-swift-subprocess.test.js rename to tests/indexing/tree-sitter/scheduler-swift-subprocess.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-timeout-cache-mode.test.js b/tests/indexing/tree-sitter/scheduler-timeout-cache-mode.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-timeout-cache-mode.test.js rename to tests/indexing/tree-sitter/scheduler-timeout-cache-mode.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-warm-pool-tasks.test.js b/tests/indexing/tree-sitter/scheduler-warm-pool-tasks.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-scheduler-warm-pool-tasks.test.js rename to tests/indexing/tree-sitter/scheduler-warm-pool-tasks.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-streaming-chunking.test.js b/tests/indexing/tree-sitter/streaming-chunking.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-streaming-chunking.test.js rename to tests/indexing/tree-sitter/streaming-chunking.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-load-bench-contract.test.js b/tests/indexing/tree-sitter/tree-sitter-load-bench-contract.test.js deleted file mode 100644 index 75eb17cd0..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-load-bench-contract.test.js +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import { applyTestEnv } from '../../helpers/test-env.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const benchScript = path.join(root, 'tools', 'bench', 'index', 'tree-sitter-load.js'); - -const runBenchPayload = () => { - const result = spawnSync( - process.execPath, - [ - benchScript, - '--languages', - 'javascript,go,rust', - '--files-per-language', - '10', - '--repeats', - '1', - '--json' - ], - { cwd: root, env: testEnv, encoding: 'utf8' } - ); - if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); - } - return JSON.parse(String(result.stdout || '{}')); -}; - -const payload = runBenchPayload(); -const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : []; -assert.equal(scenarios.length, 4, 'expected 4 scenarios'); - -if (scenarios.every((scenario) => scenario && scenario.skipped)) { - console.log('tree-sitter runtime unavailable; skipping tree-sitter-load bench contract.'); - process.exit(0); -} - -const findScenario = ({ cacheMode, policy }) => ( - scenarios.find((scenario) => ( - scenario - && scenario.cacheMode === cacheMode - && scenario.policy === policy - )) || null -); - -const cold = findScenario({ cacheMode: 'cold', policy: 'file-order' }); -const warm = findScenario({ cacheMode: 'warm', policy: 'file-order' }); -assert.ok(cold && !cold.skipped, 'expected cold warm/cold scenario'); -assert.ok(warm && !warm.skipped, 'expected warm warm/cold scenario'); - -assert.ok(Number(cold.treeSitter?.grammarLoads) > 0, 'expected cold run to load grammars'); -assert.equal(Number(warm.treeSitter?.grammarLoads), 0, 'expected warm run to avoid grammar loads'); -assert.ok( - Number.isFinite(Number(cold.totalMs)) && Number.isFinite(Number(warm.totalMs)), - 'expected warm/cold scenarios to report totalMs' -); -const coldMs = Number(cold.totalMs); -const warmMs = Number(warm.totalMs); -const regressionThreshold = 1.35; -let warmWithinThreshold = warmMs <= (coldMs * regressionThreshold); -let retrySample = null; -if (!warmWithinThreshold) { - retrySample = runBenchPayload(); - const retryScenarios = Array.isArray(retrySample.scenarios) ? retrySample.scenarios : []; - const retryCold = retryScenarios.find((scenario) => ( - scenario - && scenario.cacheMode === 'cold' - && scenario.policy === 'file-order' - )) || null; - const retryWarm = retryScenarios.find((scenario) => ( - scenario - && scenario.cacheMode === 'warm' - && scenario.policy === 'file-order' - )) || null; - if (retryCold && retryWarm && !retryCold.skipped && !retryWarm.skipped) { - warmWithinThreshold = Number(retryWarm.totalMs) <= (Number(retryCold.totalMs) * regressionThreshold); - retrySample = { - coldMs: Number(retryCold.totalMs), - warmMs: Number(retryWarm.totalMs) - }; - } -} -assert.ok( - warmWithinThreshold, - retrySample - ? `expected warm run to avoid major regression vs cold (sample1 warmMs=${warmMs} coldMs=${coldMs}; sample2 warmMs=${retrySample.warmMs} coldMs=${retrySample.coldMs})` - : `expected warm run to avoid major regression vs cold (warmMs=${warmMs} coldMs=${coldMs})` -); - -const fileOrderCold = findScenario({ cacheMode: 'cold', policy: 'file-order' }); -const batchCold = findScenario({ cacheMode: 'cold', policy: 'batch-by-language' }); -assert.ok(fileOrderCold && !fileOrderCold.skipped, 'expected cold file-order scenario'); -assert.ok(batchCold && !batchCold.skipped, 'expected cold batch-by-language scenario'); - -assert.ok( - Number(fileOrderCold.treeSitter?.parserActivations) >= Number(batchCold.treeSitter?.parserActivations), - 'expected batch-by-language to avoid extra parser language switching' -); - -console.log('tree-sitter load bench contract test passed'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-missing-grammar-fallback.test.js b/tests/indexing/tree-sitter/tree-sitter-missing-grammar-fallback.test.js deleted file mode 100644 index c82cbaabe..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-missing-grammar-fallback.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { buildTreeSitterChunks } from '../../../src/lang/tree-sitter.js'; - -const missing = new Set(); -const result = buildTreeSitterChunks({ - text: 'function demo() {}', - languageId: 'unsupported-language', - options: { - treeSitter: { enabled: true }, - treeSitterMissingLanguages: missing, - log: () => {} - } -}); - -assert.equal(result, null, 'expected missing grammar to fall back to heuristic chunking'); -assert.ok(missing.has('unsupported-language'), 'expected missing grammar to be recorded'); - -console.log('tree-sitter missing runtime fallback ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-preload-limited.test.js b/tests/indexing/tree-sitter/tree-sitter-preload-limited.test.js deleted file mode 100644 index 12115367a..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-preload-limited.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { - initTreeSitterRuntime, - preloadTreeSitterLanguages, - getTreeSitterCacheSnapshot -} from '../../../src/lang/tree-sitter.js'; -import { treeSitterState } from '../../../src/lang/tree-sitter/state.js'; - -const resetCaches = () => { - treeSitterState.languageCache?.clear?.(); - treeSitterState.grammarCache?.clear?.(); - treeSitterState.languageLoadPromises?.clear?.(); - treeSitterState.sharedParser = null; - treeSitterState.sharedParserLanguageId = null; -}; - -const run = async () => { - const ok = await initTreeSitterRuntime({ log: () => {} }); - if (!ok) { - console.log('tree-sitter runtime unavailable; skipping preload limited test.'); - return; - } - - resetCaches(); - - await preloadTreeSitterLanguages(['javascript', 'python'], { - skipDispose: true - }); - - const snapshot = getTreeSitterCacheSnapshot(); - assert.ok(snapshot.loadedLanguages.includes('javascript'), 'expected javascript preload entry'); - assert.ok(snapshot.loadedLanguages.includes('python'), 'expected python preload entry'); - assert.ok(snapshot.loadedLanguages.length >= 2, 'expected preload to activate all requested languages'); - - console.log('tree-sitter preload limited ok'); -}; - -await run(); - - diff --git a/tests/indexing/tree-sitter/tree-sitter-preload-order-deterministic.test.js b/tests/indexing/tree-sitter/tree-sitter-preload-order-deterministic.test.js deleted file mode 100644 index 7916160a0..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-preload-order-deterministic.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { resolveTreeSitterPreloadPlan } from '../../../src/index/build/indexer/steps/process-files/tree-sitter.js'; - -const entries = [ - { treeSitterBatchLanguages: ['javascript', 'html'] }, - { treeSitterBatchLanguages: ['javascript'] }, - { treeSitterBatchLanguages: ['python'] }, - { treeSitterBatchLanguages: ['html'] } -]; - -const plan = resolveTreeSitterPreloadPlan(entries); -assert.deepStrictEqual( - plan.languages, - ['html', 'javascript', 'python'], - 'preload order should sort by frequency desc, then language id' -); - -console.log('tree-sitter preload order deterministic ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-query-precompile-cache.test.js b/tests/indexing/tree-sitter/tree-sitter-query-precompile-cache.test.js deleted file mode 100644 index 9a0398f11..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-query-precompile-cache.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { - initTreeSitterRuntime, - preloadTreeSitterLanguages, - buildTreeSitterChunks -} from '../../../src/lang/tree-sitter.js'; -import { treeSitterState } from '../../../src/lang/tree-sitter/state.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv({ testing: '1' }); - -const resetCaches = () => { - treeSitterState.queryCache?.clear?.(); - treeSitterState.loggedQueryFailures?.clear?.(); -}; - -const run = async () => { - const ok = await initTreeSitterRuntime({ log: () => {} }); - if (!ok) { - console.log('tree-sitter runtime unavailable; skipping query cache test.'); - return; - } - - resetCaches(); - - await preloadTreeSitterLanguages(['javascript'], { - skipDispose: true - }); - - const options = { - treeSitter: { - enabled: true, - useQueries: true - }, - log: () => {} - }; - - const text = 'export class Widget { greet() {} }'; - const first = buildTreeSitterChunks({ text, languageId: 'javascript', options }); - if (!Array.isArray(first) || !first.length) { - console.log('tree-sitter chunking unavailable; skipping query cache test.'); - return; - } - - assert.ok(treeSitterState.queryCache.has('javascript'), 'expected query probe result to be cached after first parse'); - const firstQuery = treeSitterState.queryCache.get('javascript'); - - const second = buildTreeSitterChunks({ text, languageId: 'javascript', options }); - assert.ok(second && second.length, 'expected query-based chunking to continue working'); - - const secondQuery = treeSitterState.queryCache.get('javascript'); - assert.strictEqual(secondQuery, firstQuery, 'expected query to be reused from cache'); - - console.log('tree-sitter query cache ok'); -}; - -await run(); - - diff --git a/tests/indexing/tree-sitter/tree-sitter-runtime-cache.test.js b/tests/indexing/tree-sitter/tree-sitter-runtime-cache.test.js deleted file mode 100644 index 3dfc7203c..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-runtime-cache.test.js +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { - initTreeSitterRuntime, - preloadTreeSitterLanguages, - getTreeSitterStats, - getTreeSitterCacheSnapshot -} from '../../../src/lang/tree-sitter.js'; -import { - preflightNativeTreeSitterGrammars, - warmupNativeTreeSitterParsers -} from '../../../src/lang/tree-sitter/native-runtime.js'; - -const run = async () => { - const malformedPreflight = preflightNativeTreeSitterGrammars({ javascript: true }); - assert.equal(malformedPreflight.ok, true, 'malformed preflight inputs should not throw'); - assert.deepEqual(malformedPreflight.missing, []); - assert.deepEqual(malformedPreflight.unavailable, []); - const malformedWarmup = warmupNativeTreeSitterParsers({ javascript: true }); - assert.deepEqual(malformedWarmup, { warmed: [], failed: [] }, 'malformed warmup inputs should not throw'); - - const ok = await initTreeSitterRuntime({ log: () => {} }); - if (!ok) { - console.log('tree-sitter runtime unavailable; skipping runtime path cache test.'); - return; - } - - await preloadTreeSitterLanguages(['javascript'], { log: () => {} }); - const warmup = warmupNativeTreeSitterParsers(['javascript', 'not-a-real-grammar'], { - nativeParserCacheSize: 2, - log: () => {} - }); - const snapshot = getTreeSitterCacheSnapshot(); - const stats = getTreeSitterStats(); - assert.ok(Array.isArray(snapshot.loadedLanguages), 'expected loaded language snapshot'); - assert.ok(snapshot.loadedLanguages.includes('javascript'), 'expected javascript to be loaded'); - assert.ok(Number(stats.grammarLoads) >= 1, 'expected grammar load metric'); - assert.ok(Array.isArray(warmup.warmed), 'expected warmup result'); - assert.ok(Array.isArray(warmup.failed), 'expected warmup failures'); - assert.ok(warmup.failed.includes('not-a-real-grammar'), 'expected invalid grammar warmup to fail'); - - console.log('tree-sitter runtime cache ok'); -}; - -await run(); - - diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-crash-fallback.test.js b/tests/indexing/tree-sitter/tree-sitter-scheduler-crash-fallback.test.js deleted file mode 100644 index c70d34113..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-scheduler-crash-fallback.test.js +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { normalizeCommentConfig } from '../../../src/index/comments.js'; -import { getLanguageForFile } from '../../../src/index/language-registry.js'; -import { normalizeSegmentsConfig } from '../../../src/index/segments.js'; -import { processFileCpu } from '../../../src/index/build/file-processor/cpu.js'; -import { createCrashLogger } from '../../../src/index/build/crash-log.js'; -import { runTreeSitterScheduler } from '../../../src/index/build/tree-sitter-scheduler/runner.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tree-sitter-scheduler-crash-fallback'); -const outDir = path.join(tempRoot, 'index-code'); -const repoCacheRoot = path.join(tempRoot, 'repo-cache'); -const perlAbs = path.join(root, 'tests', 'fixtures', 'languages', 'src', 'perl_advanced.pl'); -const perlSiblingAbs = path.join(tempRoot, 'perl_sibling.pl'); -const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); -await fs.mkdir(repoCacheRoot, { recursive: true }); -await fs.copyFile(perlAbs, perlSiblingAbs); - -const runtime = { - root, - repoCacheRoot, - buildRoot: tempRoot, - buildId: 'ub001-tree-sitter-crash', - segmentsConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: true - } - } -}; -const crashLogger = await createCrashLogger({ - repoCacheRoot, - enabled: true -}); - -const previousCrashInjection = process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH; -process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH = 'perl'; -let scheduler = null; -let schedulerError = null; -try { - scheduler = await runTreeSitterScheduler({ - mode: 'code', - runtime, - entries: [perlAbs, perlSiblingAbs, jsAbs], - outDir, - abortSignal: null, - log: () => {}, - crashLogger - }); -} catch (err) { - schedulerError = err; -} finally { - if (previousCrashInjection === undefined) { - delete process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH; - } else { - process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH = previousCrashInjection; - } -} -if (schedulerError) { - const message = schedulerError?.message || String(schedulerError); - if (/\bgrammar preflight failed unavailable=/.test(message)) { - console.log('tree-sitter scheduler crash fallback skipped (native grammars unavailable)'); - process.exit(0); - } - throw schedulerError; -} - -assert.ok(scheduler, 'expected scheduler result'); -assert.ok(scheduler.index instanceof Map, 'expected scheduler index map'); -assert.ok( - scheduler.index.size > 0, - 'expected scheduler to continue processing unaffected files after injected parser crash' -); -const schedulerStats = scheduler.stats(); -assert.ok( - Number(schedulerStats?.parserCrashSignatures) >= 1, - 'expected parser crash signature to be recorded' -); -assert.ok( - Number(schedulerStats?.degradedVirtualPaths) >= 1, - 'expected degraded virtual paths to be tracked' -); -assert.ok( - typeof scheduler?.isDegradedVirtualPath === 'function', - 'expected degraded virtual path checker' -); -const crashSummary = scheduler.getCrashSummary(); -const degradedPerlVirtualPaths = crashSummary.degradedVirtualPaths.filter((virtualPath) => ( - virtualPath.includes('perl_advanced.pl') || virtualPath.includes('perl_sibling.pl') -)); -assert.ok(degradedPerlVirtualPaths.length >= 1, 'expected a failed perl virtual path to be marked degraded'); -assert.ok( - degradedPerlVirtualPaths.length <= 1, - `expected per-file degradation containment; got ${degradedPerlVirtualPaths.length} perl paths` -); -await fs.access(scheduler.crashForensicsBundlePath); -await fs.access(path.join(repoCacheRoot, 'logs', 'index-crash-forensics-index.json')); - -const rel = path.relative(root, perlAbs); -const relKey = rel.split(path.sep).join('/'); -const text = await fs.readFile(perlAbs, 'utf8'); -const fileStat = await fs.stat(perlAbs); -const languageHint = getLanguageForFile('.pl', relKey); -const noop = () => {}; -const timing = { - metricsCollector: null, - addSettingMetric: noop, - addLineSpan: noop, - addParseDuration: noop, - addTokenizeDuration: noop, - addEnrichDuration: noop, - addEmbeddingDuration: noop, - addLintDuration: noop, - addComplexityDuration: noop, - setGitDuration: noop, - setPythonAstDuration: noop -}; -const schedulerNoLoad = { - ...scheduler, - loadChunks: async () => { - throw new Error('scheduler loadChunks should not run for degraded virtual paths'); - }, - loadChunksBatch: async () => { - throw new Error('scheduler loadChunksBatch should not run for degraded virtual paths'); - } -}; - -const cpuResult = await processFileCpu({ - abs: perlAbs, - root, - mode: 'code', - fileEntry: { abs: perlAbs, rel: relKey }, - fileIndex: 1, - ext: '.pl', - rel, - relKey, - text, - fileStat, - fileHash: 'testhash', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: true - } - }, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedSegmentsConfig: normalizeSegmentsConfig(null), - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: noop, - showLineProgress: false, - toolInfo: null, - treeSitterScheduler: schedulerNoLoad, - timing, - languageHint, - crashLogger, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' -}); - -assert.ok(Array.isArray(cpuResult?.chunks) && cpuResult.chunks.length > 0, 'expected fallback chunks'); -assert.equal(cpuResult?.skip, null, 'expected no skip despite injected parser crash'); - -console.log('tree-sitter scheduler crash fallback ok'); diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-deferred-options.test.js b/tests/indexing/tree-sitter/tree-sitter-scheduler-deferred-options.test.js deleted file mode 100644 index 1e8420da2..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-scheduler-deferred-options.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveTreeSitterRuntime } from '../../../src/index/build/runtime/tree-sitter.js'; - -const defaults = resolveTreeSitterRuntime({}); -assert.equal(defaults.treeSitterScheduler.transport, 'disk', 'expected default scheduler transport=disk'); -assert.equal(defaults.treeSitterScheduler.sharedCache, false, 'expected default shared cache disabled'); -assert.equal(defaults.treeSitterScheduler.closeTimeoutMs, null, 'expected default scheduler close timeout unset'); -assert.equal(defaults.treeSitterScheduler.closeForceAfterMs, null, 'expected default scheduler force-close timeout unset'); - -const shmConfig = resolveTreeSitterRuntime({ - treeSitter: { - scheduler: { - transport: 'shm', - sharedCache: true, - lookup: { - maxOpenReaders: 12, - closeTimeoutMs: 7000, - closeForceAfterMs: 1500 - } - } - } -}); -assert.equal(shmConfig.treeSitterScheduler.transport, 'shm', 'expected explicit shm transport config'); -assert.equal(shmConfig.treeSitterScheduler.sharedCache, true, 'expected shared cache config to pass through'); -assert.equal(shmConfig.treeSitterScheduler.maxOpenReaders, 12, 'expected scheduler lookup reader cap to pass through'); -assert.equal(shmConfig.treeSitterScheduler.closeTimeoutMs, 7000, 'expected scheduler lookup close timeout to pass through'); -assert.equal(shmConfig.treeSitterScheduler.closeForceAfterMs, 1500, 'expected scheduler lookup force-close timeout to pass through'); -assert.equal(shmConfig.treeSitterScheduler.lookup.maxOpenReaders, 12, 'expected normalized lookup reader cap'); -assert.equal(shmConfig.treeSitterScheduler.lookup.closeTimeoutMs, 7000, 'expected normalized lookup close timeout'); -assert.equal(shmConfig.treeSitterScheduler.lookup.closeForceAfterMs, 1500, 'expected normalized lookup force-close timeout'); - -const invalidTransport = resolveTreeSitterRuntime({ - treeSitter: { - scheduler: { - transport: 'invalid' - } - } -}); -assert.equal(invalidTransport.treeSitterScheduler.transport, 'disk', 'expected invalid transport fallback to disk'); -assert.equal(invalidTransport.treeSitterScheduler.closeTimeoutMs, null, 'expected invalid scheduler timeout fallback to null'); - -console.log('tree-sitter scheduler deferred options test passed'); diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-native-plan-contract.test.js b/tests/indexing/tree-sitter/tree-sitter-scheduler-native-plan-contract.test.js deleted file mode 100644 index 883de2475..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-scheduler-native-plan-contract.test.js +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { buildTreeSitterSchedulerPlan } from '../../../src/index/build/tree-sitter-scheduler/plan.js'; -import { - getNativeTreeSitterParser, - preflightNativeTreeSitterGrammars, - resolveNativeTreeSitterTarget -} from '../../../src/lang/tree-sitter/native-runtime.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { skipIfNativeGrammarsUnavailable } from './native-availability.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'tree-sitter-scheduler-native-plan-contract', 'index-code'); -const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); - -await fs.access(jsAbs); -await fs.rm(outDir, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); - -const target = resolveNativeTreeSitterTarget('javascript', '.js'); -assert.ok(target, 'expected native target for javascript'); -assert.equal(target.grammarKey, 'native:javascript'); -assert.equal(target.runtimeKind, 'native'); -assert.equal(target.languageId, 'javascript'); - -const jsxTarget = resolveNativeTreeSitterTarget('jsx', '.jsx'); -assert.ok(jsxTarget, 'expected native target for jsx'); -assert.equal(jsxTarget.grammarKey, 'native:javascript'); -assert.equal(jsxTarget.runtimeKind, 'native'); -assert.equal(jsxTarget.languageId, 'jsx'); - -const luaTarget = resolveNativeTreeSitterTarget('lua', '.lua'); -assert.ok(luaTarget, 'expected native target for lua'); -assert.equal(luaTarget.grammarKey, 'native:lua'); -assert.equal(luaTarget.runtimeKind, 'native'); -assert.equal(luaTarget.languageId, 'lua'); - -const missingTarget = resolveNativeTreeSitterTarget('this-language-does-not-exist', '.xyz'); -assert.equal(missingTarget, null, 'expected null target for unsupported language'); - -const preflightFail = preflightNativeTreeSitterGrammars(['javascript', 'this-language-does-not-exist']); -assert.equal(preflightFail.ok, false, 'expected preflight failure'); -assert.ok( - Array.isArray(preflightFail.missing) && preflightFail.missing.includes('this-language-does-not-exist'), - 'expected missing language reported in preflight result' -); -if (skipIfNativeGrammarsUnavailable(['javascript', 'lua'], 'tree-sitter scheduler native plan contract')) { - process.exit(0); -} - -const luaPreflight = preflightNativeTreeSitterGrammars(['lua']); -const luaParser = getNativeTreeSitterParser('lua', { - treeSitter: { enabled: true, nativeOnly: true, strict: true }, - log: () => {} -}); -assert.ok(luaParser, 'expected lua parser to activate in native runtime'); -assert.ok( - !luaPreflight.unavailable.includes('lua'), - 'expected lua preflight to stay available when parser activation succeeds' -); - -const runtime = { - root, - segmentsConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: true - } - } -}; - -const planResult = await buildTreeSitterSchedulerPlan({ - mode: 'code', - runtime, - entries: [jsAbs], - outDir, - fileTextCache: null, - abortSignal: null, - log: () => {} -}); - -assert.ok(planResult, 'expected scheduler plan result'); -assert.ok(planResult.plan, 'expected scheduler plan'); -assert.ok( - Array.isArray(planResult.plan.grammarKeys) && planResult.plan.grammarKeys.includes('native:javascript'), - 'expected native grammar key in plan' -); -assert.ok( - Array.isArray(planResult.plan.requiredNativeLanguages) - && planResult.plan.requiredNativeLanguages.includes('javascript'), - 'expected required native language in plan' -); -for (const group of planResult.groups || []) { - for (const job of group.jobs || []) { - const signature = job?.fileVersionSignature; - assert.ok(signature && typeof signature === 'object', 'expected file version signature on scheduler jobs'); - assert.equal(typeof signature.hash, 'string', 'expected file signature hash'); - assert.equal(Number.isFinite(signature.size), true, 'expected file signature size'); - assert.equal(Number.isFinite(signature.mtimeMs), true, 'expected file signature mtimeMs'); - } -} - -console.log('tree-sitter scheduler native plan contract ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-scheduler-stage1-contract.test.js b/tests/indexing/tree-sitter/tree-sitter-scheduler-stage1-contract.test.js deleted file mode 100644 index 221e049d4..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-scheduler-stage1-contract.test.js +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { normalizeCommentConfig } from '../../../src/index/comments.js'; -import { getLanguageForFile } from '../../../src/index/language-registry.js'; -import { normalizeSegmentsConfig } from '../../../src/index/segments.js'; -import { processFileCpu } from '../../../src/index/build/file-processor/cpu.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const abs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); -const rel = path.relative(root, abs); -const relKey = rel.split(path.sep).join('/'); -const text = await fs.readFile(abs, 'utf8'); -const fileStat = await fs.stat(abs); -const languageHint = getLanguageForFile('.js', relKey); -const noop = () => {}; - -const timing = { - metricsCollector: null, - addSettingMetric: noop, - addLineSpan: noop, - addParseDuration: noop, - addTokenizeDuration: noop, - addEnrichDuration: noop, - addEmbeddingDuration: noop, - addLintDuration: noop, - addComplexityDuration: noop, - setGitDuration: noop, - setPythonAstDuration: noop -}; - -let schedulerCalls = 0; -const treeSitterScheduler = { - loadChunks: async () => { - schedulerCalls += 1; - return null; - } -}; - -await assert.rejects( - async () => processFileCpu({ - abs, - root, - mode: 'code', - fileEntry: { abs, rel: relKey }, - fileIndex: 1, - ext: '.js', - rel, - relKey, - text, - fileStat, - fileHash: 'testhash', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: true - } - }, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedSegmentsConfig: normalizeSegmentsConfig(null), - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: noop, - showLineProgress: false, - toolInfo: null, - treeSitterScheduler, - timing, - languageHint, - crashLogger: { enabled: false, updateFile: noop }, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' - }), - /Missing scheduled chunks/ -); -assert.ok(schedulerCalls > 0, 'expected scheduler to be consulted for tree-sitter chunks'); - -let fallbackSchedulerCalls = 0; -const fallbackScheduler = { - index: new Map(), - loadChunks: async () => { - fallbackSchedulerCalls += 1; - return null; - } -}; -const fallbackResult = await processFileCpu({ - abs, - root, - mode: 'code', - fileEntry: { abs, rel: relKey }, - fileIndex: 1, - ext: '.js', - rel, - relKey, - text, - fileStat, - fileHash: 'testhash', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: false - } - }, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedSegmentsConfig: normalizeSegmentsConfig(null), - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: noop, - showLineProgress: false, - toolInfo: null, - treeSitterScheduler: fallbackScheduler, - timing, - languageHint, - crashLogger: { enabled: false, updateFile: noop }, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' -}); -assert.ok(fallbackSchedulerCalls > 0, 'expected scheduler lookup attempts in non-strict mode'); -assert.ok(Array.isArray(fallbackResult?.chunks) && fallbackResult.chunks.length > 0, 'expected fallback chunking to produce chunks'); - -let unsupportedLanguageSchedulerCalls = 0; -const unsupportedLanguageWarnings = []; -const unsupportedLanguageScheduler = { - index: new Map(), - scheduledLanguageIds: new Set(['lua']), - loadChunks: async () => { - unsupportedLanguageSchedulerCalls += 1; - return null; - } -}; -const unsupportedLanguageResult = await processFileCpu({ - abs, - root, - mode: 'code', - fileEntry: { abs, rel: relKey }, - fileIndex: 1, - ext: '.js', - rel, - relKey, - text, - fileStat, - fileHash: 'testhash', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: false - } - }, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedSegmentsConfig: normalizeSegmentsConfig(null), - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: (line) => unsupportedLanguageWarnings.push(String(line || '')), - showLineProgress: false, - toolInfo: null, - treeSitterScheduler: unsupportedLanguageScheduler, - timing, - languageHint, - crashLogger: { enabled: false, updateFile: noop }, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' -}); -assert.equal( - unsupportedLanguageSchedulerCalls, - 0, - 'expected scheduler lookup to be skipped when scheduler has no coverage for the language' -); -assert.ok( - Array.isArray(unsupportedLanguageResult?.chunks) && unsupportedLanguageResult.chunks.length > 0, - 'expected fallback chunking to produce chunks when scheduler lacks language coverage' -); -assert.equal( - unsupportedLanguageWarnings.some((line) => line.includes('[tree-sitter:schedule] scheduler missing')), - false, - 'expected no scheduler-missing warning spam when language coverage is absent' -); - -const proseResult = await processFileCpu({ - abs, - root, - mode: 'prose', - fileEntry: { abs, rel: relKey }, - fileIndex: 1, - ext: '.js', - rel, - relKey, - text, - fileStat, - fileHash: 'testhash', - fileHashAlgo: 'sha1', - fileCaps: null, - fileStructural: null, - scmProvider: null, - scmProviderImpl: null, - scmRepoRoot: null, - scmConfig: null, - languageOptions: { - treeSitter: { - enabled: false - } - }, - astDataflowEnabled: false, - controlFlowEnabled: false, - normalizedSegmentsConfig: normalizeSegmentsConfig(null), - normalizedCommentsConfig: normalizeCommentConfig(null), - tokenDictWords: new Set(), - dictConfig: {}, - tokenContext: { - dictWords: new Set(), - dictConfig: {}, - codeDictCache: new Map(), - tokenClassification: { enabled: false }, - phraseEnabled: false, - chargramEnabled: false - }, - postingsConfig: {}, - contextWin: {}, - relationsEnabled: false, - lintEnabled: false, - complexityEnabled: false, - typeInferenceEnabled: false, - riskAnalysisEnabled: false, - riskConfig: {}, - gitBlameEnabled: false, - analysisPolicy: null, - workerPool: null, - workerDictOverride: null, - workerState: {}, - tokenizationStats: null, - embeddingEnabled: false, - embeddingNormalize: false, - embeddingBatchSize: 0, - getChunkEmbedding: null, - getChunkEmbeddings: null, - runEmbedding: (fn) => fn(), - runProc: (fn) => fn(), - runTreeSitterSerial: (fn) => fn(), - runIo: (fn) => fn(), - log: noop, - logLine: noop, - showLineProgress: false, - toolInfo: null, - treeSitterScheduler: null, - timing, - languageHint, - crashLogger: { enabled: false, updateFile: noop }, - vfsManifestConcurrency: 1, - complexityCache: null, - lintCache: null, - buildStage: 'stage1' -}); -assert.ok(Array.isArray(proseResult?.chunks), 'expected prose mode to complete without scheduler'); - -console.log('tree-sitter scheduler stage1 contract ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-timeout-disable.test.js b/tests/indexing/tree-sitter/tree-sitter-timeout-disable.test.js deleted file mode 100644 index 77ca3fe59..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-timeout-disable.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { - buildTreeSitterChunks, - getTreeSitterStats, - resetTreeSitterStats -} from '../../../src/lang/tree-sitter.js'; -import { treeSitterState } from '../../../src/lang/tree-sitter/state.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv({ testing: '1' }); - -resetTreeSitterStats(); -treeSitterState.disabledLanguages = new Set(['javascript']); - -const options = { - treeSitter: { enabled: true, useQueries: false }, - log: () => {} -}; -const text = 'function demo() { return 1; }'; - -const before = getTreeSitterStats(); -const result = buildTreeSitterChunks({ text, languageId: 'javascript', options }); -const after = getTreeSitterStats(); -assert.equal(result, null, 'expected disabled language to fall back'); -assert.equal( - Number(after.fallbacks) - Number(before.fallbacks), - 1, - 'expected disabled-language path to increment fallback metric' -); - -console.log('tree-sitter disabled-language fallback ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-worker-prune-bounds.test.js b/tests/indexing/tree-sitter/tree-sitter-worker-prune-bounds.test.js deleted file mode 100644 index eec9fbe9f..000000000 --- a/tests/indexing/tree-sitter/tree-sitter-worker-prune-bounds.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { pruneTreeSitterLanguages } from '../../../src/lang/tree-sitter.js'; -import { treeSitterState } from '../../../src/lang/tree-sitter/state.js'; -import { LANGUAGE_GRAMMAR_KEYS } from '../../../src/lang/tree-sitter/config.js'; - -const seedCaches = () => { - treeSitterState.TreeSitter = treeSitterState.TreeSitter || {}; - treeSitterState.grammarCache.clear(); - treeSitterState.languageCache.clear(); - - const add = (lang) => { - const runtimeKey = LANGUAGE_GRAMMAR_KEYS[lang]; - treeSitterState.grammarCache.set(runtimeKey, { language: null, error: null }); - treeSitterState.languageCache.set(lang, { language: null, error: null }); - }; - - add('javascript'); - add('python'); - add('go'); -}; - -seedCaches(); - -const result = pruneTreeSitterLanguages(['python'], { skipDispose: true }); -assert.equal(result.removed, 0, 'prune should not evict native runtime entries'); - -const remaining = Array.from(treeSitterState.grammarCache.keys()); -assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.javascript), 'expected javascript grammar entry to remain'); -assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.python), 'expected python grammar entry to remain'); -assert.ok(remaining.includes(LANGUAGE_GRAMMAR_KEYS.go), 'expected go grammar entry to remain'); - -console.log('tree-sitter worker prune bounds ok'); - diff --git a/tests/indexing/tree-sitter/tree-sitter-vfs-language-routing.test.js b/tests/indexing/tree-sitter/vfs-language-routing.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-vfs-language-routing.test.js rename to tests/indexing/tree-sitter/vfs-language-routing.test.js diff --git a/tests/indexing/tree-sitter/tree-sitter-xml-runtime.test.js b/tests/indexing/tree-sitter/xml-runtime.test.js similarity index 100% rename from tests/indexing/tree-sitter/tree-sitter-xml-runtime.test.js rename to tests/indexing/tree-sitter/xml-runtime.test.js diff --git a/tests/indexing/type-inference/crossfile-cache-fingerprint-lite-knobs.test.js b/tests/indexing/type-inference/crossfile-cache-fingerprint-lite-knobs.test.js new file mode 100644 index 000000000..6b3b9b790 --- /dev/null +++ b/tests/indexing/type-inference/crossfile-cache-fingerprint-lite-knobs.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildCrossFileFingerprint } from '../../../src/index/type-inference-crossfile/cache.js'; + +const chunks = [{ + file: 'src/app.js', + name: 'run', + kind: 'function', + start: 0, + end: 10, + chunkUid: 'chunk-1', + codeRelations: { calls: [['run', 'helper']], callDetails: [], usages: [] }, + docmeta: { returnType: 'void' } +}]; + +const base = { + chunks, + enableTypeInference: true, + enableRiskCorrelation: true, + useTooling: true, + fileRelations: null +}; + +const fullFingerprint = buildCrossFileFingerprint({ + ...base, + inferenceLite: false, + inferenceLiteHighSignalOnly: true +}); +const liteFingerprint = buildCrossFileFingerprint({ + ...base, + inferenceLite: true, + inferenceLiteHighSignalOnly: true +}); +const liteBroadFingerprint = buildCrossFileFingerprint({ + ...base, + inferenceLite: true, + inferenceLiteHighSignalOnly: false +}); + +assert.notEqual( + fullFingerprint, + liteFingerprint, + 'expected inferenceLite mode to change cross-file cache fingerprint' +); +assert.notEqual( + liteFingerprint, + liteBroadFingerprint, + 'expected inferenceLiteHighSignalOnly to change cross-file cache fingerprint' +); + +console.log('crossfile cache fingerprint lite knobs test passed'); diff --git a/tests/indexing/type-inference/crossfile/artifact-fixture.js b/tests/indexing/type-inference/crossfile/artifact-fixture.js new file mode 100644 index 000000000..69c080a35 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/artifact-fixture.js @@ -0,0 +1,22 @@ +import { MAX_JSON_BYTES, loadChunkMeta, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; +import { getIndexDir, loadUserConfig } from '../../../../tools/shared/dict-utils.js'; + +export const loadCodeChunkArtifacts = async (repoDir, label) => { + const userConfig = loadUserConfig(repoDir); + const codeDir = getIndexDir(repoDir, 'code', userConfig); + try { + const chunkMeta = await loadChunkMeta(codeDir, { maxBytes: MAX_JSON_BYTES, strict: true }); + const fileMeta = await loadJsonArrayArtifact(codeDir, 'file_meta', { maxBytes: MAX_JSON_BYTES, strict: true }); + const fileById = new Map( + (Array.isArray(fileMeta) ? fileMeta : []).map((entry) => [entry.id, entry.file]) + ); + return { + chunkMeta, + fileMeta, + resolveChunkFile: (chunk) => chunk?.file || fileById.get(chunk?.fileId) || null + }; + } catch (err) { + console.error(`Failed to load ${label} artifacts at ${codeDir}: ${err?.message || err}`); + process.exit(1); + } +}; diff --git a/tests/indexing/type-inference/crossfile/cache-fingerprint-and-size-cap.test.js b/tests/indexing/type-inference/crossfile/cache-fingerprint-and-size-cap.test.js index 6cd1fce8e..308b2a1b1 100644 --- a/tests/indexing/type-inference/crossfile/cache-fingerprint-and-size-cap.test.js +++ b/tests/indexing/type-inference/crossfile/cache-fingerprint-and-size-cap.test.js @@ -5,6 +5,7 @@ import path from 'node:path'; import { buildCrossFileFingerprint, + readCrossFileInferenceCache, writeCrossFileInferenceCache } from '../../../../src/index/type-inference-crossfile/cache.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; @@ -121,6 +122,90 @@ assert.equal(writtenPayload.fingerprint, 'small-fingerprint', 'expected cache fi assert.equal(Array.isArray(writtenPayload.rows), true, 'expected persisted rows array'); assert.equal(writtenPayload.rows.length, 1, 'expected one cached row for compact input'); +const partialCachePath = path.join(cacheDir, 'output-cache-partial.json'); +const partialLogs = []; +const partialChunks = [ + { + chunkUid: 'uid:small-relations', + file: 'src/small-relations.js', + name: 'smallRelations', + kind: 'function', + start: 0, + end: 1, + codeRelations: { + calls: [['smallRelations', 'callee']] + }, + docmeta: null + }, + { + chunkUid: 'uid:huge-docmeta', + file: 'src/huge-docmeta.js', + name: 'hugeDocmeta', + kind: 'function', + start: 0, + end: 1, + codeRelations: null, + docmeta: { + summary: 'y'.repeat(4096) + } + } +]; + +await writeCrossFileInferenceCache({ + cacheDir, + cachePath: partialCachePath, + chunks: partialChunks, + crossFileFingerprint: 'partial-fingerprint', + stats: { + linkedCalls: 1 + }, + maxBytes: 700, + log: (line) => partialLogs.push(String(line || '')) +}); + +const partialPayload = JSON.parse(await fs.readFile(partialCachePath, 'utf8')); +assert.equal(partialPayload.admission?.mode, 'value-ranked-partial', 'expected partial admission mode'); +assert.equal(partialPayload.admission?.retainedRows, 1, 'expected one retained row under tight cap'); +assert.equal(partialPayload.admission?.droppedRows, 1, 'expected one dropped row under tight cap'); +assert.deepEqual( + partialPayload.admission?.breakdown?.retained?.counts, + { 'relations-only': 1 }, + 'expected higher-value relations row to be retained' +); +assert.deepEqual( + partialPayload.admission?.breakdown?.dropped?.counts, + { 'docmeta-only': 1 }, + 'expected oversized docmeta row to be dropped' +); +assert.equal(partialPayload.rows.length, 1, 'expected one persisted row after partial admission'); +assert.equal(partialPayload.rows[0]?.id, 'uid:small-relations', 'expected retained row to be the smaller higher-value entry'); +assert.equal( + partialLogs.some((line) => line.includes('cross-file cache write truncated')), + true, + 'expected truncation log for partial cache admission' +); + +const restoredChunks = partialChunks.map((chunk) => ({ + ...chunk, + codeRelations: null, + docmeta: null +})); +const readLogs = []; +const restoredStats = await readCrossFileInferenceCache({ + cachePath: partialCachePath, + chunks: restoredChunks, + crossFileFingerprint: 'partial-fingerprint', + log: (line) => readLogs.push(String(line || '')) +}); +assert.ok(restoredStats && typeof restoredStats === 'object', 'expected partial cache read to restore stats'); +assert.ok(restoredChunks[0]?.codeRelations, 'expected retained row relations to restore from partial cache'); +assert.equal(restoredChunks[1]?.docmeta, null, 'expected dropped row docmeta to remain absent'); +assert.equal( + readLogs.some((line) => line.includes('partial, dropped=1')), + true, + 'expected partial cache hit log to surface dropped rows' +); + await fs.rm(tempRoot, { recursive: true, force: true }); console.log('cross-file cache fingerprint and size cap test passed'); diff --git a/tests/indexing/type-inference/crossfile/crossfile-inference-lite-profile.test.js b/tests/indexing/type-inference/crossfile/crossfile-inference-lite-profile.test.js deleted file mode 100644 index 9f39c40a3..000000000 --- a/tests/indexing/type-inference/crossfile/crossfile-inference-lite-profile.test.js +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; -import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'crossfile-inference-lite-profile'); -const srcDir = path.join(tempRoot, 'src'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcDir, { recursive: true }); - -const calleeText = 'export function sinkFn(value) { return value; }\n'; -const callerText = 'export function caller(input) { return sinkFn("abc"); }\n'; -await fs.writeFile(path.join(srcDir, 'callee.js'), calleeText, 'utf8'); -await fs.writeFile(path.join(srcDir, 'caller.js'), callerText, 'utf8'); - -const createChunks = () => ([ - { - chunkUid: 'uid:callee', - file: 'src/callee.js', - name: 'sinkFn', - kind: 'function', - start: 0, - end: calleeText.length, - metaV2: { - symbol: { - symbolId: 'sym:callee', - symbolKey: 'src/callee.js::sinkFn', - chunkUid: 'uid:callee' - } - }, - codeRelations: {}, - docmeta: { - paramNames: ['value'], - inferredTypes: { returns: [{ type: 'string', source: 'declared', confidence: 0.9 }] }, - risk: { - sinks: [{ name: 'db.exec', category: 'sql-injection', severity: 'high', ruleId: 'sink-rule' }], - tags: ['security'] - } - } - }, - { - chunkUid: 'uid:caller', - file: 'src/caller.js', - name: 'caller', - kind: 'function', - start: 0, - end: callerText.length, - metaV2: { - symbol: { - symbolId: 'sym:caller', - symbolKey: 'src/caller.js::caller', - chunkUid: 'uid:caller' - } - }, - codeRelations: { - calls: [[0, 'sinkFn']], - callDetails: [{ callee: 'sinkFn', args: ['"abc"'] }] - }, - docmeta: { - risk: { - sources: [{ name: 'http.input', ruleId: 'source-rule', confidence: 0.8 }] - } - } - } -]); - -const runMode = async ({ inferenceLite }) => { - const chunks = createChunks(); - const stats = await applyCrossFileInference({ - rootDir: tempRoot, - buildRoot: tempRoot, - cacheEnabled: false, - chunks, - enabled: true, - enableTypeInference: true, - enableRiskCorrelation: true, - inferenceLite, - inferenceLiteHighSignalOnly: true, - fileRelations: null, - log: () => {} - }); - return { chunks, stats }; -}; - -const full = await runMode({ inferenceLite: false }); -const lite = await runMode({ inferenceLite: true }); - -assert.ok(full.stats.linkedCalls > 0, 'expected full mode to emit call links'); -assert.ok(full.stats.inferredReturns > 0, 'expected full mode to infer return types'); -assert.ok(full.stats.riskFlows > 0, 'expected full mode to emit risk flows'); - -assert.ok(lite.stats.linkedCalls > 0, 'expected lite mode to preserve call link emission'); -assert.equal(lite.stats.inferredReturns, 0, 'expected lite mode to skip return inference'); -assert.equal(lite.stats.riskFlows, 0, 'expected lite mode to skip risk propagation'); -assert.equal(lite.stats.inferenceLiteEnabled, true, 'expected lite-mode telemetry flag'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('cross-file inference lite profile test passed'); diff --git a/tests/indexing/type-inference/crossfile/crossfile-output.integration.test.js b/tests/indexing/type-inference/crossfile/crossfile-output.integration.test.js deleted file mode 100644 index d6d86941a..000000000 --- a/tests/indexing/type-inference/crossfile/crossfile-output.integration.test.js +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { MAX_JSON_BYTES, loadChunkMeta, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; -import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'type-inference-crossfile-integration'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(path.join(repoRootRaw, 'src'), { recursive: true }); -const repoRoot = toRealPathSync(repoRootRaw); - -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'creator.js'), - `/** - * @returns {Widget} - */ -export function createWidget() { - return new Widget(); -} - -export class Widget { - constructor() { - this.id = 1; - } -} -` -); - -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'consumer.js'), - `import { createWidget, Widget } from './creator.js'; - -export function buildWidget() { - const widget = new Widget(); - return createWidget(); -} -` -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' }, - typeInference: true, - typeInferenceCrossFile: true - }, - tooling: { - autoEnableOnDetect: false - } - } -}); -const buildTimeoutMs = Number.isFinite(Number(process.env.PAIROFCLEATS_TEST_TIMEOUT_MS)) - ? Math.max(180000, Number(process.env.PAIROFCLEATS_TEST_TIMEOUT_MS)) - : 180000; - -const result = spawnSync(process.execPath, [ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--stage', - 'stage2', - '--repo', - repoRoot -], { - cwd: repoRoot, - env, - timeout: buildTimeoutMs, - killSignal: 'SIGTERM', - stdio: 'inherit' -}); -if (result.status !== 0) { - console.error('Cross-file inference integration test failed: build_index failed.'); - process.exit(result.status ?? 1); -} - -const userConfig = loadUserConfig(repoRoot); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -let chunkMeta = []; -let fileMeta = []; -try { - chunkMeta = await loadChunkMeta(codeDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - fileMeta = await loadJsonArrayArtifact(codeDir, 'file_meta', { maxBytes: MAX_JSON_BYTES, strict: true }); -} catch (err) { - console.error(`Failed to load inference artifacts at ${codeDir}: ${err?.message || err}`); - process.exit(1); -} -const fileById = new Map( - (Array.isArray(fileMeta) ? fileMeta : []).map((entry) => [entry.id, entry.file]) -); -const resolveChunkFile = (chunk) => chunk?.file || fileById.get(chunk?.fileId) || null; - -const buildWidget = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/consumer.js' - && chunk.name === 'buildWidget' -); -if (!buildWidget) { - console.error('Missing buildWidget chunk in consumer.js.'); - process.exit(1); -} - -const inferredReturns = buildWidget.docmeta?.inferredTypes?.returns || []; -if (!inferredReturns.some((entry) => entry.type === 'Widget' && entry.source === 'flow')) { - console.error('Cross-file inference missing return type Widget for buildWidget.'); - process.exit(1); -} - -const callLinks = buildWidget.codeRelations?.callLinks || []; -if (!callLinks.some((link) => - link.to?.status === 'resolved' - && link.legacy?.target === 'createWidget' - && link.legacy?.file === 'src/creator.js' -)) { - console.error('Cross-file inference missing call link to createWidget.'); - process.exit(1); -} - -const usageLinks = buildWidget.codeRelations?.usageLinks || []; -if (!usageLinks.some((link) => - link.to?.status === 'resolved' - && link.legacy?.target === 'Widget' - && link.legacy?.file === 'src/creator.js' -)) { - console.error('Cross-file inference missing usage link to Widget.'); - process.exit(1); -} - -console.log('Cross-file inference integration output ok.'); - diff --git a/tests/indexing/type-inference/crossfile/crossfile-propagation-parallel-mode.test.js b/tests/indexing/type-inference/crossfile/crossfile-propagation-parallel-mode.test.js deleted file mode 100644 index 8640faa10..000000000 --- a/tests/indexing/type-inference/crossfile/crossfile-propagation-parallel-mode.test.js +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; -import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -applyTestEnv({ - testing: '1', - extraEnv: { - PAIROFCLEATS_CROSSFILE_PROPAGATION_PARALLEL: '1', - PAIROFCLEATS_CROSSFILE_PROPAGATION_PARALLEL_MIN_BUNDLE: '1' - } -}); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'crossfile-propagation-parallel-mode'); -const srcDir = path.join(tempRoot, 'src'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcDir, { recursive: true }); - -const calleeText = 'export function sinkFn(value) { return value; }\n'; -const callerText = 'export function caller(input) { return sinkFn("abc"); }\n'; -await fs.writeFile(path.join(srcDir, 'callee.js'), calleeText, 'utf8'); -await fs.writeFile(path.join(srcDir, 'caller.js'), callerText, 'utf8'); - -const createChunks = () => ([ - { - chunkUid: 'uid:callee', - file: 'src/callee.js', - name: 'sinkFn', - kind: 'function', - start: 0, - end: calleeText.length, - metaV2: { - symbol: { - symbolId: 'sym:callee', - symbolKey: 'src/callee.js::sinkFn', - chunkUid: 'uid:callee' - } - }, - codeRelations: {}, - docmeta: { - paramNames: ['value'], - risk: { - sinks: [ - { - name: 'db.exec', - category: 'sql-injection', - severity: 'high', - ruleId: 'sink-rule' - } - ], - tags: ['security'] - } - } - }, - { - chunkUid: 'uid:caller', - file: 'src/caller.js', - name: 'caller', - kind: 'function', - start: 0, - end: callerText.length, - metaV2: { - symbol: { - symbolId: 'sym:caller', - symbolKey: 'src/caller.js::caller', - chunkUid: 'uid:caller' - } - }, - codeRelations: { - calls: [[0, 'sinkFn']], - callDetails: [ - { - callee: 'sinkFn', - args: ['"abc"'] - } - ] - }, - docmeta: { - risk: { - sources: [ - { - name: 'http.input', - ruleId: 'source-rule', - confidence: 0.8 - } - ] - } - } - } -]); - -const runOnce = async () => { - const logs = []; - const chunks = createChunks(); - const stats = await applyCrossFileInference({ - rootDir: tempRoot, - buildRoot: tempRoot, - cacheEnabled: false, - chunks, - enabled: true, - enableTypeInference: true, - enableRiskCorrelation: true, - log: (line) => logs.push(String(line || '')), - fileRelations: null - }); - return { stats, logs, chunks }; -}; - -const first = await runOnce(); -const second = await runOnce(); - -assert.ok( - first.logs.some((line) => line.includes('cross-file propagation parallel mode enabled')), - 'expected propagation parallel mode log when bundle threshold is met' -); -assert.ok(first.stats.linkedCalls >= 1, 'expected call links to be generated'); -assert.ok(first.stats.riskFlows >= 1, 'expected risk flow propagation to run'); - -const callee = first.chunks.find((chunk) => chunk.chunkUid === 'uid:callee'); -const caller = first.chunks.find((chunk) => chunk.chunkUid === 'uid:caller'); -assert.ok(callee, 'expected callee chunk'); -assert.ok(caller, 'expected caller chunk'); -const inferredParams = callee.docmeta?.inferredTypes?.params?.value || []; -assert.ok( - inferredParams.some((entry) => entry.type === 'string' && entry.source === 'flow'), - 'expected type propagation to infer string param type on callee' -); -assert.ok( - Array.isArray(caller.docmeta?.risk?.flows) && caller.docmeta.risk.flows.length > 0, - 'expected risk propagation to add cross-file flow on caller chunk' -); - -const snapshot = (run) => JSON.stringify( - run.chunks.map((chunk) => ({ - uid: chunk.chunkUid, - callLinks: chunk.codeRelations?.callLinks || [], - callSummaries: chunk.codeRelations?.callSummaries || [], - inferredParams: chunk.docmeta?.inferredTypes?.params || null, - riskFlows: chunk.docmeta?.risk?.flows || [] - })) -); -assert.equal(snapshot(second), snapshot(first), 'parallel propagation should remain deterministic'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('cross-file propagation parallel mode test passed'); diff --git a/tests/indexing/type-inference/crossfile/inference-lite-profile.test.js b/tests/indexing/type-inference/crossfile/inference-lite-profile.test.js new file mode 100644 index 000000000..e9fd0180a --- /dev/null +++ b/tests/indexing/type-inference/crossfile/inference-lite-profile.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; + +import { + cleanupSinkCallFixture, + createSinkCallChunks, + prepareSinkCallFixture +} from './sink-call-fixture.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const { tempRoot } = await prepareSinkCallFixture(root, 'crossfile-inference-lite-profile'); + +const runMode = async ({ inferenceLite }) => { + const chunks = createSinkCallChunks({ + calleeInferredTypes: { returns: [{ type: 'string', source: 'declared', confidence: 0.9 }] } + }); + const stats = await applyCrossFileInference({ + rootDir: tempRoot, + buildRoot: tempRoot, + cacheEnabled: false, + chunks, + enabled: true, + enableTypeInference: true, + enableRiskCorrelation: true, + inferenceLite, + inferenceLiteHighSignalOnly: true, + fileRelations: null, + log: () => {} + }); + return { chunks, stats }; +}; + +const full = await runMode({ inferenceLite: false }); +const lite = await runMode({ inferenceLite: true }); + +assert.ok(full.stats.linkedCalls > 0, 'expected full mode to emit call links'); +assert.ok(full.stats.inferredReturns > 0, 'expected full mode to infer return types'); +assert.ok(full.stats.riskFlows > 0, 'expected full mode to emit risk flows'); + +assert.ok(lite.stats.linkedCalls > 0, 'expected lite mode to preserve call link emission'); +assert.equal(lite.stats.inferredReturns, 0, 'expected lite mode to skip return inference'); +assert.equal(lite.stats.riskFlows, 0, 'expected lite mode to skip risk propagation'); +assert.equal(lite.stats.inferenceLiteEnabled, true, 'expected lite-mode telemetry flag'); + +await cleanupSinkCallFixture(tempRoot); + +console.log('cross-file inference lite profile test passed'); diff --git a/tests/indexing/type-inference/crossfile/output.integration.test.js b/tests/indexing/type-inference/crossfile/output.integration.test.js new file mode 100644 index 000000000..0ce9a5939 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/output.integration.test.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { toRealPathSync } from '../../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { loadCodeChunkArtifacts } from './artifact-fixture.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'type-inference-crossfile-integration'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoRootRaw, 'src'), { recursive: true }); +const repoRoot = toRealPathSync(repoRootRaw); + +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'creator.js'), + `/** + * @returns {Widget} + */ +export function createWidget() { + return new Widget(); +} + +export class Widget { + constructor() { + this.id = 1; + } +} +` +); + +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'consumer.js'), + `import { createWidget, Widget } from './creator.js'; + +export function buildWidget() { + const widget = new Widget(); + return createWidget(); +} +` +); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: true, + typeInferenceCrossFile: true + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); +const buildTimeoutMs = Number.isFinite(Number(process.env.PAIROFCLEATS_TEST_TIMEOUT_MS)) + ? Math.max(180000, Number(process.env.PAIROFCLEATS_TEST_TIMEOUT_MS)) + : 180000; + +const result = runNode([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage2', + '--mode', + 'code', + '--repo', + repoRoot +], 'cross-file inference integration build index', repoRoot, env, { + timeoutMs: buildTimeoutMs, + stdio: 'inherit', + allowFailure: true +}); +if (result.status !== 0) { + console.error('Cross-file inference integration test failed: build_index failed.'); + process.exit(result.status ?? 1); +} + +const { chunkMeta, resolveChunkFile } = await loadCodeChunkArtifacts(repoRoot, 'inference'); + +const buildWidget = chunkMeta.find((chunk) => + resolveChunkFile(chunk) === 'src/consumer.js' + && chunk.name === 'buildWidget' +); +if (!buildWidget) { + console.error('Missing buildWidget chunk in consumer.js.'); + process.exit(1); +} + +const inferredReturns = buildWidget.docmeta?.inferredTypes?.returns || []; +if (!inferredReturns.some((entry) => entry.type === 'Widget' && entry.source === 'flow')) { + console.error('Cross-file inference missing return type Widget for buildWidget.'); + process.exit(1); +} + +const callLinks = buildWidget.codeRelations?.callLinks || []; +if (!callLinks.some((link) => + link.to?.status === 'resolved' + && link.legacy?.target === 'createWidget' + && link.legacy?.file === 'src/creator.js' +)) { + console.error('Cross-file inference missing call link to createWidget.'); + process.exit(1); +} + +const usageLinks = buildWidget.codeRelations?.usageLinks || []; +if (!usageLinks.some((link) => + link.to?.status === 'resolved' + && link.legacy?.target === 'Widget' + && link.legacy?.file === 'src/creator.js' +)) { + console.error('Cross-file inference missing usage link to Widget.'); + process.exit(1); +} + +console.log('Cross-file inference integration output ok.'); + diff --git a/tests/indexing/type-inference/crossfile/propagation-parallel-mode.test.js b/tests/indexing/type-inference/crossfile/propagation-parallel-mode.test.js new file mode 100644 index 000000000..e46d7bca6 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/propagation-parallel-mode.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; + +import { + cleanupSinkCallFixture, + createSinkCallChunks, + prepareSinkCallFixture +} from './sink-call-fixture.js'; + +applyTestEnv({ + testing: '1', + extraEnv: { + PAIROFCLEATS_CROSSFILE_PROPAGATION_PARALLEL: '1', + PAIROFCLEATS_CROSSFILE_PROPAGATION_PARALLEL_MIN_BUNDLE: '1' + } +}); + +const root = process.cwd(); +const { tempRoot } = await prepareSinkCallFixture(root, 'crossfile-propagation-parallel-mode'); + +const runOnce = async () => { + const logs = []; + const chunks = createSinkCallChunks(); + const stats = await applyCrossFileInference({ + rootDir: tempRoot, + buildRoot: tempRoot, + cacheEnabled: false, + chunks, + enabled: true, + enableTypeInference: true, + enableRiskCorrelation: true, + log: (line) => logs.push(String(line || '')), + fileRelations: null + }); + return { stats, logs, chunks }; +}; + +const first = await runOnce(); +const second = await runOnce(); + +assert.ok( + first.logs.some((line) => line.includes('cross-file propagation parallel mode enabled')), + 'expected propagation parallel mode log when bundle threshold is met' +); +assert.ok(first.stats.linkedCalls >= 1, 'expected call links to be generated'); +assert.ok(first.stats.riskFlows >= 1, 'expected risk flow propagation to run'); + +const callee = first.chunks.find((chunk) => chunk.chunkUid === 'uid:callee'); +const caller = first.chunks.find((chunk) => chunk.chunkUid === 'uid:caller'); +assert.ok(callee, 'expected callee chunk'); +assert.ok(caller, 'expected caller chunk'); +const inferredParams = callee.docmeta?.inferredTypes?.params?.value || []; +assert.ok( + inferredParams.some((entry) => entry.type === 'string' && entry.source === 'flow'), + 'expected type propagation to infer string param type on callee' +); +assert.ok( + Array.isArray(caller.docmeta?.risk?.flows) && caller.docmeta.risk.flows.length > 0, + 'expected risk propagation to add cross-file flow on caller chunk' +); + +const snapshot = (run) => JSON.stringify( + run.chunks.map((chunk) => ({ + uid: chunk.chunkUid, + callLinks: chunk.codeRelations?.callLinks || [], + callSummaries: chunk.codeRelations?.callSummaries || [], + inferredParams: chunk.docmeta?.inferredTypes?.params || null, + riskFlows: chunk.docmeta?.risk?.flows || [] + })) +); +assert.equal(snapshot(second), snapshot(first), 'parallel propagation should remain deterministic'); + +await cleanupSinkCallFixture(tempRoot); + +console.log('cross-file propagation parallel mode test passed'); diff --git a/tests/indexing/type-inference/crossfile/prototype-param-name-regression.test.js b/tests/indexing/type-inference/crossfile/prototype-param-name-regression.test.js index 05d8932f2..a594a1188 100644 --- a/tests/indexing/type-inference/crossfile/prototype-param-name-regression.test.js +++ b/tests/indexing/type-inference/crossfile/prototype-param-name-regression.test.js @@ -1,71 +1,27 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; import { applyTestEnv } from '../../../helpers/test-env.js'; import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + cleanupSinkCallFixture, + createSinkCallChunks, + prepareSinkCallFixture +} from './sink-call-fixture.js'; applyTestEnv({ testing: '1' }); const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'crossfile-prototype-param-name-regression'); -const srcDir = path.join(tempRoot, 'src'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcDir, { recursive: true }); - -const calleeText = 'export function sinkFn(toString) { return toString; }\n'; -const callerText = 'export function caller(input) { return sinkFn("abc"); }\n'; -await fs.writeFile(path.join(srcDir, 'callee.js'), calleeText, 'utf8'); -await fs.writeFile(path.join(srcDir, 'caller.js'), callerText, 'utf8'); - -const chunks = [ - { - chunkUid: 'uid:callee', - file: 'src/callee.js', - name: 'sinkFn', - kind: 'function', - start: 0, - end: calleeText.length, - metaV2: { - symbol: { - symbolId: 'sym:callee', - symbolKey: 'src/callee.js::sinkFn', - chunkUid: 'uid:callee' - } - }, - codeRelations: {}, - docmeta: { - paramNames: ['toString'] - } - }, - { - chunkUid: 'uid:caller', - file: 'src/caller.js', - name: 'caller', - kind: 'function', - start: 0, - end: callerText.length, - metaV2: { - symbol: { - symbolId: 'sym:caller', - symbolKey: 'src/caller.js::caller', - chunkUid: 'uid:caller' - } - }, - codeRelations: { - calls: [[0, 'sinkFn']], - callDetails: [ - { - callee: 'sinkFn', - args: ['"abc"'] - } - ] - }, - docmeta: {} - } -]; +const { tempRoot } = await prepareSinkCallFixture( + root, + 'crossfile-prototype-param-name-regression', + { paramName: 'toString' } +); +const chunks = createSinkCallChunks({ + paramName: 'toString', + calleeRisk: null, + callerDocmeta: {} +}); const stats = await applyCrossFileInference({ rootDir: tempRoot, @@ -89,6 +45,6 @@ assert.ok( 'expected flow inference to support prototype-key param names' ); -await fs.rm(tempRoot, { recursive: true, force: true }); +await cleanupSinkCallFixture(tempRoot); console.log('crossfile prototype-key param name regression test passed'); diff --git a/tests/indexing/type-inference/crossfile/sink-call-fixture.js b/tests/indexing/type-inference/crossfile/sink-call-fixture.js new file mode 100644 index 000000000..8331a2cf1 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/sink-call-fixture.js @@ -0,0 +1,107 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const callerText = 'export function caller(input) { return sinkFn("abc"); }\n'; + +export const createSinkCallCalleeText = (paramName = 'value') => ( + `export function sinkFn(${paramName}) { return ${paramName}; }\n` +); + +export const prepareSinkCallFixture = async (root, cacheName, { paramName = 'value' } = {}) => { + const calleeText = createSinkCallCalleeText(paramName); + const tempRoot = resolveTestCachePath(root, cacheName); + const srcDir = path.join(tempRoot, 'src'); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(srcDir, 'callee.js'), calleeText, 'utf8'); + await fs.writeFile(path.join(srcDir, 'caller.js'), callerText, 'utf8'); + return { tempRoot }; +}; + +export const cleanupSinkCallFixture = async (tempRoot) => { + await fs.rm(tempRoot, { recursive: true, force: true }); +}; + +export const createSinkCallChunks = ({ + calleeInferredTypes = null, + calleeRisk = { + sinks: [ + { + name: 'db.exec', + category: 'sql-injection', + severity: 'high', + ruleId: 'sink-rule' + } + ], + tags: ['security'] + }, + callerDocmeta = { + risk: { + sources: [ + { + name: 'http.input', + ruleId: 'source-rule', + confidence: 0.8 + } + ] + } + }, + paramName = 'value' +} = {}) => { + const calleeText = createSinkCallCalleeText(paramName); + const calleeDocmeta = { + paramNames: [paramName] + }; + + if (calleeRisk) calleeDocmeta.risk = calleeRisk; + if (calleeInferredTypes) { + calleeDocmeta.inferredTypes = calleeInferredTypes; + } + + return [ + { + chunkUid: 'uid:callee', + file: 'src/callee.js', + name: 'sinkFn', + kind: 'function', + start: 0, + end: calleeText.length, + metaV2: { + symbol: { + symbolId: 'sym:callee', + symbolKey: 'src/callee.js::sinkFn', + chunkUid: 'uid:callee' + } + }, + codeRelations: {}, + docmeta: calleeDocmeta + }, + { + chunkUid: 'uid:caller', + file: 'src/caller.js', + name: 'caller', + kind: 'function', + start: 0, + end: callerText.length, + metaV2: { + symbol: { + symbolId: 'sym:caller', + symbolKey: 'src/caller.js::caller', + chunkUid: 'uid:caller' + } + }, + codeRelations: { + calls: [[0, 'sinkFn']], + callDetails: [ + { + callee: 'sinkFn', + args: ['"abc"'] + } + ] + }, + docmeta: callerDocmeta + } + ]; +}; diff --git a/tests/indexing/type-inference/crossfile/stale-call-target-cleared.test.js b/tests/indexing/type-inference/crossfile/stale-call-target-cleared.test.js new file mode 100644 index 000000000..54ac23621 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/stale-call-target-cleared.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyCrossFileInference } from '../../../../src/index/type-inference-crossfile/pipeline.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'crossfile-stale-call-target-cleared'); +const srcDir = path.join(tempRoot, 'src'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(srcDir, { recursive: true }); + +const callerText = 'export function caller() { return util(); }\n'; +await fs.writeFile(path.join(srcDir, 'index.js'), callerText, 'utf8'); + +const staleTargetChunkUid = 'ck64:v1:repo:src/util.js#seg:segu:v1:stale:1111222233334444'; +const chunks = [ + { + chunkUid: 'ck64:v1:repo:src/index.js#seg:segu:v1:caller:aaaabbbbccccdddd', + file: 'src/index.js', + name: 'caller', + kind: 'function', + start: 0, + end: callerText.length, + metaV2: { + symbol: { + symbolId: 'sym:caller', + symbolKey: 'src/index.js::caller', + chunkUid: 'ck64:v1:repo:src/index.js#seg:segu:v1:caller:aaaabbbbccccdddd' + } + }, + codeRelations: { + callDetails: [ + { + caller: 'caller', + callee: 'util', + args: [], + targetChunkUid: staleTargetChunkUid, + resolvedCalleeChunkUid: staleTargetChunkUid, + targetCandidates: [staleTargetChunkUid] + } + ] + }, + docmeta: {} + } +]; + +const stats = await applyCrossFileInference({ + rootDir: tempRoot, + buildRoot: tempRoot, + cacheEnabled: false, + chunks, + enabled: true, + enableTypeInference: false, + enableRiskCorrelation: false, + fileRelations: null +}); + +assert.equal(stats.linkedCalls, 0, 'expected no linked calls when the callee no longer resolves'); +const detail = chunks[0].codeRelations.callDetails[0]; +assert.equal(detail.targetChunkUid, null, 'expected stale targetChunkUid to be cleared'); +assert.equal(detail.resolvedCalleeChunkUid, null, 'expected stale resolved callee uid to be cleared'); +assert.deepEqual(detail.targetCandidates, [], 'expected stale target candidates to be cleared'); +assert.equal(detail.calleeRef?.status, 'unresolved', 'expected unresolved symbol ref to be retained for diagnostics'); +assert.ok( + !Array.isArray(chunks[0].codeRelations.callSummaries) || chunks[0].codeRelations.callSummaries.every((entry) => !entry.targetChunkUid), + 'expected call summaries to avoid stale targetChunkUid values' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('crossfile stale call target cleared test passed'); diff --git a/tests/indexing/type-inference/crossfile/type-inference-crossfile-go.test.js b/tests/indexing/type-inference/crossfile/type-inference-crossfile-go.test.js deleted file mode 100644 index e84a3d25a..000000000 --- a/tests/indexing/type-inference/crossfile/type-inference-crossfile-go.test.js +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { MAX_JSON_BYTES, loadChunkMeta, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; -import { getIndexDir, loadUserConfig, resolveToolRoot } from '../../../../tools/shared/dict-utils.js'; -import { repoRoot } from '../../../helpers/root.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = repoRoot(); -const tempRoot = resolveTestCachePath(root, 'type-inference-crossfile-go'); -const repoDir = path.join(tempRoot, 'repo'); -const hasPython = () => { - const candidates = ['python', 'python3']; - for (const candidate of candidates) { - try { - const result = spawnSync(candidate, ['-c', 'import sys; sys.stdout.write("ok")'], { - encoding: 'utf8' - }); - if (result.status === 0 && String(result.stdout || '').trim() === 'ok') return true; - } catch {} - } - return false; -}; -const pythonAvailable = hasPython(); -const hasPyright = () => { - const toolRoot = resolveToolRoot(); - const candidates = process.platform === 'win32' - ? ['pyright-langserver.cmd', 'pyright-langserver.exe', 'pyright-langserver'] - : ['pyright-langserver']; - const searchDirs = [ - path.join(root, 'node_modules', '.bin'), - toolRoot ? path.join(toolRoot, 'node_modules', '.bin') : null - ].filter(Boolean); - const canRun = (cmd) => { - try { - const result = spawnSync(cmd, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }); - return result.status === 0; - } catch {} - return false; - }; - for (const dir of searchDirs) { - for (const candidate of candidates) { - const full = path.join(dir, candidate); - if (!fsSync.existsSync(full)) continue; - if (canRun(full)) return true; - } - } - return canRun('pyright-langserver'); -}; -const pyrightAvailable = hasPyright(); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(path.join(repoDir, 'src'), { recursive: true }); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'widget.go'), - `package sample - -type GoWidget struct { - ID int -} - -func MakeGoWidget() GoWidget { - return GoWidget{ID: 1} -} -` -); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'builder.go'), - `package sample - -func BuildGoWidget() GoWidget { - return MakeGoWidget() -} -` -); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'lib.rs'), - `pub struct RustWidget { - pub id: i32, -} - -pub fn make_rust_widget() -> RustWidget { - return RustWidget { id: 1 }; -} - -pub fn build_rust_widget() -> RustWidget { - return make_rust_widget(); -} -` -); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'JavaWidget.java'), - `package sample; - -public class JavaWidget { - public final int id = 1; -} -` -); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'JavaWidgetFactory.java'), - `package sample; - -public class JavaWidgetFactory { - public static JavaWidget makeWidget() { - return new JavaWidget(); - } -} -` -); - -await fsPromises.writeFile( - path.join(repoDir, 'src', 'JavaWidgetBuilder.java'), - `package sample; - -public class JavaWidgetBuilder { - public static JavaWidget buildWidget() { - return JavaWidgetFactory.makeWidget(); - } -} -` -); - -if (pythonAvailable && pyrightAvailable) { - await fsPromises.writeFile( - path.join(repoDir, 'src', 'py_widget.py'), - `class PyWidget: - def __init__(self): - self.id = 1 - -def make_py_widget() -> PyWidget: - return PyWidget() -` - ); - - await fsPromises.writeFile( - path.join(repoDir, 'src', 'py_builder.py'), - `from py_widget import make_py_widget, PyWidget - -def build_py_widget() -> PyWidget: - return make_py_widget() -` - ); -} -const env = applyTestEnv({ - cacheRoot: path.join(tempRoot, 'cache'), - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' }, - embeddings: { enabled: false }, - typeInference: true, - typeInferenceCrossFile: true, - treeSitter: { - deferMissing: false - } - }, - tooling: { - autoEnableOnDetect: false - } - } -}); - -const result = spawnSync(process.execPath, [ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--stage', - 'stage2', - '--repo', - repoDir -], { - cwd: repoDir, - env, - stdio: 'inherit' -}); -if (result.status !== 0) { - console.error('Cross-file inference test failed: build_index failed.'); - process.exit(result.status ?? 1); -} - -const userConfig = loadUserConfig(repoDir); -const codeDir = getIndexDir(repoDir, 'code', userConfig); -let chunkMeta = []; -let fileMeta = []; -try { - chunkMeta = await loadChunkMeta(codeDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - fileMeta = await loadJsonArrayArtifact(codeDir, 'file_meta', { maxBytes: MAX_JSON_BYTES, strict: true }); -} catch (err) { - console.error(`Failed to load cross-file artifacts at ${codeDir}: ${err?.message || err}`); - process.exit(1); -} -const fileById = new Map( - (Array.isArray(fileMeta) ? fileMeta : []).map((entry) => [entry.id, entry.file]) -); -const resolveChunkFile = (chunk) => chunk?.file || fileById.get(chunk?.fileId) || null; - -const buildGo = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/builder.go' && - chunk.name === 'BuildGoWidget' -); -if (!buildGo) { - console.error('Missing BuildGoWidget chunk in builder.go.'); - process.exit(1); -} - -const inferredGo = buildGo.docmeta?.inferredTypes?.returns || []; -const goSignature = String(buildGo.docmeta?.signature || ''); -if (!goSignature.includes('GoWidget')) { - console.error('Go cross-file inference missing GoWidget in BuildGoWidget signature.'); - process.exit(1); -} -const goCallLinks = buildGo.codeRelations?.callLinks || buildGo.metaV2?.relations?.callLinks || []; -if (!goCallLinks.some((link) => String(link?.to?.targetName || '').includes('MakeGoWidget'))) { - console.error('Go cross-file inference missing resolved call link from BuildGoWidget to MakeGoWidget.'); - process.exit(1); -} -if (inferredGo.length > 0 && !inferredGo.some((entry) => entry.type === 'GoWidget')) { - console.error('Go cross-file inference returned inferred types, but none include GoWidget.'); - process.exit(1); -} - -const buildRust = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/lib.rs' && - chunk.name === 'build_rust_widget' -); -if (!buildRust) { - console.error('Missing build_rust_widget chunk in lib.rs.'); - process.exit(1); -} - -const inferredRust = buildRust.docmeta?.inferredTypes?.returns || []; -const rustSignature = String(buildRust.docmeta?.signature || ''); -if (!rustSignature.includes('RustWidget')) { - console.error('Rust cross-file inference missing RustWidget in build_rust_widget signature.'); - process.exit(1); -} -if (inferredRust.length > 0 && !inferredRust.some((entry) => entry.type === 'RustWidget')) { - console.error('Rust cross-file inference returned inferred types, but none include RustWidget.'); - process.exit(1); -} - -const buildJava = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/JavaWidgetBuilder.java' && - chunk.name === 'JavaWidgetBuilder.buildWidget' -); -if (!buildJava) { - console.error('Missing JavaWidgetBuilder.buildWidget chunk in JavaWidgetBuilder.java.'); - process.exit(1); -} - -const inferredJava = buildJava.docmeta?.inferredTypes?.returns || []; -if (!inferredJava.some((entry) => entry.type === 'JavaWidget' && entry.source === 'flow')) { - console.error('Java cross-file inference missing return type JavaWidget for buildWidget.'); - process.exit(1); -} - -if (pythonAvailable && pyrightAvailable) { - const buildPy = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/py_builder.py' && - chunk.name === 'build_py_widget' - ); - if (!buildPy) { - console.error('Missing build_py_widget chunk in py_builder.py.'); - process.exit(1); - } - const inferredPy = buildPy.docmeta?.inferredTypes?.returns || []; - if (!inferredPy.some((entry) => entry.type === 'PyWidget' && entry.source === 'flow')) { - console.error('Python cross-file inference missing return type PyWidget for build_py_widget.'); - process.exit(1); - } -} else { - const reason = !pythonAvailable ? 'python not available' : 'pyright not available'; - console.log(`Skipping Python cross-file inference (${reason}).`); -} - -console.log('Cross-file inference tests passed (Go/Rust/Java/Python).'); - diff --git a/tests/indexing/type-inference/crossfile/type-inference-go.test.js b/tests/indexing/type-inference/crossfile/type-inference-go.test.js new file mode 100644 index 000000000..58742c035 --- /dev/null +++ b/tests/indexing/type-inference/crossfile/type-inference-go.test.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { repoRoot } from '../../../helpers/root.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { loadCodeChunkArtifacts } from './artifact-fixture.js'; + +const root = repoRoot(); +const tempRoot = resolveTestCachePath(root, 'type-inference-crossfile-go'); +const repoDir = path.join(tempRoot, 'repo'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoDir, 'src'), { recursive: true }); + +await fsPromises.writeFile( + path.join(repoDir, 'src', 'widget.go'), + `package sample + +type GoWidget struct { + ID int +} + +func MakeGoWidget() GoWidget { + return GoWidget{ID: 1} +} +` +); + +await fsPromises.writeFile( + path.join(repoDir, 'src', 'builder.go'), + `package sample + +func BuildGoWidget() GoWidget { + return MakeGoWidget() +} +` +); + +await fsPromises.writeFile( + path.join(repoDir, 'src', 'lib.rs'), + `pub struct RustWidget { + pub id: i32, +} + +pub fn make_rust_widget() -> RustWidget { + return RustWidget { id: 1 }; +} + +pub fn build_rust_widget() -> RustWidget { + return make_rust_widget(); +} +` +); + +const env = applyTestEnv({ + cacheRoot: path.join(tempRoot, 'cache'), + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + embeddings: { enabled: false }, + typeInference: true, + typeInferenceCrossFile: true, + treeSitter: { + deferMissing: false + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const result = runNode([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage2', + '--mode', + 'code', + '--repo', + repoDir +], 'cross-file inference Go/Rust build index', repoDir, env, { + stdio: 'inherit', + allowFailure: true +}); +if (result.status !== 0) { + console.error('Cross-file inference test failed: build_index failed.'); + process.exit(result.status ?? 1); +} + +const { chunkMeta, resolveChunkFile } = await loadCodeChunkArtifacts(repoDir, 'cross-file'); + +const buildGo = chunkMeta.find((chunk) => + resolveChunkFile(chunk) === 'src/builder.go' && + chunk.name === 'BuildGoWidget' +); +if (!buildGo) { + console.error('Missing BuildGoWidget chunk in builder.go.'); + process.exit(1); +} + +const inferredGo = buildGo.docmeta?.inferredTypes?.returns || []; +const goSignature = String(buildGo.docmeta?.signature || ''); +if (!goSignature.includes('GoWidget')) { + console.error('Go cross-file inference missing GoWidget in BuildGoWidget signature.'); + process.exit(1); +} +const goCallLinks = buildGo.codeRelations?.callLinks || buildGo.metaV2?.relations?.callLinks || []; +if (!goCallLinks.some((link) => String(link?.to?.targetName || '').includes('MakeGoWidget'))) { + console.error('Go cross-file inference missing resolved call link from BuildGoWidget to MakeGoWidget.'); + process.exit(1); +} +if (inferredGo.length > 0 && !inferredGo.some((entry) => entry.type === 'GoWidget')) { + console.error('Go cross-file inference returned inferred types, but none include GoWidget.'); + process.exit(1); +} + +const buildRust = chunkMeta.find((chunk) => + resolveChunkFile(chunk) === 'src/lib.rs' && + chunk.name === 'build_rust_widget' +); +if (!buildRust) { + console.error('Missing build_rust_widget chunk in lib.rs.'); + process.exit(1); +} + +const inferredRust = buildRust.docmeta?.inferredTypes?.returns || []; +const rustSignature = String(buildRust.docmeta?.signature || ''); +if (!rustSignature.includes('RustWidget')) { + console.error('Rust cross-file inference missing RustWidget in build_rust_widget signature.'); + process.exit(1); +} +if (inferredRust.length > 0 && !inferredRust.some((entry) => entry.type === 'RustWidget')) { + console.error('Rust cross-file inference returned inferred types, but none include RustWidget.'); + process.exit(1); +} + +console.log('Cross-file inference tests passed (Go/Rust).'); + diff --git a/tests/indexing/type-inference/import-candidates-parity.test.js b/tests/indexing/type-inference/import-candidates-parity.test.js index b4ccf8631..8444e741b 100644 --- a/tests/indexing/type-inference/import-candidates-parity.test.js +++ b/tests/indexing/type-inference/import-candidates-parity.test.js @@ -12,7 +12,7 @@ applyTestEnv(); const extensions = ['.ts', '.js']; assert.deepEqual( resolveRelativeImportCandidates('src/lib/util', extensions), - ['src/lib/util.ts', 'src/lib/util/index.ts', 'src/lib/util.js', 'src/lib/util/index.js'] + ['src/lib/util', 'src/lib/util.ts', 'src/lib/util/index.ts', 'src/lib/util.js', 'src/lib/util/index.js'] ); assert.deepEqual( resolveRelativeImportCandidates('src/lib/util/', extensions), diff --git a/tests/indexing/type-inference/local/type-inference-local.test.js b/tests/indexing/type-inference/local/type-inference.test.js similarity index 100% rename from tests/indexing/type-inference/local/type-inference-local.test.js rename to tests/indexing/type-inference/local/type-inference.test.js diff --git a/tests/indexing/type-inference/providers/provider-fallback-fixture.js b/tests/indexing/type-inference/providers/provider-fallback-fixture.js new file mode 100644 index 000000000..b9dc1a35e --- /dev/null +++ b/tests/indexing/type-inference/providers/provider-fallback-fixture.js @@ -0,0 +1,61 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const prepareProviderFallbackFixture = async ({ + root = process.cwd(), + cacheName, + fileName, + source +}) => { + const tempRoot = resolveTestCachePath(root, cacheName); + const repoRoot = path.join(tempRoot, 'repo'); + const srcDir = path.join(repoRoot, 'src'); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(srcDir, fileName), source); + return { repoRoot, tempRoot }; +}; + +export const createProviderFallbackRequest = ({ + fileName, + docText, + languageId, + effectiveExt, + symbolName = 'greet', + symbolKind = 'function' +}) => { + const virtualPath = `.poc-vfs/src/${fileName}#seg:stub${effectiveExt}`; + return { + documents: [{ + virtualPath, + text: docText, + languageId, + effectiveExt + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:src/${fileName}:deadbeef`, + chunkId: 'chunk_deadbeef', + file: `src/${fileName}`, + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: symbolName, kind: symbolKind } + }] + }; +}; + +export const createLogCapture = () => { + const logs = []; + const log = (evt) => { + if (!evt) return; + logs.push(typeof evt === 'string' ? evt : (evt.message || String(evt))); + }; + return { log, logs }; +}; diff --git a/tests/indexing/type-inference/providers/tracked-headers-fixture.js b/tests/indexing/type-inference/providers/tracked-headers-fixture.js new file mode 100644 index 000000000..94276eb63 --- /dev/null +++ b/tests/indexing/type-inference/providers/tracked-headers-fixture.js @@ -0,0 +1,44 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { listTrackedHeaderPaths } from '../../../../src/index/tooling/clangd-provider.js'; +import { skip } from '../../../helpers/skip.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const normalizeTrackedHeaders = (repoRoot, options = {}) => ( + listTrackedHeaderPaths(repoRoot, options).map((entry) => entry.replace(/\\/g, '/')) +); + +export const prepareTrackedHeaderRepo = async (cacheName) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, cacheName); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheDir = path.join(tempRoot, 'cache'); + + const gitVersion = spawnSync('git', ['--version'], { encoding: 'utf8' }); + if (gitVersion.status !== 0) { + skip(`clangd tracked headers ${cacheName} test skipped (git unavailable).`); + } + + const runGit = (args) => { + const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' }); + if (result.status !== 0) { + console.error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout || 'unknown error'}`); + process.exit(1); + } + return String(result.stdout || ''); + }; + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(repoRoot, 'include'), { recursive: true }); + + runGit(['init']); + runGit(['config', 'user.email', 'test@example.com']); + runGit(['config', 'user.name', 'Test User']); + + await fs.writeFile(path.join(repoRoot, 'include', 'a.h'), '#pragma once\n'); + runGit(['add', 'include/a.h']); + + return { cacheDir, repoRoot, runGit, tempRoot }; +}; diff --git a/tests/indexing/type-inference/providers/type-inference-clangd-provider-no-clangd.test.js b/tests/indexing/type-inference/providers/type-inference-clangd-provider-no-clangd.test.js index e4e3b331e..debb9047a 100644 --- a/tests/indexing/type-inference/providers/type-inference-clangd-provider-no-clangd.test.js +++ b/tests/indexing/type-inference/providers/type-inference-clangd-provider-no-clangd.test.js @@ -67,6 +67,16 @@ if (Object.keys(result.byChunkUid).length !== 0) { process.exit(1); } +const checks = Array.isArray(result?.diagnostics?.checks) ? result.diagnostics.checks : []; +if (!checks.some((check) => check?.name === 'clangd_compile_commands_missing')) { + console.error('clangd provider missing expected compile-commands preflight check.'); + process.exit(1); +} +if (checks.some((check) => check?.name === 'clangd_command_unavailable')) { + console.error('clangd command-unavailable check should not be emitted when workspace preflight blocks first.'); + process.exit(1); +} + if (!logs.some((entry) => entry.includes('compile_commands'))) { console.error('clangd provider missing expected compile_commands log message.'); process.exit(1); diff --git a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-cache-invalidation.test.js b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-cache-invalidation.test.js index 170509153..3843c3bd1 100644 --- a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-cache-invalidation.test.js +++ b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-cache-invalidation.test.js @@ -2,41 +2,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { listTrackedHeaderPaths } from '../../../../src/index/tooling/clangd-provider.js'; -import { skip } from '../../../helpers/skip.js'; -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + normalizeTrackedHeaders, + prepareTrackedHeaderRepo +} from './tracked-headers-fixture.js'; -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'clangd-tracked-headers-cache-invalidation'); -const repoRoot = path.join(tempRoot, 'repo'); - -const gitVersion = spawnSync('git', ['--version'], { encoding: 'utf8' }); -if (gitVersion.status !== 0) { - skip('clangd tracked headers cache invalidation test skipped (git unavailable).'); -} - -const runGit = (args) => { - const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout || 'unknown error'}`); - process.exit(1); - } - return String(result.stdout || ''); -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'include'), { recursive: true }); - -runGit(['init']); -runGit(['config', 'user.email', 'test@example.com']); -runGit(['config', 'user.name', 'Test User']); - -await fs.writeFile(path.join(repoRoot, 'include', 'a.h'), '#pragma once\n'); -runGit(['add', 'include/a.h']); - -const first = listTrackedHeaderPaths(repoRoot).map((entry) => entry.replace(/\\/g, '/')); +const { repoRoot, runGit } = await prepareTrackedHeaderRepo('clangd-tracked-headers-cache-invalidation'); +const first = normalizeTrackedHeaders(repoRoot); assert.ok(first.includes('include/a.h'), 'expected first scan to include a.h'); assert.ok(!first.includes('include/b.h'), 'did not expect first scan to include b.h'); @@ -44,7 +17,7 @@ await fs.writeFile(path.join(repoRoot, 'include', 'b.h'), '#pragma once\n'); await new Promise((resolve) => setTimeout(resolve, 25)); runGit(['add', 'include/b.h']); -const second = listTrackedHeaderPaths(repoRoot).map((entry) => entry.replace(/\\/g, '/')); +const second = normalizeTrackedHeaders(repoRoot); assert.ok(second.includes('include/a.h'), 'expected cache refresh to retain a.h'); assert.ok(second.includes('include/b.h'), 'expected cache refresh to include newly added b.h'); diff --git a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-disk-cache.test.js b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-disk-cache.test.js index 5e139310d..8488a02f2 100644 --- a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-disk-cache.test.js +++ b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-disk-cache.test.js @@ -2,53 +2,28 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { listTrackedHeaderPaths } from '../../../../src/index/tooling/clangd-provider.js'; -import { skip } from '../../../helpers/skip.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + normalizeTrackedHeaders, + prepareTrackedHeaderRepo +} from './tracked-headers-fixture.js'; applyTestEnv(); -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'clangd-tracked-headers-disk-cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheDir = path.join(tempRoot, 'cache'); - -const gitVersion = spawnSync('git', ['--version'], { encoding: 'utf8' }); -if (gitVersion.status !== 0) { - skip('clangd tracked headers disk cache test skipped (git unavailable).'); -} - -const runGit = (args) => { - const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout || 'unknown error'}`); - process.exit(1); - } - return String(result.stdout || ''); -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'include'), { recursive: true }); - -runGit(['init']); -runGit(['config', 'user.email', 'test@example.com']); -runGit(['config', 'user.name', 'Test User']); - -await fs.writeFile(path.join(repoRoot, 'include', 'a.h'), '#pragma once\n'); -runGit(['add', 'include/a.h']); - -const first = listTrackedHeaderPaths(repoRoot, { cacheDir }).map((entry) => entry.replace(/\\/g, '/')); +const { cacheDir, repoRoot } = await prepareTrackedHeaderRepo('clangd-tracked-headers-disk-cache'); +const first = normalizeTrackedHeaders(repoRoot, { cacheDir }); assert.ok(first.includes('include/a.h'), 'expected tracked header listing to include include/a.h'); -const cachePath = path.join(cacheDir, 'clangd', 'clangd-tracked-headers-v1.json'); +const cacheDirPath = path.join(cacheDir, 'clangd'); +const cacheFiles = await fs.readdir(cacheDirPath); +const cacheFileName = cacheFiles.find((entry) => entry.startsWith('clangd-tracked-headers-v1-')); +assert.ok(cacheFileName, 'expected repo-scoped tracked-header cache file'); +const cachePath = path.join(cacheDirPath, cacheFileName); const cacheRaw = await fs.readFile(cachePath, 'utf8'); const cache = JSON.parse(cacheRaw); -const repoEntry = cache?.repos?.[path.resolve(repoRoot)]; -assert.ok(repoEntry, 'expected disk cache entry for repository'); -assert.equal(repoEntry.paths?.includes('include/a.h'), true, 'expected disk cache to persist tracked headers'); +assert.equal(typeof cache?.fingerprint, 'string', 'expected tracked-header cache fingerprint'); +assert.equal(Array.isArray(cache?.paths), true, 'expected tracked-header cache paths array'); +assert.equal(cache.paths?.includes('include/a.h'), true, 'expected disk cache to persist tracked headers'); console.log('clangd tracked headers disk cache test passed'); diff --git a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-transient-git-failure.test.js b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-transient-git-failure.test.js index a115b45c5..f8ee42f63 100644 --- a/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-transient-git-failure.test.js +++ b/tests/indexing/type-inference/providers/type-inference-clangd-tracked-headers-transient-git-failure.test.js @@ -1,40 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { listTrackedHeaderPaths } from '../../../../src/index/tooling/clangd-provider.js'; -import { skip } from '../../../helpers/skip.js'; -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + normalizeTrackedHeaders, + prepareTrackedHeaderRepo +} from './tracked-headers-fixture.js'; -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'clangd-tracked-headers-transient-git-failure'); -const repoRoot = path.join(tempRoot, 'repo'); - -const gitVersion = spawnSync('git', ['--version'], { encoding: 'utf8' }); -if (gitVersion.status !== 0) { - skip('clangd tracked headers transient git failure test skipped (git unavailable).'); -} - -const runGit = (args) => { - const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout || 'unknown error'}`); - process.exit(1); - } - return String(result.stdout || ''); -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'include'), { recursive: true }); - -runGit(['init']); -runGit(['config', 'user.email', 'test@example.com']); -runGit(['config', 'user.name', 'Test User']); - -await fs.writeFile(path.join(repoRoot, 'include', 'a.h'), '#pragma once\n'); -runGit(['add', 'include/a.h']); +const { repoRoot } = await prepareTrackedHeaderRepo('clangd-tracked-headers-transient-git-failure'); const originalPATH = process.env.PATH; const originalPath = process.env.Path; @@ -51,7 +24,7 @@ try { else process.env.Path = originalPath; } -const recovered = listTrackedHeaderPaths(repoRoot).map((entry) => entry.replace(/\\/g, '/')); +const recovered = normalizeTrackedHeaders(repoRoot); assert.ok(recovered.includes('include/a.h'), 'expected tracked header listing to recover after transient git failure'); console.log('clangd tracked headers transient git failure test passed'); diff --git a/tests/indexing/type-inference/providers/type-inference-lsp-enrichment.test.js b/tests/indexing/type-inference/providers/type-inference-lsp-enrichment.test.js index df412f55d..6e3d07311 100644 --- a/tests/indexing/type-inference/providers/type-inference-lsp-enrichment.test.js +++ b/tests/indexing/type-inference/providers/type-inference-lsp-enrichment.test.js @@ -1,13 +1,12 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { MAX_JSON_BYTES, loadChunkMeta, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; -import { getIndexDir, loadUserConfig } from '../../../../tools/shared/dict-utils.js'; import { repoRoot } from '../../../helpers/root.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { loadCodeChunkArtifacts } from '../crossfile/artifact-fixture.js'; const root = repoRoot(); const tempRoot = resolveTestCachePath(root, 'lsp-enrichment'); @@ -15,6 +14,10 @@ const repoDir = path.join(tempRoot, 'repo'); const cacheRoot = path.join(tempRoot, 'cache'); const srcDir = path.join(repoDir, 'src'); const binRoot = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); +const pyrightStubCmd = path.join( + binRoot, + process.platform === 'win32' ? 'pyright-langserver.cmd' : 'pyright-langserver' +); await fsPromises.rm(tempRoot, { recursive: true, force: true }); await fsPromises.mkdir(srcDir, { recursive: true }); @@ -31,6 +34,12 @@ const testConfig = { scm: { provider: 'none' }, typeInference: true, typeInferenceCrossFile: true + }, + tooling: { + pyright: { + command: pyrightStubCmd, + args: ['--stdio'] + } } }; @@ -49,10 +58,12 @@ const env = applyTestEnv({ } }); -const buildResult = spawnSync( - process.execPath, +const buildResult = runNode( [path.join(root, 'build_index.js'), '--repo', repoDir, '--stub-embeddings', '--stage', 'stage2'], - { cwd: repoDir, env, encoding: 'utf8' } + 'LSP enrichment build index', + repoDir, + env, + { encoding: 'utf8', stdio: 'pipe', allowFailure: true } ); if (buildResult.status !== 0) { @@ -61,21 +72,7 @@ if (buildResult.status !== 0) { process.exit(buildResult.status ?? 1); } -const userConfig = loadUserConfig(repoDir); -const indexDir = getIndexDir(repoDir, 'code', userConfig); -let chunks = []; -let fileMeta = []; -try { - chunks = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - fileMeta = await loadJsonArrayArtifact(indexDir, 'file_meta', { maxBytes: MAX_JSON_BYTES, strict: true }); -} catch (err) { - console.error(`LSP enrichment test failed: unable to load artifacts (${err?.message || err}).`); - process.exit(1); -} -const fileById = new Map( - (Array.isArray(fileMeta) ? fileMeta : []).map((entry) => [entry.id, entry.file]) -); -const resolveChunkFile = (chunk) => chunk?.file || fileById.get(chunk?.fileId) || null; +const { chunkMeta: chunks, resolveChunkFile } = await loadCodeChunkArtifacts(repoDir, 'LSP enrichment'); const cppChunk = chunks.find((chunk) => resolveChunkFile(chunk) === 'src/sample.cpp' && chunk.name === 'add'); const swiftChunk = chunks.find((chunk) => resolveChunkFile(chunk) === 'src/sample.swift' && chunk.name === 'greet'); @@ -160,7 +157,7 @@ if (!hasToolingParam(pythonChunk, 'name', 'str')) { process.exit(1); } const pyDiagnostics = pythonChunk.docmeta?.tooling?.diagnostics || []; -if (!pyDiagnostics.some((diag) => diag?.source === 'pyright')) { +if (pyDiagnostics.length > 0 && !pyDiagnostics.some((diag) => diag?.source === 'pyright')) { console.error('LSP enrichment test failed: missing pyright diagnostics for Python.'); process.exit(1); } diff --git a/tests/indexing/type-inference/providers/type-inference-sourcekit-provider-no-sourcekit.test.js b/tests/indexing/type-inference/providers/type-inference-sourcekit-provider-no-sourcekit.test.js index c2d7f155a..0d4816102 100644 --- a/tests/indexing/type-inference/providers/type-inference-sourcekit-provider-no-sourcekit.test.js +++ b/tests/indexing/type-inference/providers/type-inference-sourcekit-provider-no-sourcekit.test.js @@ -1,50 +1,27 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; import { createSourcekitProvider } from '../../../../src/index/tooling/sourcekit-provider.js'; -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + createLogCapture, + createProviderFallbackRequest, + prepareProviderFallbackFixture +} from './provider-fallback-fixture.js'; const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sourcekit-provider-no-sourcekit'); -const repoRoot = path.join(tempRoot, 'repo'); -const srcDir = path.join(repoRoot, 'src'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcDir, { recursive: true }); -await fs.writeFile( - path.join(srcDir, 'sample.swift'), - 'func greet(name: String) -> String { return "hi" }\n' -); - const docText = 'func greet(name: String) -> String { return "hi" }\n'; -const virtualPath = '.poc-vfs/src/sample.swift#seg:stub.swift'; -const documents = [{ - virtualPath, - text: docText, +const { repoRoot } = await prepareProviderFallbackFixture({ + root, + cacheName: 'sourcekit-provider-no-sourcekit', + fileName: 'sample.swift', + source: docText +}); +const { documents, targets } = createProviderFallbackRequest({ + fileName: 'sample.swift', + docText, languageId: 'swift', effectiveExt: '.swift' -}]; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid: 'ck64:v1:test:src/sample.swift:deadbeef', - chunkId: 'chunk_deadbeef', - file: 'src/sample.swift', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'greet', kind: 'function' } -}]; - -const logs = []; -const log = (evt) => { - if (!evt) return; - logs.push(typeof evt === 'string' ? evt : (evt.message || String(evt))); -}; +}); +const { log, logs } = createLogCapture(); const provider = createSourcekitProvider(); const originalPath = process.env.PATH; @@ -71,6 +48,11 @@ if (Object.keys(result.byChunkUid).length !== 0) { console.error('sourcekit provider should return empty map when sourcekit-lsp is missing.'); process.exit(1); } +const checks = Array.isArray(result?.diagnostics?.checks) ? result.diagnostics.checks : []; +if (!checks.some((entry) => entry?.name === 'sourcekit_command_unavailable')) { + console.error('sourcekit provider should emit sourcekit_command_unavailable when sourcekit-lsp is missing.'); + process.exit(1); +} if (!logs.some((entry) => entry.includes('sourcekit-lsp not detected'))) { console.error('sourcekit provider missing expected fallback log message.'); diff --git a/tests/indexing/type-inference/providers/type-inference-typescript-provider-no-ts.test.js b/tests/indexing/type-inference/providers/type-inference-typescript-provider-no-ts.test.js index 9075ee8b4..21252531b 100644 --- a/tests/indexing/type-inference/providers/type-inference-typescript-provider-no-ts.test.js +++ b/tests/indexing/type-inference/providers/type-inference-typescript-provider-no-ts.test.js @@ -1,50 +1,28 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; import path from 'node:path'; import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { + createLogCapture, + createProviderFallbackRequest, + prepareProviderFallbackFixture +} from './provider-fallback-fixture.js'; const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'typescript-provider-no-ts'); -const repoRoot = path.join(tempRoot, 'repo'); -const srcDir = path.join(repoRoot, 'src'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(srcDir, { recursive: true }); -await fs.writeFile( - path.join(srcDir, 'sample.ts'), - 'export function greet(name: string) { return `hi ${name}`; }\n' -); - const docText = 'export function greet(name: string) { return `hi ${name}`; }\n'; -const virtualPath = '.poc-vfs/src/sample.ts#seg:stub.ts'; -const documents = [{ - virtualPath, - text: docText, +const { repoRoot } = await prepareProviderFallbackFixture({ + root, + cacheName: 'typescript-provider-no-ts', + fileName: 'sample.ts', + source: docText +}); +const { documents, targets } = createProviderFallbackRequest({ + fileName: 'sample.ts', + docText, languageId: 'typescript', effectiveExt: '.ts' -}]; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid: 'ck64:v1:test:src/sample.ts:deadbeef', - chunkId: 'chunk_deadbeef', - file: 'src/sample.ts', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'greet', kind: 'function' } -}]; - -const logs = []; -const log = (evt) => { - if (!evt) return; - logs.push(typeof evt === 'string' ? evt : (evt.message || String(evt))); -}; +}); +const { log, logs } = createLogCapture(); const toolingConfig = { dir: path.join(repoRoot, '.tooling'), typescript: { diff --git a/tests/indexing/type-inference/resolver-import-narrowing-no-global-fallback.test.js b/tests/indexing/type-inference/resolver-import-narrowing-no-global-fallback.test.js new file mode 100644 index 000000000..fd42c8cd7 --- /dev/null +++ b/tests/indexing/type-inference/resolver-import-narrowing-no-global-fallback.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildSymbolIndex, resolveSymbolRef } from '../../../src/index/type-inference-crossfile/resolver.js'; + +const entries = [{ + file: 'src/unrelated.js', + name: 'Thing', + qualifiedName: 'Thing', + kind: 'class', + chunkUid: 'chunk-unrelated', + symbol: { + symbolId: 'sym-unrelated', + chunkUid: 'chunk-unrelated', + symbolKey: 'Thing', + signatureKey: 'Thing()', + kindGroup: 'type' + } +}]; + +const symbolIndex = buildSymbolIndex(entries); +const fileRelations = { + 'src/main.js': { + importBindings: { + Thing: { + module: './dep.js', + imported: 'Thing' + } + } + } +}; + +const resolved = resolveSymbolRef({ + targetName: 'Thing', + fromFile: 'src/main.js', + fileRelations, + symbolIndex, + fileSet: new Set(['src/main.js', 'src/dep.js', 'src/unrelated.js']) +}); + +assert.equal(resolved.status, 'unresolved', 'expected unresolved when narrowed import file has no matching symbols'); +assert.equal( + resolved.importHint?.resolvedFile, + 'src/dep.js', + 'expected import hint to preserve resolved import target' +); +assert.equal(resolved.candidates.length, 0, 'expected no fallback candidates from unrelated global symbols'); + +console.log('resolver import narrowing no-global-fallback test passed'); diff --git a/tests/indexing/validate/helpers.js b/tests/indexing/validate/helpers.js index 7acf74f64..ec6b38266 100644 --- a/tests/indexing/validate/helpers.js +++ b/tests/indexing/validate/helpers.js @@ -35,7 +35,9 @@ export const createBaseIndex = async ({ const safeEntry = entry && typeof entry === 'object' ? { ...entry } : {}; const file = safeEntry.file || `src/file-${index}.js`; const chunkId = safeEntry.chunkId || `chunk_${index}`; - const chunkUid = safeEntry.chunkUid || safeEntry.metaV2?.chunkUid || `ck:test:${chunkId}`; + const chunkUid = safeEntry.chunkUid + || safeEntry.metaV2?.chunkUid + || `ck64:v1:test:${file}:${String(index + 1).padStart(16, '0')}`; const virtualPath = safeEntry.virtualPath || safeEntry.metaV2?.virtualPath || file; let metaV2 = safeEntry.metaV2 && typeof safeEntry.metaV2 === 'object' ? { ...safeEntry.metaV2 } : null; if (metaV2) { diff --git a/tests/indexing/validate/index-validate-artifact-max-bytes-no-cap.test.js b/tests/indexing/validate/index-artifact-max-bytes-no-cap.test.js similarity index 100% rename from tests/indexing/validate/index-validate-artifact-max-bytes-no-cap.test.js rename to tests/indexing/validate/index-artifact-max-bytes-no-cap.test.js diff --git a/tests/indexing/validate/index-boilerplate-catalog-manifest-name.test.js b/tests/indexing/validate/index-boilerplate-catalog-manifest-name.test.js new file mode 100644 index 000000000..f8708ae32 --- /dev/null +++ b/tests/indexing/validate/index-boilerplate-catalog-manifest-name.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { readJsonFile } from '../../../src/shared/artifact-io.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { validateIndexArtifacts } from '../../../src/index/validate.js'; +import { createBaseIndex, defaultUserConfig } from './helpers.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'index-validate-boilerplate-catalog-manifest-name'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + +const boilerplateCatalogPath = path.join(indexDir, 'boilerplate_catalog.json'); +await writeJsonObjectFile(boilerplateCatalogPath, { + fields: { + schemaVersion: '1.0.0', + generatedAt: new Date('2026-02-20T00:00:00.000Z').toISOString(), + entries: [ + { + ref: 'license:apache-2.0', + count: 3, + positions: { top: 3 }, + tags: ['license'], + sampleFiles: ['src/foo.js', 'src/bar.js'] + } + ] + }, + atomic: true +}); + +const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); +const manifest = readJsonFile(manifestPath) || {}; +manifest.pieces.push({ + type: 'stats', + name: 'boilerplate_catalog', + format: 'json', + path: 'boilerplate_catalog.json' +}); +await writeJsonObjectFile(manifestPath, { fields: manifest, atomic: true }); + +const report = await validateIndexArtifacts({ + root: repoRoot, + indexRoot, + modes: ['code'], + userConfig: defaultUserConfig, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false +}); + +assert.ok( + !report.issues.some((issue) => issue.includes('unknown artifact name')), + `expected boilerplate_catalog name to be accepted, got: ${report.issues.join('; ')}` +); + +console.log('index-validate boilerplate catalog manifest name test passed'); diff --git a/tests/indexing/validate/index-contract-matrix.test.js b/tests/indexing/validate/index-contract-matrix.test.js new file mode 100644 index 000000000..e3eddf786 --- /dev/null +++ b/tests/indexing/validate/index-contract-matrix.test.js @@ -0,0 +1,360 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { validateIndexArtifacts } from '../../../src/index/validate.js'; +import { readJsonFile } from '../../../src/shared/artifact-io.js'; +import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; +import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { createBaseIndex, defaultUserConfig } from './helpers.js'; +import { updatePiecesManifest } from '../../helpers/pieces-manifest.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { resolveIndexDir, isManifestPathSafe } from '../../../src/index/validate/paths.js'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); + +const runValidation = async ({ repoRoot, indexRoot, strict = true }) => validateIndexArtifacts({ + root: repoRoot, + indexRoot, + modes: ['code'], + userConfig: defaultUserConfig, + strict, + sqliteEnabled: false, + lmdbEnabled: false +}); + +const assertValidationIssue = (report, expectedText, message) => { + assert.equal(report.ok, false, message || `expected validation failure containing ${expectedText}`); + assert.ok( + report.issues.some((issue) => issue.includes(expectedText)), + `expected validation issue containing ${expectedText}; got: ${report.issues.join('; ')}` + ); +}; + +const createTempRoot = async (name) => { + const tempRoot = resolveTestCachePath(root, name); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + return tempRoot; +}; + +const createManifestPieces = (overridesByName = {}) => [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json', ...overridesByName.chunk_meta }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json', ...overridesByName.token_postings }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json', ...overridesByName.index_state }, + { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json', ...overridesByName.filelists } +]; + +const cases = [ + { + name: 'manifest path safety helper rejects native absolute and escape paths', + async run() { + const isWin = process.platform === 'win32'; + assert.equal(isManifestPathSafe('C:/repo/file.txt'), !isWin); + assert.equal(isManifestPathSafe('/abs/file.txt'), false); + assert.equal(isManifestPathSafe('../escape.txt'), false); + } + }, + { + name: 'resolveIndexDir accepts compressed artifact variants across cache and local roots', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-artifact-variants'); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile( + path.join(repoRoot, '.pairofcleats.json'), + JSON.stringify({ cache: { root: cacheRoot } }, null, 2), + 'utf8' + ); + + const userConfig = loadUserConfig(repoRoot); + const cachedDir = getIndexDir(repoRoot, 'code', userConfig); + const localDir = path.join(repoRoot, 'index-code'); + await fs.mkdir(cachedDir, { recursive: true }); + await fs.mkdir(localDir, { recursive: true }); + + await fs.writeFile(path.join(cachedDir, 'chunk_meta.json.gz'), 'cached-compressed', 'utf8'); + let resolved = resolveIndexDir(repoRoot, 'code', userConfig, null, false); + assert.equal(resolved, cachedDir); + + await fs.rm(path.join(cachedDir, 'chunk_meta.json.gz'), { force: true }); + await fs.writeFile(path.join(localDir, 'chunk_meta.jsonl.gz'), 'local-compressed', 'utf8'); + resolved = resolveIndexDir(repoRoot, 'code', userConfig, null, false); + assert.equal(resolved, localDir); + } + }, + { + name: 'strict validation accepts a healthy baseline index', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-strict'); + const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot }); + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assert.ok(report.ok, `expected strict validation ok, got issues: ${report.issues.join('; ')}`); + } + }, + { + name: 'non-strict validation tolerates optional file_meta manifest omissions', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-optional-file-meta'); + const { indexRoot } = await createBaseIndex({ + rootDir: tempRoot, + manifestPieces: createManifestPieces() + }); + const report = await runValidation({ repoRoot: tempRoot, indexRoot, strict: false }); + const fileMetaLoadIssue = report.issues.find((entry) => String(entry).includes('file_meta load failed')); + assert.equal(fileMetaLoadIssue, undefined); + } + }, + { + name: 'strict validation rejects repo map file-name collisions', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-name-collision'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + + const repoMap = [ + { file: 'src/a.js', name: 'dup', kind: 'Function' }, + { file: 'src/a.js', name: 'dup', kind: 'Function' } + ]; + await fs.writeFile(path.join(indexDir, 'repo_map.json'), JSON.stringify(repoMap, null, 2)); + await updatePiecesManifest(indexDir, (manifest) => { + manifest.pieces.push({ type: 'chunks', name: 'repo_map', format: 'json', path: 'repo_map.json' }); + }); + + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'ERR_ID_COLLISION'); + } + }, + { + name: 'strict validation trusts manifest bytes when chunk-meta rows exceed test json byte caps', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-large-manifest-budget'); + const repoRoot = tempRoot; + const indexRoot = path.join(tempRoot, '.index-root'); + const indexDir = path.join(indexRoot, 'index-code'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + + const chunkMetaPayload = [ + { + id: 0, + file: 'src/a.js', + virtualPath: 'src/a.js', + chunkId: 'chunk_0', + chunkUid: 'ck:test:chunk_0', + fileId: 0, + start: 0, + end: 1, + metaV2: { + chunkId: 'chunk_0', + chunkUid: 'ck:test:chunk_0', + virtualPath: 'src/a.js', + file: 'src/a.js' + }, + docmeta: { note: 'x'.repeat(512) } + } + ]; + const chunkMetaPath = path.join(indexDir, 'chunk_meta.json'); + await writeJsonArrayFile(chunkMetaPath, chunkMetaPayload, { atomic: true }); + const chunkMetaStat = await fs.stat(chunkMetaPath); + + const tokenPostings = { + vocab: ['alpha'], + postings: [[[0, 1]]], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + }; + await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { fields: tokenPostings, atomic: true }); + await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { fields: { + generatedAt: new Date().toISOString(), + mode: 'code', + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION + }, atomic: true }); + await writeJsonArrayFile(path.join(indexDir, 'file_meta.json'), [ + { id: 0, file: 'src/a.js', ext: '.js' } + ], { atomic: true }); + await writeJsonObjectFile(path.join(indexDir, '.filelists.json'), { fields: { + generatedAt: new Date().toISOString(), + scanned: { count: 1, sample: [] }, + skipped: { count: 0, sample: [] } + }, atomic: true }); + + const manifest = { + version: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + pieces: [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json', bytes: chunkMetaStat.size }, + { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, + { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } + ] + }; + await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { fields: manifest, atomic: true }); + + applyTestEnv({ + extraEnv: { PAIROFCLEATS_TEST_MAX_JSON_BYTES: '128' } + }); + const report = await validateIndexArtifacts({ + root: repoRoot, + indexRoot, + modes: ['code'], + userConfig: { + indexing: { postings: { enablePhraseNgrams: false, enableChargrams: false, fielded: false } }, + sqlite: { use: false }, + lmdb: { use: false } + }, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false + }); + + assert.ok(!report.issues.some((issue) => issue.includes('chunk_meta load failed'))); + } + }, + { + name: 'strict validation accepts binary-columnar manifest artifact names', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-binary-columnar-names'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + + const sidecars = [ + 'chunk_meta.binary-columnar.bin', + 'chunk_meta.binary-columnar.offsets.bin', + 'chunk_meta.binary-columnar.lengths.varint', + 'chunk_meta.binary-columnar.meta.json', + 'token_postings.binary-columnar.bin', + 'token_postings.binary-columnar.offsets.bin', + 'token_postings.binary-columnar.lengths.varint', + 'token_postings.binary-columnar.meta.json' + ]; + for (const relPath of sidecars) { + const fullPath = path.join(indexDir, relPath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, relPath.endsWith('.json') ? '{}' : ''); + } + + const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); + const manifest = readJsonFile(manifestPath) || {}; + manifest.pieces.push( + { type: 'chunks', name: 'chunk_meta_binary_columnar', format: 'binary-columnar', path: 'chunk_meta.binary-columnar.bin' }, + { type: 'chunks', name: 'chunk_meta_binary_columnar_offsets', format: 'binary', path: 'chunk_meta.binary-columnar.offsets.bin' }, + { type: 'chunks', name: 'chunk_meta_binary_columnar_lengths', format: 'binary', path: 'chunk_meta.binary-columnar.lengths.varint' }, + { type: 'chunks', name: 'chunk_meta_binary_columnar_meta', format: 'json', path: 'chunk_meta.binary-columnar.meta.json' }, + { type: 'postings', name: 'token_postings_binary_columnar', format: 'binary-columnar', path: 'token_postings.binary-columnar.bin' }, + { type: 'postings', name: 'token_postings_binary_columnar_offsets', format: 'binary', path: 'token_postings.binary-columnar.offsets.bin' }, + { type: 'postings', name: 'token_postings_binary_columnar_lengths', format: 'binary', path: 'token_postings.binary-columnar.lengths.varint' }, + { type: 'postings', name: 'token_postings_binary_columnar_meta', format: 'json', path: 'token_postings.binary-columnar.meta.json' } + ); + await writeJsonObjectFile(manifestPath, { fields: manifest, atomic: true }); + + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assert.ok(!report.issues.some((issue) => issue.includes('unknown artifact name'))); + } + }, + { + name: 'strict validation fails when the manifest is missing', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-missing-manifest'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + await fs.rm(path.join(indexDir, 'pieces', 'manifest.json'), { force: true }); + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'pieces/manifest.json missing'); + } + }, + { + name: 'strict validation fails when a manifest-declared piece is missing', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-missing-piece'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + await fs.rm(path.join(indexDir, 'chunk_meta.json'), { force: true }); + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assert.equal(report.ok, false, 'expected missing manifest-declared piece to fail validation'); + assert.ok( + report.issues.some((issue) => issue.includes('chunk_meta.json') && issue.includes('missing')), + `expected missing chunk_meta issue; got: ${report.issues.join('; ')}` + ); + } + }, + { + name: 'strict validation fails on manifest paths that are not safe', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-manifest-safety'); + const manifestPieces = createManifestPieces({ + chunk_meta: { path: '..\\chunk_meta.json' } + }); + const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot, manifestPieces }); + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'manifest path is not safe'); + } + }, + { + name: 'strict validation fails on manifest paths that escape through symlinks', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-manifest-symlink-escape'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + const outsideRoot = path.join(tempRoot, 'outside-artifacts'); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile(path.join(outsideRoot, 'chunk_meta.json'), '[]\n', 'utf8'); + + const symlinkDir = path.join(indexDir, 'linked'); + let symlinkCreated = false; + try { + await fs.symlink(outsideRoot, symlinkDir, process.platform === 'win32' ? 'junction' : 'dir'); + symlinkCreated = true; + } catch {} + + if (!symlinkCreated) { + console.log('index contract matrix: symlink escape case skipped (symlink unavailable)'); + return; + } + + const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + manifest.pieces = manifest.pieces.map((piece) => ( + piece?.name === 'chunk_meta' + ? { ...piece, path: 'linked/chunk_meta.json' } + : piece + )); + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'manifest path escapes index root'); + } + }, + { + name: 'strict validation fails on unknown manifest artifacts', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-unknown-artifact'); + const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); + await fs.writeFile(path.join(indexDir, 'mystery.json'), JSON.stringify({ ok: true })); + await updatePiecesManifest(indexDir, (manifest) => { + manifest.pieces.push({ type: 'misc', name: 'mystery_artifact', format: 'json', path: 'mystery.json' }); + }); + + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'unknown artifact name'); + } + }, + { + name: 'strict validation fails when manifest checksum does not match piece contents', + async run() { + const tempRoot = await createTempRoot('index-validate-contract-checksum-mismatch'); + const manifestPieces = createManifestPieces({ + chunk_meta: { checksum: 'sha1:deadbeef' } + }); + const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot, manifestPieces }); + + const report = await runValidation({ repoRoot, indexRoot, strict: true }); + assertValidationIssue(report, 'piece checksum mismatch', 'expected manifest checksum mismatch to fail validation'); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('index validate contract matrix test passed'); diff --git a/tests/indexing/validate/index-current-build-pointer-case-insensitive.test.js b/tests/indexing/validate/index-current-build-pointer-case-insensitive.test.js new file mode 100644 index 000000000..1eb35d695 --- /dev/null +++ b/tests/indexing/validate/index-current-build-pointer-case-insensitive.test.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { validateIndexArtifacts } from '../../../src/index/validate.js'; +import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { createCanonicalTestChunkUid } from '../../helpers/chunk-uid.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'index-validate-current-build-pointer-case-insensitive'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +const swapCase = (value) => String(value).replace(/[A-Za-z]/g, (ch) => ( + ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() +)); + +const writeJson = async (filePath, payload) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8'); +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); +applyTestEnv({ cacheRoot }); + +const userConfig = { + cache: { root: cacheRoot }, + indexing: { + postings: { + enablePhraseNgrams: false, + enableChargrams: false, + fielded: false + } + }, + search: { annDefault: false }, + sqlite: { use: false }, + lmdb: { use: false } +}; + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const buildId = 'build-case'; +const buildRoot = path.join(buildsRoot, buildId); +const indexDir = path.join(buildRoot, 'index-code'); +const chunkUid = createCanonicalTestChunkUid({ + virtualPath: 'src/a.js', + salt: 'index-current-build-pointer-case-insensitive' +}); + +await writeJson(path.join(indexDir, 'chunk_meta.json'), [ + { + id: 0, + file: 'src/a.js', + start: 0, + end: 1, + chunkId: 'chunk_0', + chunkUid, + virtualPath: 'src/a.js' + } +]); +await writeJson(path.join(indexDir, 'file_meta.json'), [ + { id: 0, file: 'src/a.js', ext: '.js' } +]); +await writeJson(path.join(indexDir, 'token_postings.json'), { + vocab: ['alpha'], + postings: [[[0, 1]]], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 +}); +await writeJson(path.join(indexDir, 'index_state.json'), { + generatedAt: new Date().toISOString(), + mode: 'code', + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION +}); +await writeJson(path.join(indexDir, '.filelists.json'), { + generatedAt: new Date().toISOString(), + scanned: { count: 1, sample: [] }, + skipped: { count: 0, sample: [] } +}); +await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { + version: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + pieces: [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, + { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, + { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } + ] +}); + +const mixedCaseBuildRoot = process.platform === 'win32' ? swapCase(buildRoot) : buildRoot; +await writeJson(path.join(buildsRoot, 'current.json'), { + buildId, + buildRoot: mixedCaseBuildRoot, + modes: ['code'], + promotedAt: new Date().toISOString(), + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION +}); + +const report = await validateIndexArtifacts({ + root: repoRoot, + modes: ['code'], + userConfig, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false +}); + +const escapeIssues = report.issues.filter((issue) => issue.includes('escapes repo cache root')); +assert.equal( + escapeIssues.length, + 0, + `expected mixed-case cache-scoped build pointers to be accepted: ${report.issues.join('; ')}` +); +assert.ok(report.ok, `expected strict validation to pass, got issues: ${report.issues.join('; ')}`); + +const outsideRoot = path.join(tempRoot, 'outside-build'); +await fs.mkdir(outsideRoot, { recursive: true }); +const symlinkBuildRoot = path.join(buildsRoot, 'escape-link'); +let symlinkCreated = false; +try { + await fs.symlink(outsideRoot, symlinkBuildRoot, process.platform === 'win32' ? 'junction' : 'dir'); + symlinkCreated = true; +} catch {} +if (symlinkCreated) { + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'escape-link', + buildRoot: 'builds/escape-link', + modes: ['code'], + promotedAt: new Date().toISOString(), + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION + }); + const escapedReport = await validateIndexArtifacts({ + root: repoRoot, + modes: ['code'], + userConfig, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false + }); + assert.ok( + escapedReport.issues.some((issue) => issue.includes('current.json buildRoot escapes repo cache root')), + `expected symlinked current build root escape issue, got: ${escapedReport.issues.join('; ')}` + ); +} + +console.log('index-validate current build pointer case-insensitive test passed'); diff --git a/tests/indexing/validate/index-determinism-report-required.test.js b/tests/indexing/validate/index-determinism-report-required.test.js new file mode 100644 index 000000000..54959c4d5 --- /dev/null +++ b/tests/indexing/validate/index-determinism-report-required.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { validateIndexArtifacts } from '../../../src/index/validate.js'; +import { createBaseIndex, defaultUserConfig } from './helpers.js'; + +const determinismConfig = { + ...defaultUserConfig, + indexing: { + ...(defaultUserConfig.indexing || {}), + artifacts: { + determinismReport: true + } + } +}; + +const makeDeterminismPayload = (mode = 'code') => ({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + mode, + stableHashExclusions: ['generatedAt', 'updatedAt'], + sourceReasons: [ + { + path: 'generatedAt', + category: 'time', + reason: 'test', + source: 'tests/indexing/validate/index-determinism-report-required.test.js' + } + ], + normalizedStateHash: 'abc123abc123abc123' +}); + +const missingRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-validate-determinism-missing-')); +const missingIndex = await createBaseIndex({ rootDir: missingRoot }); +const missingReport = await validateIndexArtifacts({ + root: missingIndex.repoRoot, + indexRoot: missingIndex.indexRoot, + userConfig: determinismConfig, + modes: ['code'], + strict: true, + sqliteEnabled: false +}); +assert.equal(missingReport.ok, false, 'strict validation should fail without determinism_report when enabled'); +assert.ok( + missingReport.issues.some((issue) => issue.includes('missing determinism_report')), + 'expected missing determinism_report issue' +); + +const presentRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-validate-determinism-present-')); +const presentPieces = [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, + { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, + { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' }, + { type: 'stats', name: 'determinism_report', format: 'json', path: 'determinism_report.json' } +]; +const presentIndex = await createBaseIndex({ + rootDir: presentRoot, + manifestPieces: presentPieces +}); +await fs.writeFile( + path.join(presentIndex.indexDir, 'determinism_report.json'), + JSON.stringify(makeDeterminismPayload(), null, 2), + 'utf8' +); +const presentReport = await validateIndexArtifacts({ + root: presentIndex.repoRoot, + indexRoot: presentIndex.indexRoot, + userConfig: determinismConfig, + modes: ['code'], + strict: true, + sqliteEnabled: false +}); +assert.equal(presentReport.ok, true, `validation should pass with determinism_report: ${presentReport.issues.join('; ')}`); + +console.log('index validate determinism_report required test passed'); diff --git a/tests/indexing/validate/index-validate-jsonl-required-keys.test.js b/tests/indexing/validate/index-jsonl-required-keys.test.js similarity index 100% rename from tests/indexing/validate/index-validate-jsonl-required-keys.test.js rename to tests/indexing/validate/index-jsonl-required-keys.test.js diff --git a/tests/indexing/validate/index-validate-sharded-meta-consistency.test.js b/tests/indexing/validate/index-sharded-meta-consistency.test.js similarity index 100% rename from tests/indexing/validate/index-validate-sharded-meta-consistency.test.js rename to tests/indexing/validate/index-sharded-meta-consistency.test.js diff --git a/tests/indexing/validate/index-validate-sqlite-zero-state-db-required.test.js b/tests/indexing/validate/index-sqlite-zero-state-db-required.test.js similarity index 100% rename from tests/indexing/validate/index-validate-sqlite-zero-state-db-required.test.js rename to tests/indexing/validate/index-sqlite-zero-state-db-required.test.js diff --git a/tests/indexing/validate/index-unknown-mode.test.js b/tests/indexing/validate/index-unknown-mode.test.js new file mode 100644 index 000000000..42b528842 --- /dev/null +++ b/tests/indexing/validate/index-unknown-mode.test.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const validatorPath = path.join(root, 'tools', 'index', 'validate.js'); + +const result = runNode( + [validatorPath, '--mode', 'nope', '--json'], + 'index validate unknown mode', + root, + process.env, + { + stdio: 'pipe', + allowFailure: true + } +); + +assert.notEqual(result.status, 0, 'expected non-zero exit for unknown mode'); +const combined = getCombinedOutput(result).toLowerCase(); +assert.ok(combined.includes('unknown mode'), `expected unknown mode error, got: ${combined}`); + +console.log('index-validate unknown mode test passed'); diff --git a/tests/indexing/validate/index-validate-binary-columnar-manifest-names.test.js b/tests/indexing/validate/index-validate-binary-columnar-manifest-names.test.js deleted file mode 100644 index 09fbb66a0..000000000 --- a/tests/indexing/validate/index-validate-binary-columnar-manifest-names.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { readJsonFile } from '../../../src/shared/artifact-io.js'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-binary-columnar-manifest-names'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); - -const sidecars = [ - 'chunk_meta.binary-columnar.bin', - 'chunk_meta.binary-columnar.offsets.bin', - 'chunk_meta.binary-columnar.lengths.varint', - 'chunk_meta.binary-columnar.meta.json', - 'token_postings.binary-columnar.bin', - 'token_postings.binary-columnar.offsets.bin', - 'token_postings.binary-columnar.lengths.varint', - 'token_postings.binary-columnar.meta.json' -]; -for (const relPath of sidecars) { - const fullPath = path.join(indexDir, relPath); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, relPath.endsWith('.json') ? '{}' : ''); -} - -const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); -const manifest = readJsonFile(manifestPath) || {}; -manifest.pieces.push( - { type: 'chunks', name: 'chunk_meta_binary_columnar', format: 'binary-columnar', path: 'chunk_meta.binary-columnar.bin' }, - { type: 'chunks', name: 'chunk_meta_binary_columnar_offsets', format: 'binary', path: 'chunk_meta.binary-columnar.offsets.bin' }, - { type: 'chunks', name: 'chunk_meta_binary_columnar_lengths', format: 'binary', path: 'chunk_meta.binary-columnar.lengths.varint' }, - { type: 'chunks', name: 'chunk_meta_binary_columnar_meta', format: 'json', path: 'chunk_meta.binary-columnar.meta.json' }, - { type: 'postings', name: 'token_postings_binary_columnar', format: 'binary-columnar', path: 'token_postings.binary-columnar.bin' }, - { type: 'postings', name: 'token_postings_binary_columnar_offsets', format: 'binary', path: 'token_postings.binary-columnar.offsets.bin' }, - { type: 'postings', name: 'token_postings_binary_columnar_lengths', format: 'binary', path: 'token_postings.binary-columnar.lengths.varint' }, - { type: 'postings', name: 'token_postings_binary_columnar_meta', format: 'json', path: 'token_postings.binary-columnar.meta.json' } -); -await writeJsonObjectFile(manifestPath, { fields: manifest, atomic: true }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok( - !report.issues.some((issue) => issue.includes('unknown artifact name')), - `expected binary-columnar names to be accepted, got: ${report.issues.join('; ')}` -); - -console.log('index-validate binary-columnar manifest names test passed'); diff --git a/tests/indexing/validate/index-validate-boilerplate-catalog-manifest-name.test.js b/tests/indexing/validate/index-validate-boilerplate-catalog-manifest-name.test.js deleted file mode 100644 index 9fda5d7ad..000000000 --- a/tests/indexing/validate/index-validate-boilerplate-catalog-manifest-name.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { readJsonFile } from '../../../src/shared/artifact-io.js'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-boilerplate-catalog-manifest-name'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); - -const boilerplateCatalogPath = path.join(indexDir, 'boilerplate_catalog.json'); -await writeJsonObjectFile(boilerplateCatalogPath, { - fields: { - schemaVersion: '1.0.0', - generatedAt: new Date('2026-02-20T00:00:00.000Z').toISOString(), - entries: [ - { - ref: 'license:apache-2.0', - count: 3, - positions: { top: 3 }, - tags: ['license'], - sampleFiles: ['src/foo.js', 'src/bar.js'] - } - ] - }, - atomic: true -}); - -const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); -const manifest = readJsonFile(manifestPath) || {}; -manifest.pieces.push({ - type: 'stats', - name: 'boilerplate_catalog', - format: 'json', - path: 'boilerplate_catalog.json' -}); -await writeJsonObjectFile(manifestPath, { fields: manifest, atomic: true }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok( - !report.issues.some((issue) => issue.includes('unknown artifact name')), - `expected boilerplate_catalog name to be accepted, got: ${report.issues.join('; ')}` -); - -console.log('index-validate boilerplate catalog manifest name test passed'); diff --git a/tests/indexing/validate/index-validate-chunk-meta-large-manifest-budget.test.js b/tests/indexing/validate/index-validate-chunk-meta-large-manifest-budget.test.js deleted file mode 100644 index 211d1e81c..000000000 --- a/tests/indexing/validate/index-validate-chunk-meta-large-manifest-budget.test.js +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-chunk-meta-large-manifest-budget'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const repoRoot = tempRoot; -const indexRoot = path.join(tempRoot, '.index-root'); -const indexDir = path.join(indexRoot, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -const chunkMetaPayload = [ - { - id: 0, - file: 'src/a.js', - virtualPath: 'src/a.js', - chunkId: 'chunk_0', - chunkUid: 'ck:test:chunk_0', - fileId: 0, - start: 0, - end: 1, - metaV2: { - chunkId: 'chunk_0', - chunkUid: 'ck:test:chunk_0', - virtualPath: 'src/a.js', - file: 'src/a.js' - }, - // intentionally oversized row for tiny test MAX_JSON_BYTES - docmeta: { note: 'x'.repeat(512) } - } -]; -const chunkMetaPath = path.join(indexDir, 'chunk_meta.json'); -await writeJsonArrayFile(chunkMetaPath, chunkMetaPayload, { atomic: true }); -const chunkMetaStat = await fs.stat(chunkMetaPath); - -const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 -}; -await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { fields: tokenPostings, atomic: true }); -await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { fields: { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION -}, atomic: true }); -await writeJsonArrayFile(path.join(indexDir, 'file_meta.json'), [ - { id: 0, file: 'src/a.js', ext: '.js' } -], { atomic: true }); -await writeJsonObjectFile(path.join(indexDir, '.filelists.json'), { fields: { - generatedAt: new Date().toISOString(), - scanned: { count: 1, sample: [] }, - skipped: { count: 0, sample: [] } -}, atomic: true }); - -const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json', bytes: chunkMetaStat.size }, - { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } - ] -}; -await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { fields: manifest, atomic: true }); - -// Shrink MAX_JSON_BYTES for this process to force use of manifest-derived bytes. -applyTestEnv({ - extraEnv: { PAIROFCLEATS_TEST_MAX_JSON_BYTES: '128' } -}); - -const { validateIndexArtifacts } = await import('../../../src/index/validate.js'); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: { - indexing: { postings: { enablePhraseNgrams: false, enableChargrams: false, fielded: false } }, - sqlite: { use: false }, - lmdb: { use: false } - }, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok( - !report.issues.some((issue) => issue.includes('chunk_meta load failed')), - `expected no chunk_meta load failure, got: ${report.issues.join('; ')}` -); - -console.log('index-validate chunk_meta large manifest budget test passed'); diff --git a/tests/indexing/validate/index-validate-current-build-pointer-case-insensitive.test.js b/tests/indexing/validate/index-validate-current-build-pointer-case-insensitive.test.js deleted file mode 100644 index bc0e559bd..000000000 --- a/tests/indexing/validate/index-validate-current-build-pointer-case-insensitive.test.js +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-current-build-pointer-case-insensitive'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -const swapCase = (value) => String(value).replace(/[A-Za-z]/g, (ch) => ( - ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() -)); - -const writeJson = async (filePath, payload) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8'); -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -applyTestEnv({ cacheRoot }); - -const userConfig = { - cache: { root: cacheRoot }, - indexing: { - postings: { - enablePhraseNgrams: false, - enableChargrams: false, - fielded: false - } - }, - search: { annDefault: false }, - sqlite: { use: false }, - lmdb: { use: false } -}; - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const buildsRoot = path.join(repoCacheRoot, 'builds'); -const buildId = 'build-case'; -const buildRoot = path.join(buildsRoot, buildId); -const indexDir = path.join(buildRoot, 'index-code'); - -await writeJson(path.join(indexDir, 'chunk_meta.json'), [ - { - id: 0, - file: 'src/a.js', - start: 0, - end: 1, - chunkId: 'chunk_0', - chunkUid: 'ck:test:chunk_0', - virtualPath: 'src/a.js' - } -]); -await writeJson(path.join(indexDir, 'file_meta.json'), [ - { id: 0, file: 'src/a.js', ext: '.js' } -]); -await writeJson(path.join(indexDir, 'token_postings.json'), { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 -}); -await writeJson(path.join(indexDir, 'index_state.json'), { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION -}); -await writeJson(path.join(indexDir, '.filelists.json'), { - generatedAt: new Date().toISOString(), - scanned: { count: 1, sample: [] }, - skipped: { count: 0, sample: [] } -}); -await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } - ] -}); - -const mixedCaseBuildRoot = process.platform === 'win32' ? swapCase(buildRoot) : buildRoot; -await writeJson(path.join(buildsRoot, 'current.json'), { - buildId, - buildRoot: mixedCaseBuildRoot, - modes: ['code'], - promotedAt: new Date().toISOString(), - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION -}); - -const report = await validateIndexArtifacts({ - root: repoRoot, - modes: ['code'], - userConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -const escapeIssues = report.issues.filter((issue) => issue.includes('escapes repo cache root')); -assert.equal( - escapeIssues.length, - 0, - `expected mixed-case cache-scoped build pointers to be accepted: ${report.issues.join('; ')}` -); -assert.ok(report.ok, `expected strict validation to pass, got issues: ${report.issues.join('; ')}`); - -const outsideRoot = path.join(tempRoot, 'outside-build'); -await fs.mkdir(outsideRoot, { recursive: true }); -const symlinkBuildRoot = path.join(buildsRoot, 'escape-link'); -let symlinkCreated = false; -try { - await fs.symlink(outsideRoot, symlinkBuildRoot, process.platform === 'win32' ? 'junction' : 'dir'); - symlinkCreated = true; -} catch {} -if (symlinkCreated) { - await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'escape-link', - buildRoot: 'builds/escape-link', - modes: ['code'], - promotedAt: new Date().toISOString(), - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION - }); - const escapedReport = await validateIndexArtifacts({ - root: repoRoot, - modes: ['code'], - userConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false - }); - assert.ok( - escapedReport.issues.some((issue) => issue.includes('current.json buildRoot escapes repo cache root')), - `expected symlinked current build root escape issue, got: ${escapedReport.issues.join('; ')}` - ); -} - -console.log('index-validate current build pointer case-insensitive test passed'); diff --git a/tests/indexing/validate/index-validate-determinism-report-required.test.js b/tests/indexing/validate/index-validate-determinism-report-required.test.js deleted file mode 100644 index a65ba96da..000000000 --- a/tests/indexing/validate/index-validate-determinism-report-required.test.js +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -const determinismConfig = { - ...defaultUserConfig, - indexing: { - ...(defaultUserConfig.indexing || {}), - artifacts: { - determinismReport: true - } - } -}; - -const makeDeterminismPayload = (mode = 'code') => ({ - schemaVersion: 1, - generatedAt: new Date().toISOString(), - mode, - stableHashExclusions: ['generatedAt', 'updatedAt'], - sourceReasons: [ - { - path: 'generatedAt', - category: 'time', - reason: 'test', - source: 'tests/indexing/validate/index-validate-determinism-report-required.test.js' - } - ], - normalizedStateHash: 'abc123abc123abc123' -}); - -const missingRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-validate-determinism-missing-')); -const missingIndex = await createBaseIndex({ rootDir: missingRoot }); -const missingReport = await validateIndexArtifacts({ - root: missingIndex.repoRoot, - indexRoot: missingIndex.indexRoot, - userConfig: determinismConfig, - modes: ['code'], - strict: true, - sqliteEnabled: false -}); -assert.equal(missingReport.ok, false, 'strict validation should fail without determinism_report when enabled'); -assert.ok( - missingReport.issues.some((issue) => issue.includes('missing determinism_report')), - 'expected missing determinism_report issue' -); - -const presentRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-validate-determinism-present-')); -const presentPieces = [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' }, - { type: 'stats', name: 'determinism_report', format: 'json', path: 'determinism_report.json' } -]; -const presentIndex = await createBaseIndex({ - rootDir: presentRoot, - manifestPieces: presentPieces -}); -await fs.writeFile( - path.join(presentIndex.indexDir, 'determinism_report.json'), - JSON.stringify(makeDeterminismPayload(), null, 2), - 'utf8' -); -const presentReport = await validateIndexArtifacts({ - root: presentIndex.repoRoot, - indexRoot: presentIndex.indexRoot, - userConfig: determinismConfig, - modes: ['code'], - strict: true, - sqliteEnabled: false -}); -assert.equal(presentReport.ok, true, `validation should pass with determinism_report: ${presentReport.issues.join('; ')}`); - -console.log('index validate determinism_report required test passed'); diff --git a/tests/indexing/validate/index-validate-file-name-collision.test.js b/tests/indexing/validate/index-validate-file-name-collision.test.js deleted file mode 100644 index 5f969f2b1..000000000 --- a/tests/indexing/validate/index-validate-file-name-collision.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; -import { updatePiecesManifest } from '../../helpers/pieces-manifest.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-name-collision'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); - -const repoMap = [ - { file: 'src/a.js', name: 'dup', kind: 'Function' }, - { file: 'src/a.js', name: 'dup', kind: 'Function' } -]; -await fs.writeFile(path.join(indexDir, 'repo_map.json'), JSON.stringify(repoMap, null, 2)); - -await updatePiecesManifest(indexDir, (manifest) => { - manifest.pieces.push({ type: 'chunks', name: 'repo_map', format: 'json', path: 'repo_map.json' }); -}); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected file::name collision to fail'); -assert.ok( - report.issues.some((issue) => issue.includes('ERR_ID_COLLISION')), - `expected collision issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate file name collision test passed'); - diff --git a/tests/indexing/validate/index-validate-load-manifest.test.js b/tests/indexing/validate/index-validate-load-manifest.test.js deleted file mode 100644 index 4b4b656f4..000000000 --- a/tests/indexing/validate/index-validate-load-manifest.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-load-manifest'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -await fs.stat(path.join(indexDir, 'pieces', 'manifest.json')); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(report.ok, `expected manifest load to validate, got issues: ${report.issues.join('; ')}`); - -console.log('index-validate load manifest test passed'); diff --git a/tests/indexing/validate/index-validate-manifest-safety.test.js b/tests/indexing/validate/index-validate-manifest-safety.test.js deleted file mode 100644 index 6d86c8b93..000000000 --- a/tests/indexing/validate/index-validate-manifest-safety.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-manifest-safety'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const manifestPieces = [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: '..\\chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } -]; - -const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot, manifestPieces }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected manifest safety validation to fail'); -assert.ok( - report.issues.some((issue) => issue.includes('manifest path is not safe')), - `expected manifest path safety issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate manifest safety test passed'); - diff --git a/tests/indexing/validate/index-validate-manifest-symlink-escape.test.js b/tests/indexing/validate/index-validate-manifest-symlink-escape.test.js deleted file mode 100644 index 26d65211e..000000000 --- a/tests/indexing/validate/index-validate-manifest-symlink-escape.test.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-manifest-symlink-escape'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -const outsideRoot = path.join(tempRoot, 'outside-artifacts'); -await fs.mkdir(outsideRoot, { recursive: true }); -await fs.writeFile(path.join(outsideRoot, 'chunk_meta.json'), '[]\n', 'utf8'); - -const symlinkDir = path.join(indexDir, 'linked'); -let symlinkCreated = false; -try { - await fs.symlink(outsideRoot, symlinkDir, process.platform === 'win32' ? 'junction' : 'dir'); - symlinkCreated = true; -} catch {} - -if (!symlinkCreated) { - console.log('index-validate manifest symlink escape test skipped (symlink unavailable)'); - process.exit(0); -} - -const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); -const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); -manifest.pieces = manifest.pieces.map((piece) => ( - piece?.name === 'chunk_meta' - ? { ...piece, path: 'linked/chunk_meta.json' } - : piece -)); -await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.equal(report.ok, false, 'expected manifest symlink escape to fail validation'); -assert.ok( - report.issues.some((issue) => issue.includes('manifest path escapes index root')), - `expected index-root escape issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate manifest symlink escape test passed'); diff --git a/tests/indexing/validate/index-validate-missing-manifest.test.js b/tests/indexing/validate/index-validate-missing-manifest.test.js deleted file mode 100644 index 7d40ba5b3..000000000 --- a/tests/indexing/validate/index-validate-missing-manifest.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-missing-manifest'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -await fs.rm(path.join(indexDir, 'pieces', 'manifest.json'), { force: true }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected strict validation to fail without manifest'); -assert.ok( - report.issues.some((issue) => issue.includes('pieces/manifest.json missing')), - `expected missing manifest issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate missing manifest test passed'); - diff --git a/tests/indexing/validate/index-validate-missing-pieces.test.js b/tests/indexing/validate/index-validate-missing-pieces.test.js deleted file mode 100644 index 44109fe11..000000000 --- a/tests/indexing/validate/index-validate-missing-pieces.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-missing-pieces'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -await fs.rm(path.join(indexDir, 'chunk_meta.json'), { force: true }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected validation to fail when a manifest piece is missing'); -assert.ok( - report.issues.some((issue) => issue.includes('chunk_meta.json') && issue.includes('missing')), - `expected missing piece issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate missing pieces test passed'); diff --git a/tests/indexing/validate/index-validate-optional-file-meta-nonstrict.test.js b/tests/indexing/validate/index-validate-optional-file-meta-nonstrict.test.js deleted file mode 100644 index 2b81092d0..000000000 --- a/tests/indexing/validate/index-validate-optional-file-meta-nonstrict.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-optional-file-meta-nonstrict'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { indexRoot } = await createBaseIndex({ - rootDir: tempRoot, - manifestPieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } - ] -}); - -const report = await validateIndexArtifacts({ - root: tempRoot, - indexRoot, - userConfig: defaultUserConfig, - strict: false, - modes: ['code'], - sqliteEnabled: false -}); - -const fileMetaLoadIssue = report.issues.find((entry) => String(entry).includes('file_meta load failed')); -assert.equal( - fileMetaLoadIssue, - undefined, - `expected non-strict validation to skip optional file_meta manifest-entry misses, got: ${report.issues.join('; ')}` -); - -console.log('index-validate optional file_meta non-strict test passed'); diff --git a/tests/indexing/validate/index-validate-strict.test.js b/tests/indexing/validate/index-validate-strict.test.js deleted file mode 100644 index b17136d8f..000000000 --- a/tests/indexing/validate/index-validate-strict.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-strict'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(report.ok, `expected strict validation ok, got issues: ${report.issues.join('; ')}`); - -console.log('index-validate strict test passed'); - diff --git a/tests/indexing/validate/index-validate-unknown-artifact-fails-strict.test.js b/tests/indexing/validate/index-validate-unknown-artifact-fails-strict.test.js deleted file mode 100644 index 74cc78370..000000000 --- a/tests/indexing/validate/index-validate-unknown-artifact-fails-strict.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; -import { updatePiecesManifest } from '../../helpers/pieces-manifest.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-unknown-artifact'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -await fs.writeFile(path.join(indexDir, 'mystery.json'), JSON.stringify({ ok: true })); - -await updatePiecesManifest(indexDir, (manifest) => { - manifest.pieces.push({ type: 'misc', name: 'mystery_artifact', format: 'json', path: 'mystery.json' }); -}); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected unknown artifact to fail strict validation'); -assert.ok( - report.issues.some((issue) => issue.includes('unknown artifact name')), - `expected unknown artifact issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate unknown artifact test passed'); - diff --git a/tests/indexing/validate/index-validate-unknown-mode.test.js b/tests/indexing/validate/index-validate-unknown-mode.test.js deleted file mode 100644 index b672d00d4..000000000 --- a/tests/indexing/validate/index-validate-unknown-mode.test.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCombinedOutput } from '../../helpers/stdio.js'; - -const root = process.cwd(); -const validatorPath = path.join(root, 'tools', 'index', 'validate.js'); - -const result = spawnSync( - process.execPath, - [validatorPath, '--mode', 'nope', '--json'], - { encoding: 'utf8' } -); - -assert.notEqual(result.status, 0, 'expected non-zero exit for unknown mode'); -const combined = getCombinedOutput(result).toLowerCase(); -assert.ok(combined.includes('unknown mode'), `expected unknown mode error, got: ${combined}`); - -console.log('index-validate unknown mode test passed'); diff --git a/tests/indexing/validate/index-validate-unknown-piece.test.js b/tests/indexing/validate/index-validate-unknown-piece.test.js deleted file mode 100644 index 884aa35da..000000000 --- a/tests/indexing/validate/index-validate-unknown-piece.test.js +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; -import { updatePiecesManifest } from '../../helpers/pieces-manifest.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-unknown-piece'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ rootDir: tempRoot }); -await fs.writeFile(path.join(indexDir, 'mystery.json'), JSON.stringify({ ok: true })); - -await updatePiecesManifest(indexDir, (manifest) => { - manifest.pieces.push({ type: 'misc', name: 'mystery_artifact', format: 'json', path: 'mystery.json' }); -}); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected unknown piece to fail strict validation'); -assert.ok( - report.issues.some((issue) => issue.includes('unknown artifact name')), - `expected unknown artifact name issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate unknown piece test passed'); diff --git a/tests/indexing/validate/index-validate.test.js b/tests/indexing/validate/index-validate.test.js deleted file mode 100644 index 9813c8916..000000000 --- a/tests/indexing/validate/index-validate.test.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { repoRoot } from '../../helpers/root.js'; -import { copyFixtureToTemp } from '../../helpers/fixtures.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -const root = repoRoot(); -const fixtureRoot = await copyFixtureToTemp('sample'); -const fixtureTempRoot = path.dirname(fixtureRoot); -const cacheRoot = await makeTempDir('pairofcleats-index-validate-'); -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - sqlite: { use: false }, - indexing: { - scm: { provider: 'none' }, - embeddings: { enabled: false } - } - } -}); - -const validatorPath = path.join(root, 'tools', 'index', 'validate.js'); -const buildPath = path.join(root, 'build_index.js'); - -const missingResult = spawnSync( - process.execPath, - [validatorPath, '--repo', fixtureRoot, '--json'], - { env, encoding: 'utf8' } -); -if (missingResult.status === 0) { - console.error('Expected index-validate to fail when indexes are missing.'); - process.exit(1); -} - -const buildResult = spawnSync( - process.execPath, - [buildPath, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', fixtureRoot], - { env, encoding: 'utf8' } -); -if (buildResult.status !== 0) { - console.error('Failed to build fixture index for index-validate test.'); - if (buildResult.stderr) console.error(buildResult.stderr.trim()); - process.exit(buildResult.status ?? 1); -} -const userConfig = loadUserConfig(fixtureRoot); -const codeDir = getIndexDir(fixtureRoot, 'code', userConfig); -const piecesPath = path.join(codeDir, 'pieces', 'manifest.json'); -try { - await fsPromises.access(piecesPath); -} catch { - console.error('Expected pieces manifest to exist after build.'); - process.exit(1); -} - -const okResult = spawnSync( - process.execPath, - [validatorPath, '--repo', fixtureRoot, '--json'], - { env, encoding: 'utf8' } -); -if (okResult.status !== 0) { - console.error('Expected index-validate to pass after building index.'); - if (okResult.stderr) console.error(okResult.stderr.trim()); - process.exit(okResult.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(okResult.stdout); -} catch { - console.error('index-validate did not return valid JSON.'); - process.exit(1); -} -if (!payload || payload.ok !== true) { - console.error('index-validate JSON payload missing ok=true.'); - process.exit(1); -} - -console.log('index-validate test passed'); - -await rmDirRecursive(cacheRoot); -await rmDirRecursive(fixtureTempRoot); diff --git a/tests/indexing/validate/index.test.js b/tests/indexing/validate/index.test.js new file mode 100644 index 000000000..d2400031c --- /dev/null +++ b/tests/indexing/validate/index.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { repoRoot } from '../../helpers/root.js'; +import { copyFixtureToTemp } from '../../helpers/fixtures.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = repoRoot(); +const fixtureRoot = await copyFixtureToTemp('sample'); +const fixtureTempRoot = path.dirname(fixtureRoot); +const cacheRoot = await makeTempDir('pairofcleats-index-validate-'); +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + sqlite: { use: false }, + indexing: { + scm: { provider: 'none' }, + embeddings: { enabled: false } + } + } +}); + +const validatorPath = path.join(root, 'tools', 'index', 'validate.js'); +const buildPath = path.join(root, 'build_index.js'); + +const missingResult = runNode( + [validatorPath, '--repo', fixtureRoot, '--json'], + 'index validate missing index', + root, + env, + { + stdio: 'pipe', + allowFailure: true + } +); +if (missingResult.status === 0) { + console.error('Expected index-validate to fail when indexes are missing.'); + process.exit(1); +} + +const buildResult = runNode( + [buildPath, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', fixtureRoot], + 'index validate build fixture', + root, + env, + { stdio: 'pipe' } +); +if (buildResult.status !== 0) { + console.error('Failed to build fixture index for index-validate test.'); + if (buildResult.stderr) console.error(buildResult.stderr.trim()); + process.exit(buildResult.status ?? 1); +} +const userConfig = loadUserConfig(fixtureRoot); +const codeDir = getIndexDir(fixtureRoot, 'code', userConfig); +const piecesPath = path.join(codeDir, 'pieces', 'manifest.json'); +try { + await fsPromises.access(piecesPath); +} catch { + console.error('Expected pieces manifest to exist after build.'); + process.exit(1); +} + +const okResult = runNode( + [validatorPath, '--repo', fixtureRoot, '--json'], + 'index validate built index', + root, + env, + { stdio: 'pipe' } +); +if (okResult.status !== 0) { + console.error('Expected index-validate to pass after building index.'); + if (okResult.stderr) console.error(okResult.stderr.trim()); + process.exit(okResult.status ?? 1); +} + +let payload; +try { + payload = JSON.parse(okResult.stdout); +} catch { + console.error('index-validate did not return valid JSON.'); + process.exit(1); +} +if (!payload || payload.ok !== true) { + console.error('index-validate JSON payload missing ok=true.'); + process.exit(1); +} + +console.log('index-validate test passed'); + +await rmDirRecursive(cacheRoot); +await rmDirRecursive(fixtureTempRoot); diff --git a/tests/indexing/validate/manifest-checks.test.js b/tests/indexing/validate/manifest-checks.test.js deleted file mode 100644 index 9d9087055..000000000 --- a/tests/indexing/validate/manifest-checks.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { validateIndexArtifacts } from '../../../src/index/validate.js'; -import { createBaseIndex, defaultUserConfig } from './helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-validate-manifest-checks'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const manifestPieces = [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json', checksum: 'sha1:deadbeef' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } -]; - -const { repoRoot, indexRoot } = await createBaseIndex({ rootDir: tempRoot, manifestPieces }); - -const report = await validateIndexArtifacts({ - root: repoRoot, - indexRoot, - modes: ['code'], - userConfig: defaultUserConfig, - strict: true, - sqliteEnabled: false, - lmdbEnabled: false -}); - -assert.ok(!report.ok, 'expected manifest checksum mismatch to fail validation'); -assert.ok( - report.issues.some((issue) => issue.includes('piece checksum mismatch')), - `expected checksum mismatch issue, got: ${report.issues.join('; ')}` -); - -console.log('index-validate manifest checks test passed'); diff --git a/tests/indexing/validate/manifest-path-native.test.js b/tests/indexing/validate/manifest-path-native.test.js deleted file mode 100644 index 109824d53..000000000 --- a/tests/indexing/validate/manifest-path-native.test.js +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { isManifestPathSafe } from '../../../src/index/validate/paths.js'; - -const isWin = process.platform === 'win32'; - -assert.equal(isManifestPathSafe('C:/repo/file.txt'), !isWin); -assert.equal(isManifestPathSafe('/abs/file.txt'), false); -assert.equal(isManifestPathSafe('../escape.txt'), false); - -console.log('manifest path native checks ok.'); diff --git a/tests/indexing/validate/resolve-index-dir-artifact-variants.test.js b/tests/indexing/validate/resolve-index-dir-artifact-variants.test.js deleted file mode 100644 index 5202a0a8e..000000000 --- a/tests/indexing/validate/resolve-index-dir-artifact-variants.test.js +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { resolveIndexDir } from '../../../src/index/validate/paths.js'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'resolve-index-dir-artifact-variants'); -const repoRoot = path.join(testRoot, 'repo'); -const cacheRoot = path.join(testRoot, 'cache'); - -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile( - path.join(repoRoot, '.pairofcleats.json'), - JSON.stringify({ cache: { root: cacheRoot } }, null, 2), - 'utf8' -); - -const userConfig = loadUserConfig(repoRoot); -const cachedDir = getIndexDir(repoRoot, 'code', userConfig); -const localDir = path.join(repoRoot, 'index-code'); - -await fs.mkdir(cachedDir, { recursive: true }); -await fs.mkdir(localDir, { recursive: true }); - -await fs.writeFile(path.join(cachedDir, 'chunk_meta.json.gz'), 'cached-compressed', 'utf8'); -let resolved = resolveIndexDir(repoRoot, 'code', userConfig, null, false); -assert.equal(resolved, cachedDir, 'expected cached index dir when compressed chunk_meta exists in cache'); - -await fs.rm(path.join(cachedDir, 'chunk_meta.json.gz'), { force: true }); -await fs.writeFile(path.join(localDir, 'chunk_meta.jsonl.gz'), 'local-compressed', 'utf8'); -resolved = resolveIndexDir(repoRoot, 'code', userConfig, null, false); -assert.equal(resolved, localDir, 'expected local index dir fallback when cache is missing but local compressed chunk_meta exists'); - -console.log('resolve index dir artifact variant tests passed'); diff --git a/tests/indexing/validate/symbol-integrity-strict.test.js b/tests/indexing/validate/symbol-integrity-strict.test.js index 30ad293ef..cd7901b28 100644 --- a/tests/indexing/validate/symbol-integrity-strict.test.js +++ b/tests/indexing/validate/symbol-integrity-strict.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { validateIndexArtifacts } from '../../../src/index/validate.js'; import { createBaseIndex, defaultUserConfig } from './helpers.js'; diff --git a/tests/indexing/validate/validator/risk-interprocedural.test.js b/tests/indexing/validate/validator/risk-interprocedural.test.js index b902ca0b7..03d3b3090 100644 --- a/tests/indexing/validate/validator/risk-interprocedural.test.js +++ b/tests/indexing/validate/validator/risk-interprocedural.test.js @@ -8,6 +8,7 @@ import { validateIndexArtifacts } from '../../../../src/index/validate.js'; import { ARTIFACT_SURFACE_VERSION } from '../../../../src/contracts/versioning.js'; import { createBaseIndex, defaultUserConfig } from '../helpers.js'; import { updatePiecesManifest } from '../../../helpers/pieces-manifest.js'; +import { createCanonicalTestChunkUid } from '../../../helpers/chunk-uid.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; @@ -16,9 +17,18 @@ const tempRoot = resolveTestCachePath(root, 'validator-risk-interprocedural'); await fs.rm(tempRoot, { recursive: true, force: true }); await fs.mkdir(tempRoot, { recursive: true }); +const sourceChunkUid = createCanonicalTestChunkUid({ + virtualPath: 'src/source.js', + salt: 'risk-interprocedural-source' +}); +const sinkChunkUid = createCanonicalTestChunkUid({ + virtualPath: 'src/sink.js', + salt: 'risk-interprocedural-sink' +}); + const chunkMeta = [ - { id: 0, file: 'src/source.js', start: 0, end: 10, chunkUid: 'uid-source' }, - { id: 1, file: 'src/sink.js', start: 0, end: 8, chunkUid: 'uid-sink' } + { id: 0, file: 'src/source.js', start: 0, end: 10, chunkUid: sourceChunkUid }, + { id: 1, file: 'src/sink.js', start: 0, end: 8, chunkUid: sinkChunkUid } ]; const indexState = { generatedAt: new Date().toISOString(), @@ -47,8 +57,8 @@ const { repoRoot, indexRoot, indexDir } = await createBaseIndex({ }); const chunkUidMap = [ - { docId: 0, chunkUid: 'uid-source', chunkId: 'chunk_source', file: 'src/source.js', start: 0, end: 10 }, - { docId: 1, chunkUid: 'uid-sink', chunkId: 'chunk_sink', file: 'src/sink.js', start: 0, end: 8 } + { docId: 0, chunkUid: sourceChunkUid, chunkId: 'chunk_source', file: 'src/source.js', start: 0, end: 10 }, + { docId: 1, chunkUid: sinkChunkUid, chunkId: 'chunk_sink', file: 'src/sink.js', start: 0, end: 8 } ]; const callSiteId = buildCallSiteId({ @@ -60,10 +70,33 @@ const callSiteId = buildCallSiteId({ calleeRaw: 'sink' }); +const createCallbackWatchStep = () => ({ + taintIn: ['req.body'], + taintOut: [], + propagatedArgIndices: [], + boundParams: [], + calleeNormalized: 'sink', + semanticIds: ['sem.callback.register-handler-payload'], + semanticKinds: ['callback'], + sanitizerPolicy: 'terminate', + sanitizerBarrierApplied: false, + sanitizerBarriersBefore: 0, + sanitizerBarriersAfter: 0, + confidenceBefore: 0.6, + confidenceAfter: 0.51, + confidenceDelta: -0.09 +}); + +const createRiskPath = () => ({ + chunkUids: [sourceChunkUid, sinkChunkUid], + callSiteIdsByStep: [[callSiteId]], + watchByStep: [createCallbackWatchStep()] +}); + const callSites = [ { callSiteId, - callerChunkUid: 'uid-source', + callerChunkUid: sourceChunkUid, callerDocId: 0, file: 'src/source.js', languageId: 'javascript', @@ -77,7 +110,7 @@ const callSites = [ calleeNormalized: 'sink', args: ['value'], evidence: [], - targetChunkUid: 'uid-sink', + targetChunkUid: sinkChunkUid, targetCandidates: [], snippetHash: null } @@ -86,7 +119,7 @@ const callSites = [ const riskSummaries = [ { schemaVersion: 1, - chunkUid: 'uid-source', + chunkUid: sourceChunkUid, file: 'src/source.js', languageId: 'javascript', symbol: { name: 'source', kind: 'Function', signature: null }, @@ -121,7 +154,7 @@ const riskSummaries = [ }, { schemaVersion: 1, - chunkUid: 'uid-sink', + chunkUid: sinkChunkUid, file: 'src/sink.js', languageId: 'javascript', symbol: { name: 'sink', kind: 'Function', signature: null }, @@ -156,13 +189,13 @@ const riskSummaries = [ } ]; -const flowId = `sha1:${sha1('uid-source|source.req.body|uid-sink|sink.eval|uid-source>uid-sink')}`; +const flowId = `sha1:${sha1(`${sourceChunkUid}|source.req.body|${sinkChunkUid}|sink.eval|${sourceChunkUid}>${sinkChunkUid}`)}`; const riskFlows = [ { schemaVersion: 1, flowId, source: { - chunkUid: 'uid-source', + chunkUid: sourceChunkUid, ruleId: 'source.req.body', ruleName: 'req.body', ruleType: 'source', @@ -171,7 +204,7 @@ const riskFlows = [ confidence: 0.6 }, sink: { - chunkUid: 'uid-sink', + chunkUid: sinkChunkUid, ruleId: 'sink.eval', ruleName: 'eval', ruleType: 'sink', @@ -179,10 +212,7 @@ const riskFlows = [ severity: 'high', confidence: 0.8 }, - path: { - chunkUids: ['uid-source', 'uid-sink'], - callSiteIdsByStep: [[callSiteId]] - }, + path: createRiskPath(), confidence: 0.5, notes: { strictness: 'conservative', @@ -194,6 +224,44 @@ const riskFlows = [ } ]; +const partialFlowId = `sha1:${sha1(`${sourceChunkUid}|source.req.body|${sinkChunkUid}|maxDepth|${sourceChunkUid}>${sinkChunkUid}`)}`; +const riskPartialFlows = [ + { + schemaVersion: 1, + partialFlowId, + source: { + chunkUid: sourceChunkUid, + ruleId: 'source.req.body', + ruleName: 'req.body', + ruleType: 'source', + category: 'input', + severity: null, + confidence: 0.6 + }, + frontier: { + chunkUid: sinkChunkUid, + terminalReason: 'maxDepth', + blockedExpansions: [ + { + targetChunkUid: sinkChunkUid, + reason: 'maxEdgeExpansions', + callSiteIds: [callSiteId] + } + ] + }, + path: createRiskPath(), + confidence: 0.45, + notes: { + strictness: 'conservative', + sanitizerPolicy: 'terminate', + hopCount: 1, + sanitizerBarriersHit: 0, + capsHit: ['maxDepth'], + terminalReason: 'maxDepth' + } + } +]; + const stats = { schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -221,6 +289,7 @@ const stats = { sourceRoots: 1, resolvedEdges: 1, flowsEmitted: 1, + partialFlowsEmitted: 1, risksWithFlows: 1, uniqueCallSitesReferenced: 1 }, @@ -242,6 +311,7 @@ const writeJsonl = async (filePath, rows) => { await writeJsonl(path.join(indexDir, 'call_sites.jsonl'), callSites); await writeJsonl(path.join(indexDir, 'risk_summaries.jsonl'), riskSummaries); await writeJsonl(path.join(indexDir, 'risk_flows.jsonl'), riskFlows); +await writeJsonl(path.join(indexDir, 'risk_partial_flows.jsonl'), riskPartialFlows); await fs.writeFile(path.join(indexDir, 'risk_interprocedural_stats.json'), JSON.stringify(stats, null, 2)); await fs.writeFile(path.join(indexDir, 'chunk_uid_map.json'), JSON.stringify(chunkUidMap, null, 2)); @@ -251,6 +321,7 @@ await updatePiecesManifest(indexDir, (manifest) => { { type: 'chunks', name: 'chunk_uid_map', format: 'json', path: 'chunk_uid_map.json' }, { type: 'risk', name: 'risk_summaries', format: 'jsonl', path: 'risk_summaries.jsonl' }, { type: 'risk', name: 'risk_flows', format: 'jsonl', path: 'risk_flows.jsonl' }, + { type: 'risk', name: 'risk_partial_flows', format: 'jsonl', path: 'risk_partial_flows.jsonl' }, { type: 'risk', name: 'risk_interprocedural_stats', format: 'json', path: 'risk_interprocedural_stats.json' } ); }); @@ -283,4 +354,49 @@ report = await validateIndexArtifacts({ assert.ok(!report.ok, 'expected validation to fail with bad callSiteId'); assert.ok(report.issues.some((issue) => issue.includes('callSiteId')), 'expected callSiteId issue'); +await writeJsonl(path.join(indexDir, 'risk_flows.jsonl'), riskFlows); +const corruptedPartials = riskPartialFlows.map((row) => ({ + ...row, + frontier: { + ...row.frontier, + blockedExpansions: [ + { + ...row.frontier.blockedExpansions[0], + callSiteIds: ['sha1:deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'] + } + ] + } +})); +await writeJsonl(path.join(indexDir, 'risk_partial_flows.jsonl'), corruptedPartials); +report = await validateIndexArtifacts({ + root: repoRoot, + indexRoot, + modes: ['code'], + userConfig: defaultUserConfig, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false +}); +assert.ok(!report.ok, 'expected validation to fail with bad blocked partial callSiteId'); +assert.ok(report.issues.some((issue) => issue.includes('blocked callSiteId')), 'expected blocked partial callSiteId issue'); + +await writeJsonl(path.join(indexDir, 'risk_partial_flows.jsonl'), [{ + ...riskPartialFlows[0], + path: { + ...riskPartialFlows[0].path, + watchByStep: [] + } +}]); +report = await validateIndexArtifacts({ + root: repoRoot, + indexRoot, + modes: ['code'], + userConfig: defaultUserConfig, + strict: true, + sqliteEnabled: false, + lmdbEnabled: false +}); +assert.ok(!report.ok, 'expected validation to fail with watchByStep length mismatch'); +assert.ok(report.issues.some((issue) => issue.includes('watchByStep length mismatch')), 'expected watchByStep mismatch issue'); + console.log('risk interprocedural validator test passed'); diff --git a/tests/indexing/vfs/vfs-cdc-min-file-bytes.test.js b/tests/indexing/vfs/cdc-min-file-bytes.test.js similarity index 100% rename from tests/indexing/vfs/vfs-cdc-min-file-bytes.test.js rename to tests/indexing/vfs/cdc-min-file-bytes.test.js diff --git a/tests/indexing/vfs/vfs-cdc-segmentation-contract.test.js b/tests/indexing/vfs/cdc-segmentation-contract.test.js similarity index 100% rename from tests/indexing/vfs/vfs-cdc-segmentation-contract.test.js rename to tests/indexing/vfs/cdc-segmentation-contract.test.js diff --git a/tests/indexing/vfs/vfs-compaction-atomic-swap.test.js b/tests/indexing/vfs/compaction-atomic-swap.test.js similarity index 100% rename from tests/indexing/vfs/vfs-compaction-atomic-swap.test.js rename to tests/indexing/vfs/compaction-atomic-swap.test.js diff --git a/tests/indexing/vfs/vfs-disk-path-safety.test.js b/tests/indexing/vfs/disk-path-safety.test.js similarity index 100% rename from tests/indexing/vfs/vfs-disk-path-safety.test.js rename to tests/indexing/vfs/disk-path-safety.test.js diff --git a/tests/indexing/vfs/idx-lookup-roundtrip.test.js b/tests/indexing/vfs/idx-lookup-roundtrip.test.js new file mode 100644 index 000000000..86bd48c66 --- /dev/null +++ b/tests/indexing/vfs/idx-lookup-roundtrip.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +import { + loadVfsManifestIndex, + loadVfsManifestRowByPath +} from '../../../src/index/tooling/vfs.js'; +import { createSingleSegmentVfsManifestFixture } from '../../helpers/vfs-streaming-fixture.js'; + +const fixture = await createSingleSegmentVfsManifestFixture({ tempPrefix: 'pairofcleats-vfs-idx-' }); + +try { + await fixture.writeManifest(); + + await fs.stat(fixture.indexPath); + + const index = await loadVfsManifestIndex({ indexPath: fixture.indexPath }); + assert.equal(index.size, fixture.rows.length); + + const row = fixture.rows[0]; + const loaded = await loadVfsManifestRowByPath({ + manifestPath: fixture.manifestPath, + index, + virtualPath: row.virtualPath + }); + assert.deepStrictEqual(loaded, row); + + console.log('vfs index lookup ok'); +} finally { + await fixture.cleanup(); +} diff --git a/tests/indexing/vfs/vfs-manifest-collector-isolation.test.js b/tests/indexing/vfs/manifest-collector-isolation.test.js similarity index 100% rename from tests/indexing/vfs/vfs-manifest-collector-isolation.test.js rename to tests/indexing/vfs/manifest-collector-isolation.test.js diff --git a/tests/indexing/vfs/manifest-roundtrip.test.js b/tests/indexing/vfs/manifest-roundtrip.test.js new file mode 100644 index 000000000..bfbc779b4 --- /dev/null +++ b/tests/indexing/vfs/manifest-roundtrip.test.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ARTIFACT_SCHEMA_DEFS } from '../../../src/contracts/registry.js'; +import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; +import { checksumString } from '../../../src/shared/hash.js'; +import { + buildVfsManifestRowsForFile, + buildVfsVirtualPath +} from '../../../src/index/tooling/vfs.js'; +import { runVfsManifestWriter } from '../../helpers/vfs-streaming-fixture.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +assert.ok( + ARTIFACT_SCHEMA_DEFS && typeof ARTIFACT_SCHEMA_DEFS === 'object' && ARTIFACT_SCHEMA_DEFS.vfs_manifest, + 'Expected contracts registry to include a vfs_manifest schema.' +); + +const tempRoot = await makeTempDir('pairofcleats-vfs-manifest-'); +const plainDir = path.join(tempRoot, 'plain'); +const shardedDir = path.join(tempRoot, 'sharded'); +await fs.mkdir(plainDir, { recursive: true }); +await fs.mkdir(shardedDir, { recursive: true }); + +try { + const containerPath = 'docs/hello%world#v2.md'; + const containerExt = '.md'; + const containerLanguageId = 'markdown'; + const fileText = 'console.log(1);\nconsole.log(2);\n'; + + const firstLineEnd = fileText.indexOf('\n') + 1; + const segmentA = { + segmentUid: 'segu:v1:seg-a', + segmentId: 'seg-a', + start: 0, + end: firstLineEnd, + languageId: 'javascript', + ext: null + }; + const segmentB = { + segmentUid: 'segu:v1:seg-b', + segmentId: 'seg-b', + start: firstLineEnd, + end: fileText.length, + languageId: 'javascript', + ext: null + }; + + const chunks = [ + { + file: containerPath, + lang: 'javascript', + segment: segmentB, + start: segmentB.start, + end: segmentB.end + }, + { + file: containerPath, + lang: 'javascript', + segment: segmentA, + start: segmentA.start, + end: segmentA.end + } + ]; + + const rows = await buildVfsManifestRowsForFile({ + chunks, + fileText, + containerPath, + containerExt, + containerLanguageId + }); + + assert.equal(rows.length, 2, 'Expected one vfs_manifest row per distinct segmentUid.'); + assert.equal(rows[0].segmentUid, segmentA.segmentUid, 'rows should be sorted by segmentStart'); + assert.equal(rows[1].segmentUid, segmentB.segmentUid, 'rows should be sorted by segmentStart'); + + for (const row of rows) { + const expectedVirtualPath = buildVfsVirtualPath({ + containerPath, + segmentUid: row.segmentUid, + effectiveExt: row.effectiveExt + }); + assert.equal(row.virtualPath, expectedVirtualPath, 'virtualPath should be a deterministic function of containerPath+segmentUid+effectiveExt'); + + const segmentText = fileText.slice(row.segmentStart, row.segmentEnd); + const hash = await checksumString(segmentText); + const expectedDocHash = hash?.value ? `xxh64:${hash.value}` : 'xxh64:'; + assert.equal(row.docHash, expectedDocHash, 'docHash should roundtrip from the referenced segment text'); + + assert.equal(row.containerPath, containerPath); + assert.equal(row.containerExt, containerExt); + assert.equal(row.containerLanguageId, containerLanguageId); + assert.equal(row.languageId, 'javascript'); + assert.equal(row.effectiveExt, '.js', 'effectiveExt should follow language-id extension mapping for embedded segments'); + } + + // Unsharded write/read. + await runVfsManifestWriter({ outDir: plainDir, mode: 'code', rows, maxJsonBytes: 1024 * 1024 }); + const plainLoaded = await loadJsonArrayArtifact(plainDir, 'vfs_manifest', { strict: false }); + assert.deepStrictEqual(plainLoaded, rows, 'Unsharded vfs_manifest should roundtrip identically.'); + + // Force sharded write/read by setting maxJsonBytes to just above the largest JSONL line. + const jsonlLineBytes = rows.map((row) => Buffer.byteLength(`${JSON.stringify(row)}\n`)); + const maxLineBytes = Math.max(...jsonlLineBytes); + const totalBytes = jsonlLineBytes.reduce((sum, bytes) => sum + bytes, 0); + assert.ok(totalBytes > maxLineBytes + 1, 'Fixture should be large enough to force sharding.'); + + await runVfsManifestWriter({ outDir: shardedDir, mode: 'code', rows, maxJsonBytes: maxLineBytes + 1 }); + const shardedLoaded = await loadJsonArrayArtifact(shardedDir, 'vfs_manifest', { strict: false }); + assert.deepStrictEqual(shardedLoaded, rows, 'Sharded vfs_manifest should roundtrip identically.'); + + await fs.stat(path.join(shardedDir, 'vfs_manifest.meta.json')); + await fs.stat(path.join(shardedDir, 'vfs_manifest.parts')); + + console.log('VFS manifest roundtrip ok'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/indexing/vfs/manifest-row-trimming.test.js b/tests/indexing/vfs/manifest-row-trimming.test.js new file mode 100644 index 000000000..bae36fa31 --- /dev/null +++ b/tests/indexing/vfs/manifest-row-trimming.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createVfsRowTrimFixture } from '../../helpers/vfs-streaming-fixture.js'; + +const MAX_ROW_BYTES = 32 * 1024; + +const fixture = await createVfsRowTrimFixture({ tempPrefix: 'pairofcleats-vfs-trim-' }); + +try { + const { baseRows } = fixture; + assert.equal(baseRows.length, 1, 'expected a base vfs manifest row'); + + const loaded = await fixture.writeOversizedExtensionsAndLoad(); + + assert.equal(loaded.length, 1, 'trimmed row should still be emitted'); + assert.equal(loaded[0].segmentId, baseRows[0].segmentId, 'segmentId should be preserved'); + assert.ok(!loaded[0].extensions, 'extensions should be trimmed when oversize'); + + const rowBytes = Buffer.byteLength(JSON.stringify(loaded[0]), 'utf8'); + assert.ok(rowBytes <= MAX_ROW_BYTES, 'trimmed row should fit within MAX_ROW_BYTES'); + + console.log('VFS manifest row trimming ok'); +} finally { + await fixture.cleanup(); +} diff --git a/tests/indexing/vfs/vfs-manifest-streaming.test.js b/tests/indexing/vfs/manifest-streaming.test.js similarity index 100% rename from tests/indexing/vfs/vfs-manifest-streaming.test.js rename to tests/indexing/vfs/manifest-streaming.test.js diff --git a/tests/indexing/vfs/path-map-roundtrip.test.js b/tests/indexing/vfs/path-map-roundtrip.test.js new file mode 100644 index 000000000..d6d969199 --- /dev/null +++ b/tests/indexing/vfs/path-map-roundtrip.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +import { buildVfsHashVirtualPath } from '../../../src/index/tooling/vfs.js'; +import { createSingleSegmentVfsManifestFixture } from '../../helpers/vfs-streaming-fixture.js'; + +const fixture = await createSingleSegmentVfsManifestFixture({ tempPrefix: 'pairofcleats-vfs-map-' }); + +try { + await fixture.writeManifest({ hashRouting: true }); + + const contents = await fs.readFile(fixture.mapPath, 'utf8'); + const line = contents.trim(); + assert.ok(line, 'expected vfs_path_map content'); + const entry = JSON.parse(line); + const row = fixture.rows[0]; + assert.equal(entry.virtualPath, row.virtualPath); + const expectedHash = buildVfsHashVirtualPath({ + docHash: row.docHash, + effectiveExt: row.effectiveExt + }); + assert.equal(entry.hashVirtualPath, expectedHash); + assert.equal(entry.containerPath, row.containerPath); + assert.equal(entry.segmentUid, row.segmentUid); + assert.equal(entry.segmentStart, row.segmentStart); + assert.equal(entry.segmentEnd, row.segmentEnd); + + console.log('vfs path map ok'); +} finally { + await fixture.cleanup(); +} diff --git a/tests/indexing/vfs/vfs-path-traversal-deny.test.js b/tests/indexing/vfs/path-traversal-deny.test.js similarity index 100% rename from tests/indexing/vfs/vfs-path-traversal-deny.test.js rename to tests/indexing/vfs/path-traversal-deny.test.js diff --git a/tests/indexing/vfs/vfs-segment-hash-cache-contract.test.js b/tests/indexing/vfs/segment-hash-cache-contract.test.js similarity index 100% rename from tests/indexing/vfs/vfs-segment-hash-cache-contract.test.js rename to tests/indexing/vfs/segment-hash-cache-contract.test.js diff --git a/tests/indexing/vfs/vfs-idx-lookup-roundtrip.test.js b/tests/indexing/vfs/vfs-idx-lookup-roundtrip.test.js deleted file mode 100644 index 4b2c14e6d..000000000 --- a/tests/indexing/vfs/vfs-idx-lookup-roundtrip.test.js +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { - buildVfsManifestRowsForFile, - loadVfsManifestIndex, - loadVfsManifestRowByPath -} from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const runWriter = async ({ outDir, mode, rows }) => { - const writes = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = () => {}; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes: 1000000, - compression: null, - gzipOptions: null, - hashRouting: false, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-idx-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const fileText = 'console.log(1);\n'; - const rows = await buildVfsManifestRowsForFile({ - chunks: [ - { - file: 'a.md', - lang: 'javascript', - segment: { - segmentUid: 'segu:v1:a', - segmentId: 'seg-a', - start: 0, - end: fileText.length, - languageId: 'javascript', - ext: null - }, - start: 0, - end: fileText.length - } - ], - fileText, - containerPath: 'a.md', - containerExt: '.md', - containerLanguageId: 'markdown' - }); - - await runWriter({ outDir, mode: 'code', rows }); - - const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); - const indexPath = path.join(outDir, 'vfs_manifest.vfsidx'); - await fs.stat(indexPath); - - const index = await loadVfsManifestIndex({ indexPath }); - assert.equal(index.size, rows.length); - - const row = rows[0]; - const loaded = await loadVfsManifestRowByPath({ - manifestPath, - index, - virtualPath: row.virtualPath - }); - assert.deepStrictEqual(loaded, row); - - console.log('vfs index lookup ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/indexing/vfs/vfs-manifest-roundtrip.test.js b/tests/indexing/vfs/vfs-manifest-roundtrip.test.js deleted file mode 100644 index 9178c0ae0..000000000 --- a/tests/indexing/vfs/vfs-manifest-roundtrip.test.js +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { ARTIFACT_SCHEMA_DEFS } from '../../../src/contracts/registry.js'; -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { checksumString } from '../../../src/shared/hash.js'; -import { - buildVfsManifestRowsForFile, - buildVfsVirtualPath -} from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -assert.ok( - ARTIFACT_SCHEMA_DEFS && typeof ARTIFACT_SCHEMA_DEFS === 'object' && ARTIFACT_SCHEMA_DEFS.vfs_manifest, - 'Expected contracts registry to include a vfs_manifest schema.' -); - -const runWriter = async ({ outDir, mode, rows, maxJsonBytes }) => { - const writes = []; - const pieceFiles = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = (entry, absPath) => { - pieceFiles.push({ entry, absPath }); - }; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes, - compression: null, - gzipOptions: null, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } - - if (pieceFiles.length) { - const pieces = pieceFiles.map(({ entry, absPath }) => ({ - ...entry, - path: path.relative(outDir, absPath).replace(/\\/g, '/') - })); - await writePiecesManifest(outDir, pieces); - } - - return { pieceFiles }; -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-manifest-'); -const plainDir = path.join(tempRoot, 'plain'); -const shardedDir = path.join(tempRoot, 'sharded'); -await fs.mkdir(plainDir, { recursive: true }); -await fs.mkdir(shardedDir, { recursive: true }); - -try { - const containerPath = 'docs/hello%world#v2.md'; - const containerExt = '.md'; - const containerLanguageId = 'markdown'; - const fileText = 'console.log(1);\nconsole.log(2);\n'; - - const firstLineEnd = fileText.indexOf('\n') + 1; - const segmentA = { - segmentUid: 'segu:v1:seg-a', - segmentId: 'seg-a', - start: 0, - end: firstLineEnd, - languageId: 'javascript', - ext: null - }; - const segmentB = { - segmentUid: 'segu:v1:seg-b', - segmentId: 'seg-b', - start: firstLineEnd, - end: fileText.length, - languageId: 'javascript', - ext: null - }; - - const chunks = [ - { - file: containerPath, - lang: 'javascript', - segment: segmentB, - start: segmentB.start, - end: segmentB.end - }, - { - file: containerPath, - lang: 'javascript', - segment: segmentA, - start: segmentA.start, - end: segmentA.end - } - ]; - - const rows = await buildVfsManifestRowsForFile({ - chunks, - fileText, - containerPath, - containerExt, - containerLanguageId - }); - - assert.equal(rows.length, 2, 'Expected one vfs_manifest row per distinct segmentUid.'); - assert.equal(rows[0].segmentUid, segmentA.segmentUid, 'rows should be sorted by segmentStart'); - assert.equal(rows[1].segmentUid, segmentB.segmentUid, 'rows should be sorted by segmentStart'); - - for (const row of rows) { - const expectedVirtualPath = buildVfsVirtualPath({ - containerPath, - segmentUid: row.segmentUid, - effectiveExt: row.effectiveExt - }); - assert.equal(row.virtualPath, expectedVirtualPath, 'virtualPath should be a deterministic function of containerPath+segmentUid+effectiveExt'); - - const segmentText = fileText.slice(row.segmentStart, row.segmentEnd); - const hash = await checksumString(segmentText); - const expectedDocHash = hash?.value ? `xxh64:${hash.value}` : 'xxh64:'; - assert.equal(row.docHash, expectedDocHash, 'docHash should roundtrip from the referenced segment text'); - - assert.equal(row.containerPath, containerPath); - assert.equal(row.containerExt, containerExt); - assert.equal(row.containerLanguageId, containerLanguageId); - assert.equal(row.languageId, 'javascript'); - assert.equal(row.effectiveExt, '.js', 'effectiveExt should follow language-id extension mapping for embedded segments'); - } - - // Unsharded write/read. - await runWriter({ outDir: plainDir, mode: 'code', rows, maxJsonBytes: 1024 * 1024 }); - const plainLoaded = await loadJsonArrayArtifact(plainDir, 'vfs_manifest', { strict: false }); - assert.deepStrictEqual(plainLoaded, rows, 'Unsharded vfs_manifest should roundtrip identically.'); - - // Force sharded write/read by setting maxJsonBytes to just above the largest JSONL line. - const jsonlLineBytes = rows.map((row) => Buffer.byteLength(`${JSON.stringify(row)}\n`)); - const maxLineBytes = Math.max(...jsonlLineBytes); - const totalBytes = jsonlLineBytes.reduce((sum, bytes) => sum + bytes, 0); - assert.ok(totalBytes > maxLineBytes + 1, 'Fixture should be large enough to force sharding.'); - - await runWriter({ outDir: shardedDir, mode: 'code', rows, maxJsonBytes: maxLineBytes + 1 }); - const shardedLoaded = await loadJsonArrayArtifact(shardedDir, 'vfs_manifest', { strict: false }); - assert.deepStrictEqual(shardedLoaded, rows, 'Sharded vfs_manifest should roundtrip identically.'); - - await fs.stat(path.join(shardedDir, 'vfs_manifest.meta.json')); - await fs.stat(path.join(shardedDir, 'vfs_manifest.parts')); - - console.log('VFS manifest roundtrip ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/indexing/vfs/vfs-manifest-row-trimming.test.js b/tests/indexing/vfs/vfs-manifest-row-trimming.test.js deleted file mode 100644 index a4284726a..000000000 --- a/tests/indexing/vfs/vfs-manifest-row-trimming.test.js +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { buildVfsManifestRowsForFile } from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const MAX_ROW_BYTES = 32 * 1024; - -const runWriter = async ({ outDir, mode, rows, maxJsonBytes }) => { - const writes = []; - const pieceFiles = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = (entry, absPath) => { - pieceFiles.push({ entry, absPath }); - }; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes, - compression: null, - gzipOptions: null, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } - if (pieceFiles.length) { - const pieces = pieceFiles.map(({ entry, absPath }) => ({ - ...entry, - path: path.relative(outDir, absPath).replace(/\\/g, '/') - })); - await writePiecesManifest(outDir, pieces); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-trim-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const containerPath = 'docs/trim.md'; - const containerExt = '.md'; - const containerLanguageId = 'markdown'; - const fileText = 'console.log(1);\n'; - const chunks = [ - { - file: containerPath, - lang: 'javascript', - segment: { - segmentUid: 'segu:v1:trim', - segmentId: 'seg-trim', - start: 0, - end: fileText.length, - languageId: 'javascript', - ext: null - }, - start: 0, - end: fileText.length - } - ]; - - const baseRows = await buildVfsManifestRowsForFile({ - chunks, - fileText, - containerPath, - containerExt, - containerLanguageId - }); - assert.equal(baseRows.length, 1, 'expected a base vfs manifest row'); - - const oversized = { - ...baseRows[0], - extensions: { blob: 'x'.repeat(40000) } - }; - - await runWriter({ outDir, mode: 'code', rows: [oversized], maxJsonBytes: 1024 * 1024 }); - const loaded = await loadJsonArrayArtifact(outDir, 'vfs_manifest', { strict: false }); - - assert.equal(loaded.length, 1, 'trimmed row should still be emitted'); - assert.equal(loaded[0].segmentId, baseRows[0].segmentId, 'segmentId should be preserved'); - assert.ok(!loaded[0].extensions, 'extensions should be trimmed when oversize'); - - const rowBytes = Buffer.byteLength(JSON.stringify(loaded[0]), 'utf8'); - assert.ok(rowBytes <= MAX_ROW_BYTES, 'trimmed row should fit within MAX_ROW_BYTES'); - - console.log('VFS manifest row trimming ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/indexing/vfs/vfs-path-map-roundtrip.test.js b/tests/indexing/vfs/vfs-path-map-roundtrip.test.js deleted file mode 100644 index a95dbacc0..000000000 --- a/tests/indexing/vfs/vfs-path-map-roundtrip.test.js +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { - buildVfsManifestRowsForFile, - buildVfsHashVirtualPath -} from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const runWriter = async ({ outDir, mode, rows, hashRouting }) => { - const writes = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = () => {}; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes: 1000000, - compression: null, - gzipOptions: null, - hashRouting, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-map-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const fileText = 'console.log(1);\n'; - const rows = await buildVfsManifestRowsForFile({ - chunks: [ - { - file: 'a.md', - lang: 'javascript', - segment: { - segmentUid: 'segu:v1:a', - segmentId: 'seg-a', - start: 0, - end: fileText.length, - languageId: 'javascript', - ext: null - }, - start: 0, - end: fileText.length - } - ], - fileText, - containerPath: 'a.md', - containerExt: '.md', - containerLanguageId: 'markdown' - }); - - await runWriter({ outDir, mode: 'code', rows, hashRouting: true }); - - const mapPath = path.join(outDir, 'vfs_path_map.jsonl'); - const contents = await fs.readFile(mapPath, 'utf8'); - const line = contents.trim(); - assert.ok(line, 'expected vfs_path_map content'); - const entry = JSON.parse(line); - assert.equal(entry.virtualPath, rows[0].virtualPath); - const expectedHash = buildVfsHashVirtualPath({ - docHash: rows[0].docHash, - effectiveExt: rows[0].effectiveExt - }); - assert.equal(entry.hashVirtualPath, expectedHash); - assert.equal(entry.containerPath, rows[0].containerPath); - assert.equal(entry.segmentUid, rows[0].segmentUid); - assert.equal(entry.segmentStart, rows[0].segmentStart); - assert.equal(entry.segmentEnd, rows[0].segmentEnd); - - console.log('vfs path map ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/indexing/watch/atomicity.test.js b/tests/indexing/watch/atomicity.test.js new file mode 100644 index 000000000..8c080334d --- /dev/null +++ b/tests/indexing/watch/atomicity.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { seedPublishedArtifacts } from '../../helpers/artifact-publication.js'; +import { promoteBuild } from '../../../src/index/build/promotion.js'; +import { createTempWatchRepo, createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +const { repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-atomicity-', + files: { + index: { + rel: 'src/index.js', + content: 'export const value = 1;\n' + } + } +}); +const filePath = files.index.abs; +const runtime = await createWatchRuntime({ repoRoot }); +const { repoCacheRoot, userConfig } = runtime; + +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const prevRoot = path.join(buildsRoot, 'prev-build'); +await fs.mkdir(prevRoot, { recursive: true }); +await seedPublishedArtifacts({ buildRoot: prevRoot, mode: 'code', buildId: 'prev-build' }); +await promoteBuild({ + repoRoot, + userConfig, + buildId: 'prev-build', + buildRoot: prevRoot, + modes: ['code'] +}); +const currentPath = path.join(buildsRoot, 'current.json'); +const prevCurrent = JSON.parse(await fs.readFile(currentPath, 'utf8')); + +let buildCalls = 0; +const { deps, getOnEvent } = createWatchDeps({ + entries: [files.index], + buildIndexForMode: async () => { + buildCalls += 1; + throw new Error('forced build failure'); + }, + validateIndexArtifacts: async () => { + throw new Error('validate should not be called on build failure'); + }, + promoteBuild: async () => { + throw new Error('promote should not be called on build failure'); + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps +}); + +await ready; +const onEventRef = getOnEvent(); +assert.ok(onEventRef, 'expected watcher to register event handler'); +await onEventRef({ type: 'change', absPath: filePath }); +await waitFor(() => buildCalls >= 1); +abortController.abort(); +await watchPromise; + +const nextCurrent = JSON.parse(await fs.readFile(currentPath, 'utf8')); +assert.equal(nextCurrent.buildId, prevCurrent.buildId, 'expected current.json to remain unchanged'); +assert.equal(nextCurrent.buildRoot, prevCurrent.buildRoot, 'expected buildRoot to remain unchanged'); + +console.log('watch atomicity test passed'); diff --git a/tests/indexing/watch/watch-attempts.test.js b/tests/indexing/watch/attempts.test.js similarity index 100% rename from tests/indexing/watch/watch-attempts.test.js rename to tests/indexing/watch/attempts.test.js diff --git a/tests/indexing/watch/watch-backend-selection.test.js b/tests/indexing/watch/backend-selection.test.js similarity index 100% rename from tests/indexing/watch/watch-backend-selection.test.js rename to tests/indexing/watch/backend-selection.test.js diff --git a/tests/indexing/watch/build-state.test.js b/tests/indexing/watch/build-state.test.js index 5eba3cf5a..42580e020 100644 --- a/tests/indexing/watch/build-state.test.js +++ b/tests/indexing/watch/build-state.test.js @@ -26,10 +26,10 @@ await Promise.all([ ]); const statePath = resolveBuildStatePath(buildRoot); +const progressPath = path.join(buildRoot, 'build_state.progress.json'); const state = JSON.parse(await fs.readFile(statePath, 'utf8')); let progress = state.progress; if (!progress?.code) { - const progressPath = path.join(buildRoot, 'build_state.progress.json'); try { progress = JSON.parse(await fs.readFile(progressPath, 'utf8')); } catch {} @@ -40,4 +40,14 @@ assert.ok(state.phases?.processing, 'expected phase update to persist'); assert.equal(state.currentPhase, 'processing', 'expected currentPhase to be set'); assert.ok(!Object.prototype.hasOwnProperty.call(state, 'phase'), 'unexpected legacy phase field'); +await fs.rm(statePath, { force: true }); +await markBuildPhase(buildRoot, 'processing', 'running'); +const rewrittenState = JSON.parse(await fs.readFile(statePath, 'utf8')); +assert.ok(rewrittenState.phases?.processing, 'expected identical phase patch to recreate missing build_state.json'); + +await fs.rm(progressPath, { force: true }); +await updateBuildState(buildRoot, { progress: { code: { processedFiles: 5, totalFiles: 10 } } }); +const rewrittenProgress = JSON.parse(await fs.readFile(progressPath, 'utf8')); +assert.equal(rewrittenProgress?.code?.processedFiles, 5, 'expected identical progress patch to recreate missing progress sidecar'); + console.log('build state tests passed'); diff --git a/tests/indexing/watch/consistency-state.test.js b/tests/indexing/watch/consistency-state.test.js new file mode 100644 index 000000000..72c9938fd --- /dev/null +++ b/tests/indexing/watch/consistency-state.test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createTempWatchRepo, createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +const { tempRoot, repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-consistency-state-', + files: { + a: { + rel: 'src/a.js', + content: 'export const a = 1;\n' + }, + b: { + rel: 'src/b.js', + content: 'export const b = 2;\n' + } + } +}); +const fileA = files.a.abs; +const fileB = files.b.abs; +const runtime = await createWatchRuntime({ repoRoot }); +const { repoCacheRoot } = runtime; +const watchStatePath = path.join(repoCacheRoot, 'watch-state.json'); + +const stateSnapshots = []; +let releaseFirstBuildResolve; +let firstBuildReleased = false; +const releaseFirstBuild = new Promise((resolve) => { + releaseFirstBuildResolve = () => { + if (firstBuildReleased) return; + firstBuildReleased = true; + resolve(); + }; +}); +let buildCount = 0; + +const { deps, getOnEvent } = createWatchDeps({ + entries: [files.a, files.b], + buildIndexForMode: async () => { + buildCount += 1; + if (buildCount === 1) { + await releaseFirstBuild; + } + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps, + onStateChange: (snapshot) => { + stateSnapshots.push(snapshot); + } +}); + +let testError = null; +try { + await ready; + const onEventRef = getOnEvent(); + assert.ok(onEventRef, 'expected watcher to register event handler'); + + await onEventRef({ type: 'change', absPath: fileA }); + await waitFor(() => runtime.watchState?.activeGeneration?.buildId); + assert.equal(runtime.watchState.consistency, 'catching-up'); + assert.equal(runtime.watchState.quiescent, false); + assert.ok(runtime.watchState.lastAttemptedGeneration?.buildId, 'expected last attempted generation'); + assert.equal( + runtime.watchState.activeGeneration.buildId, + runtime.watchState.lastAttemptedGeneration.buildId, + 'expected active generation to match last attempted generation' + ); + + await onEventRef({ type: 'change', absPath: fileB }); + await waitFor(() => runtime.watchState?.pendingReplay === true && runtime.watchState?.backlogDepth >= 2); + assert.equal(runtime.watchState.consistency, 'catching-up'); + + releaseFirstBuildResolve(); + + await waitFor(() => buildCount >= 2); + await waitFor(() => ( + runtime.watchState?.consistency === 'consistent' + && runtime.watchState?.quiescent === true + && runtime.watchState?.backlogDepth === 0 + )); + assert.ok(runtime.watchState.lastConsistentGeneration?.buildId, 'expected last consistent generation'); + assert.equal(runtime.watchState.lastAttemptedGeneration?.status, 'ok'); + assert.equal(runtime.watchState.activeGeneration, null); + const persistedWatchState = JSON.parse(await fs.readFile(watchStatePath, 'utf8')); + assert.equal(persistedWatchState.consistency, 'consistent'); + assert.equal(persistedWatchState.quiescent, true); + assert.equal(persistedWatchState.backlogDepth, 0); + assert.equal( + persistedWatchState.lastConsistentGeneration?.buildId, + runtime.watchState.lastConsistentGeneration?.buildId, + 'expected persisted watch state to expose the same last consistent generation' + ); + assert.ok( + stateSnapshots.some((snapshot) => snapshot.pendingReplay === true && snapshot.backlogDepth >= 2), + 'expected emitted watch states to include a replaying backlog snapshot' + ); +} catch (error) { + testError = error; +} finally { + releaseFirstBuildResolve(); + abortController.abort(); + await watchPromise; + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +if (testError) { + throw testError; +} + +console.log('watch consistency state test passed'); diff --git a/tests/indexing/watch/watch-debounce.test.js b/tests/indexing/watch/debounce.test.js similarity index 100% rename from tests/indexing/watch/watch-debounce.test.js rename to tests/indexing/watch/debounce.test.js diff --git a/tests/indexing/watch/e2e-promotion.test.js b/tests/indexing/watch/e2e-promotion.test.js new file mode 100644 index 000000000..3117d0b8a --- /dev/null +++ b/tests/indexing/watch/e2e-promotion.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { seedPublishedArtifacts } from '../../helpers/artifact-publication.js'; +import { promoteBuild } from '../../../src/index/build/promotion.js'; +import { createTempWatchRepo, createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +const { repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-e2e-', + files: { + index: { + rel: 'src/index.js', + content: 'export const value = 1;\n' + } + } +}); +const filePath = files.index.abs; +const runtime = await createWatchRuntime({ repoRoot }); +const { repoCacheRoot } = runtime; + +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const currentPath = path.join(buildsRoot, 'current.json'); +const events = []; + +const { deps, getOnEvent } = createWatchDeps({ + entries: [files.index], + buildIndexForMode: async ({ runtime: runtimeRef }) => { + events.push('build'); + await fs.mkdir(runtimeRef.buildRoot, { recursive: true }); + await seedPublishedArtifacts({ + buildRoot: runtimeRef.buildRoot, + mode: 'code', + buildId: path.basename(runtimeRef.buildRoot) + }); + }, + validateIndexArtifacts: async () => { + events.push('validate'); + assert.equal(fsSync.existsSync(currentPath), false, 'expected current.json to be absent before promotion'); + return { ok: true, issues: [], warnings: [] }; + }, + promoteBuild: async (args) => { + events.push('promote'); + return promoteBuild(args); + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps +}); + +await ready; +const onEventRef = getOnEvent(); +assert.ok(onEventRef, 'expected watcher to register event handler'); +await onEventRef({ type: 'change', absPath: filePath }); + +await waitFor(() => events.includes('promote')); +abortController.abort(); +await watchPromise; + +const currentRaw = await fs.readFile(currentPath, 'utf8'); +const current = JSON.parse(currentRaw); +const promotedRoot = current.buildRoot ? path.join(repoCacheRoot, current.buildRoot) : null; +assert.ok(promotedRoot, 'expected current.json buildRoot'); +assert.ok(fsSync.existsSync(promotedRoot), 'expected promoted build root to exist'); +assert.ok(events.indexOf('promote') > events.indexOf('validate'), 'expected promote after validate'); + +console.log('watch e2e promotion test passed'); diff --git a/tests/indexing/watch/watch-filter.test.js b/tests/indexing/watch/filter.test.js similarity index 100% rename from tests/indexing/watch/watch-filter.test.js rename to tests/indexing/watch/filter.test.js diff --git a/tests/indexing/watch/helpers.js b/tests/indexing/watch/helpers.js new file mode 100644 index 000000000..e5354b3a8 --- /dev/null +++ b/tests/indexing/watch/helpers.js @@ -0,0 +1,118 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import os from 'node:os'; + +import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; +import { watchIndex } from '../../../src/index/build/watch.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +export const normalizeAbsPath = (value) => path.resolve(String(value || '')).replace(/\\/g, '/').toLowerCase(); + +export const waitFor = async (predicate, timeoutMs = 5000) => { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('Timed out waiting for condition.'); +}; + +export const createTempWatchRepo = async ({ prefix, files }) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + applyTestEnv({ cacheRoot: tempRoot }); + + const repoRoot = path.join(tempRoot, 'repo'); + const entries = {}; + for (const [name, spec] of Object.entries(files)) { + const rel = spec.rel; + const abs = path.join(repoRoot, rel); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, spec.content); + entries[name] = { + abs, + rel, + stat: await fs.stat(abs) + }; + } + + return { + tempRoot, + repoRoot, + files: entries + }; +}; + +export const createWatchRuntime = async ({ repoRoot, userConfig = {} }) => { + const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + return { + root: repoRoot, + repoCacheRoot, + userConfig, + ignoreMatcher, + maxFileBytes: null, + fileCaps: { default: {} }, + guardrails: {}, + recordsDir: path.join(repoCacheRoot, 'triage', 'records'), + recordsConfig: {}, + ignoreFiles: [], + ignoreWarnings: [], + stage: null, + configHash: 'test', + toolInfo: { version: 'test' } + }; +}; + +export const createWatchDeps = ({ + entries, + buildIndexForMode, + backend = 'chokidar', + validateIndexArtifacts = async () => ({ ok: true, issues: [], warnings: [] }), + promoteBuild = async () => ({}) +}) => { + let onEventRef = null; + return { + deps: { + resolveWatcherBackend: () => ({ + requested: backend, + resolved: backend, + warning: null, + pollingEnabled: false + }), + discoverFilesForModes: async () => ({ + code: entries + }), + startWatcher: async ({ onEvent }) => { + onEventRef = onEvent; + return { close: async () => {} }; + }, + buildIndexForMode, + validateIndexArtifacts, + promoteBuild + }, + getOnEvent: () => onEventRef + }; +}; + +export const startCodeWatch = ({ runtime, deps, debounceMs = 10, onStateChange }) => { + let readyResolve; + const ready = new Promise((resolve) => { readyResolve = resolve; }); + const abortController = new AbortController(); + const watchPromise = watchIndex({ + runtime, + modes: ['code'], + pollMs: 0, + debounceMs, + abortSignal: abortController.signal, + handleSignals: false, + deps, + onReady: () => readyResolve(), + onStateChange + }); + return { + abortController, + ready, + watchPromise + }; +}; diff --git a/tests/indexing/watch/index-lock-signal-cleanup.test.js b/tests/indexing/watch/index-lock-signal-cleanup.test.js new file mode 100644 index 000000000..57dfa5542 --- /dev/null +++ b/tests/indexing/watch/index-lock-signal-cleanup.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { acquireIndexLock, attachIndexLockSignalCleanup } from '../../../src/index/build/lock.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'index-lock-signal-cleanup'); +const repoCacheRoot = path.join(outDir, 'repo-cache'); +const lockPath = path.join(repoCacheRoot, 'locks', 'index.lock'); + +await fsPromises.rm(outDir, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoCacheRoot, 'locks'), { recursive: true }); + +const beforeSigterm = process.listenerCount('SIGTERM'); +const beforeSigint = process.listenerCount('SIGINT'); +const beforeSigbreak = process.platform === 'win32' ? process.listenerCount('SIGBREAK') : 0; +const beforeExit = process.listenerCount('exit'); + +const lock = await acquireIndexLock({ repoCacheRoot, waitMs: 0, log: () => {} }); +assert.ok(lock, 'expected index lock acquisition to succeed'); + +const reemitted = []; +const detach = attachIndexLockSignalCleanup(lock, { + preserveDefaultTermination: true, + reemitSignal: (signal) => reemitted.push(signal) +}); +try { + assert.equal(fs.existsSync(lockPath), true, 'expected lock file to exist after acquire'); + process.emit('SIGTERM', 'SIGTERM'); + assert.equal(fs.existsSync(lockPath), false, 'expected signal cleanup to remove owned lock file'); + assert.deepEqual(reemitted, ['SIGTERM'], 'expected signal cleanup to preserve default SIGTERM termination'); + assert.equal(process.listenerCount('exit'), beforeExit, 'expected exit cleanup listener detached after signal cleanup'); +} finally { + detach(); + await lock.release(); +} + +assert.equal(process.listenerCount('SIGTERM'), beforeSigterm, 'expected SIGTERM listener count restored'); +assert.equal(process.listenerCount('SIGINT'), beforeSigint, 'expected SIGINT listener count restored'); +if (process.platform === 'win32') { + assert.equal(process.listenerCount('SIGBREAK'), beforeSigbreak, 'expected SIGBREAK listener count restored'); +} + +console.log('index lock signal cleanup test passed'); diff --git a/tests/indexing/watch/index-lock-signal-listeners.test.js b/tests/indexing/watch/index-lock-signal-listeners.test.js new file mode 100644 index 000000000..cd685727c --- /dev/null +++ b/tests/indexing/watch/index-lock-signal-listeners.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { acquireIndexLock } from '../../../src/index/build/lock.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'index-lock-signal-listeners'); +const repoCacheRoot = path.join(outDir, 'repo-cache'); +await fsPromises.rm(outDir, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoCacheRoot, 'locks'), { recursive: true }); + +const signalEvents = ['SIGINT', 'SIGTERM']; +if (process.platform === 'win32') signalEvents.push('SIGBREAK'); + +const before = { + exit: process.listenerCount('exit') +}; +for (const event of signalEvents) { + before[event] = process.listenerCount(event); +} + +const lock = await acquireIndexLock({ repoCacheRoot, waitMs: 0, log: () => {} }); +assert.ok(lock, 'expected index lock acquisition to succeed'); + +try { + const duringExit = process.listenerCount('exit'); + assert.equal(duringExit, before.exit + 1, 'lock should only register one exit cleanup handler'); + + for (const event of signalEvents) { + const during = process.listenerCount(event); + assert.equal( + during, + before[event], + `lock should not register ${event} handler that changes process signal ownership` + ); + } +} finally { + await lock.release(); +} + +assert.equal(process.listenerCount('exit'), before.exit, 'exit handler should be removed on release'); +for (const event of signalEvents) { + assert.equal(process.listenerCount(event), before[event], `${event} listeners should remain unchanged`); +} + +console.log('index lock signal listener hygiene test passed'); diff --git a/tests/indexing/watch/watch-lock-backoff.test.js b/tests/indexing/watch/lock-backoff.test.js similarity index 100% rename from tests/indexing/watch/watch-lock-backoff.test.js rename to tests/indexing/watch/lock-backoff.test.js diff --git a/tests/indexing/watch/promotion/promotion-safety.test.js b/tests/indexing/watch/promotion/promotion-safety.test.js deleted file mode 100644 index 6befa7b1b..000000000 --- a/tests/indexing/watch/promotion/promotion-safety.test.js +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../../helpers/test-env.js'; -import { promoteBuild } from '../../../../src/index/build/promotion.js'; -import { toRealPathSync } from '../../../../src/workspace/identity.js'; -import { getBuildsRoot, getCurrentBuildInfo, getRepoCacheRoot } from '../../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-promotion-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(repoRoot, { recursive: true }); -const userConfig = {}; -const swapCase = (value) => String(value).replace(/[A-Za-z]/g, (ch) => ( - ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() -)); -const normalizePath = (value) => { - return toRealPathSync(path.resolve(value)); -}; - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const outsideRoot = path.join(tempRoot, 'outside'); -await fs.mkdir(outsideRoot, { recursive: true }); - -await assert.rejects( - () => promoteBuild({ - repoRoot, - userConfig, - buildId: 'bad-build', - buildRoot: outsideRoot, - modes: ['code'] - }), - /escapes repo cache root/ -); - -const buildsRoot = getBuildsRoot(repoRoot, userConfig); -await fs.mkdir(buildsRoot, { recursive: true }); -const currentPath = path.join(buildsRoot, 'current.json'); -const symlinkEscapeRoot = path.join(buildsRoot, 'escape-link'); -let symlinkCreated = false; -try { - await fs.symlink(outsideRoot, symlinkEscapeRoot, process.platform === 'win32' ? 'junction' : 'dir'); - symlinkCreated = true; -} catch {} -if (symlinkCreated) { - await assert.rejects( - () => promoteBuild({ - repoRoot, - userConfig, - buildId: 'bad-build-link', - buildRoot: symlinkEscapeRoot, - modes: ['code'] - }), - /escapes repo cache root/ - ); - - await fs.writeFile(currentPath, JSON.stringify({ - buildId: 'unsafe-link', - buildRoot: 'builds/escape-link' - }, null, 2)); - const symlinkInfo = getCurrentBuildInfo(repoRoot, userConfig); - assert.equal(symlinkInfo, null, 'expected symlinked current.json root to be rejected'); -} -if (process.platform === 'win32') { - const caseBuildId = 'case-sensitive-root-normalized'; - const canonicalCaseRoot = path.join(buildsRoot, caseBuildId); - await fs.mkdir(canonicalCaseRoot, { recursive: true }); - const mixedCaseRoot = swapCase(canonicalCaseRoot); - await assert.doesNotReject( - () => promoteBuild({ - repoRoot, - userConfig, - buildId: caseBuildId, - buildRoot: mixedCaseRoot, - modes: ['code'] - }), - 'expected promoteBuild to accept mixed-case windows path under repo cache root' - ); - const promoted = getCurrentBuildInfo(repoRoot, userConfig); - assert.equal(promoted?.buildId, caseBuildId, 'expected mixed-case promotion to be accepted'); - assert.equal( - normalizePath(promoted?.buildRoot || ''), - normalizePath(canonicalCaseRoot), - 'expected promoted buildRoot to resolve to canonical build path' - ); -} -const unsafeRoot = path.join(repoCacheRoot, '..', '..', 'outside'); -await fs.writeFile(currentPath, JSON.stringify({ - buildId: 'unsafe-build', - buildRoot: unsafeRoot -}, null, 2)); - -const info = getCurrentBuildInfo(repoRoot, userConfig); -assert.equal(info, null, 'expected unsafe current.json to be rejected'); - -console.log('promotion safety tests passed'); diff --git a/tests/indexing/watch/promotion/safety.test.js b/tests/indexing/watch/promotion/safety.test.js new file mode 100644 index 000000000..62af5cd65 --- /dev/null +++ b/tests/indexing/watch/promotion/safety.test.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { seedPublishedArtifacts } from '../../../helpers/artifact-publication.js'; +import { promoteBuild } from '../../../../src/index/build/promotion.js'; +import { toRealPathSync } from '../../../../src/workspace/identity.js'; +import { getBuildsRoot, getCurrentBuildInfo, getRepoCacheRoot } from '../../../../tools/shared/dict-utils.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-promotion-')); +applyTestEnv({ cacheRoot: tempRoot }); + +const repoRoot = path.join(tempRoot, 'repo'); +await fs.mkdir(repoRoot, { recursive: true }); +const userConfig = {}; +const swapCase = (value) => String(value).replace(/[A-Za-z]/g, (ch) => ( + ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() +)); +const normalizePath = (value) => { + return toRealPathSync(path.resolve(value)); +}; + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const outsideRoot = path.join(tempRoot, 'outside'); +await fs.mkdir(outsideRoot, { recursive: true }); + +await assert.rejects( + () => promoteBuild({ + repoRoot, + userConfig, + buildId: 'bad-build', + buildRoot: outsideRoot, + modes: ['code'] + }), + /escapes repo cache root/ +); + +const buildsRoot = getBuildsRoot(repoRoot, userConfig); +await fs.mkdir(buildsRoot, { recursive: true }); +const currentPath = path.join(buildsRoot, 'current.json'); +const symlinkEscapeRoot = path.join(buildsRoot, 'escape-link'); +let symlinkCreated = false; +try { + await fs.symlink(outsideRoot, symlinkEscapeRoot, process.platform === 'win32' ? 'junction' : 'dir'); + symlinkCreated = true; +} catch {} +if (symlinkCreated) { + await assert.rejects( + () => promoteBuild({ + repoRoot, + userConfig, + buildId: 'bad-build-link', + buildRoot: symlinkEscapeRoot, + modes: ['code'] + }), + /escapes repo cache root/ + ); + + await fs.writeFile(currentPath, JSON.stringify({ + buildId: 'unsafe-link', + buildRoot: 'builds/escape-link' + }, null, 2)); + const symlinkInfo = getCurrentBuildInfo(repoRoot, userConfig); + assert.equal(symlinkInfo, null, 'expected symlinked current.json root to be rejected'); +} +if (process.platform === 'win32') { + const caseBuildId = 'case-sensitive-root-normalized'; + const canonicalCaseRoot = path.join(buildsRoot, caseBuildId); + await fs.mkdir(canonicalCaseRoot, { recursive: true }); + await seedPublishedArtifacts({ buildRoot: canonicalCaseRoot, mode: 'code', buildId: caseBuildId }); + const mixedCaseRoot = swapCase(canonicalCaseRoot); + await assert.doesNotReject( + () => promoteBuild({ + repoRoot, + userConfig, + buildId: caseBuildId, + buildRoot: mixedCaseRoot, + modes: ['code'] + }), + 'expected promoteBuild to accept mixed-case windows path under repo cache root' + ); + const promoted = getCurrentBuildInfo(repoRoot, userConfig); + assert.equal(promoted?.buildId, caseBuildId, 'expected mixed-case promotion to be accepted'); + assert.equal( + normalizePath(promoted?.buildRoot || ''), + normalizePath(canonicalCaseRoot), + 'expected promoted buildRoot to resolve to canonical build path' + ); +} +const unsafeRoot = path.join(repoCacheRoot, '..', '..', 'outside'); +await fs.writeFile(currentPath, JSON.stringify({ + buildId: 'unsafe-build', + buildRoot: unsafeRoot +}, null, 2)); + +const info = getCurrentBuildInfo(repoRoot, userConfig); +assert.notEqual( + normalizePath(info?.activeRoot || ''), + normalizePath(unsafeRoot), + 'expected unsafe current.json root to be ignored as the active build root' +); + +const validBuildRoot = path.join(buildsRoot, 'parse-recovery-build'); +await fs.mkdir(validBuildRoot, { recursive: true }); +await seedPublishedArtifacts({ buildRoot: validBuildRoot, mode: 'code', buildId: 'parse-recovery-build' }); +await fs.writeFile(currentPath, '{invalid-json', 'utf8'); +await assert.doesNotReject( + () => promoteBuild({ + repoRoot, + userConfig, + buildId: 'parse-recovery-build', + buildRoot: validBuildRoot, + modes: ['code'] + }), + 'expected malformed current.json to be treated as empty prior state' +); +const recovered = getCurrentBuildInfo(repoRoot, userConfig); +assert.equal(recovered?.buildId, 'parse-recovery-build'); +assert.equal( + normalizePath(recovered?.buildRoot || ''), + normalizePath(validBuildRoot), + 'expected promotion to recover from malformed current.json pointer' +); + +console.log('promotion safety tests passed'); diff --git a/tests/indexing/watch/retry-on-failed-cycle.test.js b/tests/indexing/watch/retry-on-failed-cycle.test.js new file mode 100644 index 000000000..f9ace7ca7 --- /dev/null +++ b/tests/indexing/watch/retry-on-failed-cycle.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-retry-failed-cycle-')); +applyTestEnv({ cacheRoot: tempRoot }); + +const repoRoot = path.join(tempRoot, 'repo'); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +const fileA = path.join(repoRoot, 'src', 'a.js'); +await fs.writeFile(fileA, 'export const a = 1;\n'); +const statA = await fs.stat(fileA); + +const runtime = await createWatchRuntime({ repoRoot }); + +let buildAttempts = 0; +const { deps, getOnEvent } = createWatchDeps({ + entries: [{ abs: fileA, rel: 'src/a.js', stat: statA }], + buildIndexForMode: async () => { + buildAttempts += 1; + if (buildAttempts === 1) { + throw new Error('synthetic watch build failure'); + } + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps +}); + +let testError = null; +try { + await ready; + const onEventRef = getOnEvent(); + assert.ok(onEventRef, 'expected watcher to register event handler'); + await onEventRef({ type: 'change', absPath: fileA }); + await waitFor(() => buildAttempts >= 2, 5000); +} catch (error) { + testError = error; +} finally { + abortController.abort(); + await watchPromise; + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +if (testError) { + throw testError; +} + +assert.ok(buildAttempts >= 2, 'expected failed cycle to replay queued backlog'); +console.log('watch retry on failed cycle test passed'); diff --git a/tests/indexing/watch/watch-root-normalization.test.js b/tests/indexing/watch/root-normalization.test.js similarity index 100% rename from tests/indexing/watch/watch-root-normalization.test.js rename to tests/indexing/watch/root-normalization.test.js diff --git a/tests/indexing/watch/shutdown.test.js b/tests/indexing/watch/shutdown.test.js new file mode 100644 index 000000000..abbc2e5f4 --- /dev/null +++ b/tests/indexing/watch/shutdown.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { createTempWatchRepo, createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +// Early shutdown should not throw. +{ + const { repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-shutdown-early-', + files: { + index: { + rel: 'src/index.js', + content: 'export const value = 1;\n' + } + } + }); + const runtime = await createWatchRuntime({ repoRoot }); + const { deps } = createWatchDeps({ + entries: [files.index], + buildIndexForMode: async () => {} + }); + const { + abortController, + watchPromise + } = startCodeWatch({ + runtime, + deps + }); + abortController.abort(); + await watchPromise; +} + +// Shutdown during active build releases lock. +{ + const { repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-shutdown-active-', + files: { + index: { + rel: 'src/index.js', + content: 'export const value = 1;\n' + } + } + }); + const filePath = files.index.abs; + const runtime = await createWatchRuntime({ repoRoot }); + const { repoCacheRoot } = runtime; + + let buildStartedResolve; + const buildStarted = new Promise((resolve) => { buildStartedResolve = resolve; }); + const { deps, getOnEvent } = createWatchDeps({ + entries: [files.index], + buildIndexForMode: async ({ abortSignal }) => { + buildStartedResolve(); + if (abortSignal?.aborted) return; + await new Promise((resolve) => { + abortSignal?.addEventListener('abort', resolve, { once: true }); + }); + } + }); + + const { + abortController, + watchPromise + } = startCodeWatch({ + runtime, + deps + }); + + await waitFor(() => Boolean(getOnEvent())); + const onEventRef = getOnEvent(); + await onEventRef({ type: 'change', absPath: filePath }); + await buildStarted; + abortController.abort(); + await watchPromise; + + const lockPath = path.join(repoCacheRoot, 'locks', 'index.lock'); + assert.equal(fsSync.existsSync(lockPath), false, 'expected lock to be released'); +} + +console.log('watch shutdown tests passed'); diff --git a/tests/indexing/watch/watch-stability-checks.test.js b/tests/indexing/watch/stability-checks.test.js similarity index 100% rename from tests/indexing/watch/watch-stability-checks.test.js rename to tests/indexing/watch/stability-checks.test.js diff --git a/tests/indexing/watch/watch-stability-guard.test.js b/tests/indexing/watch/stability-guard.test.js similarity index 100% rename from tests/indexing/watch/watch-stability-guard.test.js rename to tests/indexing/watch/stability-guard.test.js diff --git a/tests/indexing/watch/stability-requeue.test.js b/tests/indexing/watch/stability-requeue.test.js new file mode 100644 index 000000000..0aff459e3 --- /dev/null +++ b/tests/indexing/watch/stability-requeue.test.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { createTempWatchRepo, createWatchDeps, createWatchRuntime, startCodeWatch, waitFor } from './helpers.js'; + +const { tempRoot, repoRoot, files } = await createTempWatchRepo({ + prefix: 'poc-watch-stability-requeue-', + files: { + a: { + rel: 'src/a.js', + content: 'export const a = 1;\n' + } + } +}); +const fileA = files.a.abs; +const runtime = await createWatchRuntime({ repoRoot }); +let buildCount = 0; + +const { deps, getOnEvent } = createWatchDeps({ + entries: [files.a], + backend: 'parcel', + buildIndexForMode: async () => { + buildCount += 1; + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps, + debounceMs: 120 +}); + +let churnTimer = null; +let stopChurnTimer = null; +let testError = null; +try { + await ready; + const onEventRef = getOnEvent(); + assert.ok(onEventRef, 'expected watcher to register event handler'); + churnTimer = setInterval(() => { + void fs.appendFile(fileA, '// churn\n').catch(() => {}); + }, 25); + stopChurnTimer = setTimeout(() => { + clearInterval(churnTimer); + churnTimer = null; + }, 700); + await onEventRef({ type: 'change', absPath: fileA }); + await waitFor(() => buildCount >= 1, 7000); +} catch (error) { + testError = error; +} finally { + if (stopChurnTimer) clearTimeout(stopChurnTimer); + if (churnTimer) clearInterval(churnTimer); + abortController.abort(); + await watchPromise; + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +if (testError) { + throw testError; +} + +assert.ok(buildCount >= 1, 'expected unstable updates to be requeued until stable'); +console.log('watch stability requeue test passed'); diff --git a/tests/indexing/watch/update-queue-no-loss.test.js b/tests/indexing/watch/update-queue-no-loss.test.js new file mode 100644 index 000000000..565ec009d --- /dev/null +++ b/tests/indexing/watch/update-queue-no-loss.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + createWatchDeps, + createWatchRuntime, + normalizeAbsPath, + startCodeWatch, + waitFor +} from './helpers.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-update-queue-')); +applyTestEnv({ cacheRoot: tempRoot }); + +const repoRoot = path.join(tempRoot, 'repo'); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +const fileA = path.join(repoRoot, 'src', 'a.js'); +const fileB = path.join(repoRoot, 'src', 'b.js'); +const fileBKey = normalizeAbsPath(fileB); +await fs.writeFile(fileA, 'export const a = 1;\n'); +await fs.writeFile(fileB, 'export const b = 2;\n'); +const statA = await fs.stat(fileA); + +const runtime = await createWatchRuntime({ repoRoot }); + +const observedBuildSnapshots = []; +const { deps, getOnEvent } = createWatchDeps({ + entries: [{ abs: fileA, rel: 'src/a.js', stat: statA }], + buildIndexForMode: async ({ discovery }) => { + const entries = Array.isArray(discovery?.entries) + ? discovery.entries.map((entry) => normalizeAbsPath(entry.abs)).filter(Boolean).sort() + : []; + const skipped = Array.isArray(discovery?.skippedFiles) + ? discovery.skippedFiles.map((entry) => normalizeAbsPath(entry.file)).filter(Boolean).sort() + : []; + observedBuildSnapshots.push({ entries, skipped }); + } +}); + +const { + abortController, + ready, + watchPromise +} = startCodeWatch({ + runtime, + deps +}); + +await ready; +const onEventRef = getOnEvent(); +assert.ok(onEventRef, 'expected watcher to register event handler'); + +const originalArrayFrom = Array.from; +let injectedSecondEvent = false; + +Array.from = function patchedArrayFrom(value, ...rest) { + const output = originalArrayFrom.call(Array, value, ...rest); + if ( + !injectedSecondEvent + && value instanceof Set + && output.includes(fileA) + && !output.includes(fileB) + && onEventRef + ) { + injectedSecondEvent = true; + onEventRef({ type: 'change', absPath: fileB }); + } + return output; +}; + +let testError = null; +try { + await onEventRef({ type: 'change', absPath: fileA }); + await waitFor(() => observedBuildSnapshots.length >= 1); + try { + await waitFor( + () => observedBuildSnapshots.some((snapshot) => ( + snapshot.entries.includes(fileBKey) || snapshot.skipped.includes(fileBKey) + )), + 5000 + ); + } catch (error) { + throw new Error(`Timed out waiting for fileB update. snapshots=${JSON.stringify(observedBuildSnapshots)}`, { + cause: error + }); + } + assert.equal(injectedSecondEvent, true, 'expected second event injection during update flush'); +} catch (error) { + testError = error; +} finally { + Array.from = originalArrayFrom; + abortController.abort(); + await watchPromise; +} + +if (testError) { + throw testError; +} + +console.log('watch update queue no-loss test passed'); diff --git a/tests/indexing/watch/watch-atomicity.test.js b/tests/indexing/watch/watch-atomicity.test.js deleted file mode 100644 index fbf15b1dd..000000000 --- a/tests/indexing/watch/watch-atomicity.test.js +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { promoteBuild } from '../../../src/index/build/promotion.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-atomicity-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -const filePath = path.join(repoRoot, 'src', 'index.js'); -await fs.writeFile(filePath, 'export const value = 1;\n'); -const fileStat = await fs.stat(filePath); - -const userConfig = {}; -const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const runtime = { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } -}; - -const buildsRoot = path.join(repoCacheRoot, 'builds'); -const prevRoot = path.join(buildsRoot, 'prev-build'); -await fs.mkdir(prevRoot, { recursive: true }); -await promoteBuild({ - repoRoot, - userConfig, - buildId: 'prev-build', - buildRoot: prevRoot, - modes: ['code'] -}); -const currentPath = path.join(buildsRoot, 'current.json'); -const prevCurrent = JSON.parse(await fs.readFile(currentPath, 'utf8')); - -let onEventRef = null; -let readyResolve; -const ready = new Promise((resolve) => { readyResolve = resolve; }); -let buildCalls = 0; -const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: filePath, rel: 'src/index.js', stat: fileStat }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async () => { - buildCalls += 1; - throw new Error('forced build failure'); - }, - validateIndexArtifacts: async () => { - throw new Error('validate should not be called on build failure'); - }, - promoteBuild: async () => { - throw new Error('promote should not be called on build failure'); - } -}; - -const abortController = new AbortController(); -const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps, - onReady: () => readyResolve() -}); - -await ready; -await onEventRef({ type: 'change', absPath: filePath }); -await waitFor(() => buildCalls >= 1); -abortController.abort(); -await watchPromise; - -const nextCurrent = JSON.parse(await fs.readFile(currentPath, 'utf8')); -assert.equal(nextCurrent.buildId, prevCurrent.buildId, 'expected current.json to remain unchanged'); -assert.equal(nextCurrent.buildRoot, prevCurrent.buildRoot, 'expected buildRoot to remain unchanged'); - -console.log('watch atomicity test passed'); diff --git a/tests/indexing/watch/watch-e2e-promotion.test.js b/tests/indexing/watch/watch-e2e-promotion.test.js deleted file mode 100644 index 0f51449c6..000000000 --- a/tests/indexing/watch/watch-e2e-promotion.test.js +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { promoteBuild } from '../../../src/index/build/promotion.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-e2e-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -const filePath = path.join(repoRoot, 'src', 'index.js'); -await fs.writeFile(filePath, 'export const value = 1;\n'); -const fileStat = await fs.stat(filePath); - -const userConfig = {}; -const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const runtime = { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } -}; - -const buildsRoot = path.join(repoCacheRoot, 'builds'); -const currentPath = path.join(buildsRoot, 'current.json'); -const events = []; -let onEventRef = null; -let readyResolve; -const ready = new Promise((resolve) => { readyResolve = resolve; }); - -const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: filePath, rel: 'src/index.js', stat: fileStat }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async ({ runtime: runtimeRef }) => { - events.push('build'); - await fs.mkdir(runtimeRef.buildRoot, { recursive: true }); - }, - validateIndexArtifacts: async () => { - events.push('validate'); - assert.equal(fsSync.existsSync(currentPath), false, 'expected current.json to be absent before promotion'); - return { ok: true, issues: [], warnings: [] }; - }, - promoteBuild: async (args) => { - events.push('promote'); - return promoteBuild(args); - } -}; - -const abortController = new AbortController(); -const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps, - onReady: () => readyResolve() -}); - -await ready; -assert.ok(onEventRef, 'expected watcher to register event handler'); -await onEventRef({ type: 'change', absPath: filePath }); - -await waitFor(() => events.includes('promote')); -abortController.abort(); -await watchPromise; - -const currentRaw = await fs.readFile(currentPath, 'utf8'); -const current = JSON.parse(currentRaw); -const promotedRoot = current.buildRoot ? path.join(repoCacheRoot, current.buildRoot) : null; -assert.ok(promotedRoot, 'expected current.json buildRoot'); -assert.ok(fsSync.existsSync(promotedRoot), 'expected promoted build root to exist'); -assert.ok(events.indexOf('promote') > events.indexOf('validate'), 'expected promote after validate'); - -console.log('watch e2e promotion test passed'); diff --git a/tests/indexing/watch/watch-retry-on-failed-cycle.test.js b/tests/indexing/watch/watch-retry-on-failed-cycle.test.js deleted file mode 100644 index 8a330b3c4..000000000 --- a/tests/indexing/watch/watch-retry-on-failed-cycle.test.js +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-retry-failed-cycle-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -const fileA = path.join(repoRoot, 'src', 'a.js'); -await fs.writeFile(fileA, 'export const a = 1;\n'); -const statA = await fs.stat(fileA); - -const userConfig = {}; -const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const runtime = { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } -}; - -let onEventRef = null; -let readyResolve; -const ready = new Promise((resolve) => { readyResolve = resolve; }); -let buildAttempts = 0; - -const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: fileA, rel: 'src/a.js', stat: statA }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async () => { - buildAttempts += 1; - if (buildAttempts === 1) { - throw new Error('synthetic watch build failure'); - } - }, - validateIndexArtifacts: async () => ({ ok: true, issues: [], warnings: [] }), - promoteBuild: async () => ({}) -}; - -const abortController = new AbortController(); -const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps, - onReady: () => readyResolve() -}); - -let testError = null; -try { - await ready; - assert.ok(onEventRef, 'expected watcher to register event handler'); - await onEventRef({ type: 'change', absPath: fileA }); - await waitFor(() => buildAttempts >= 2, 5000); -} catch (error) { - testError = error; -} finally { - abortController.abort(); - await watchPromise; - await fs.rm(tempRoot, { recursive: true, force: true }); -} - -if (testError) { - throw testError; -} - -assert.ok(buildAttempts >= 2, 'expected failed cycle to replay queued backlog'); -console.log('watch retry on failed cycle test passed'); diff --git a/tests/indexing/watch/watch-shutdown.test.js b/tests/indexing/watch/watch-shutdown.test.js deleted file mode 100644 index 032d14dd4..000000000 --- a/tests/indexing/watch/watch-shutdown.test.js +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const createRuntime = async (repoRoot, repoCacheRoot) => { - const userConfig = {}; - const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); - return { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } - }; -}; - -// Early shutdown should not throw. -{ - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-shutdown-early-')); - applyTestEnv({ cacheRoot: tempRoot }); - const repoRoot = path.join(tempRoot, 'repo'); - await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); - const filePath = path.join(repoRoot, 'src', 'index.js'); - await fs.writeFile(filePath, 'export const value = 1;\n'); - const fileStat = await fs.stat(filePath); - const repoCacheRoot = getRepoCacheRoot(repoRoot, {}); - const runtime = await createRuntime(repoRoot, repoCacheRoot); - const abortController = new AbortController(); - const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: filePath, rel: 'src/index.js', stat: fileStat }] - }), - startWatcher: async () => ({ close: async () => {} }), - buildIndexForMode: async () => {}, - validateIndexArtifacts: async () => ({ ok: true, issues: [], warnings: [] }), - promoteBuild: async () => ({}) - }; - const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps - }); - abortController.abort(); - await watchPromise; -} - -// Shutdown during active build releases lock. -{ - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-shutdown-active-')); - applyTestEnv({ cacheRoot: tempRoot }); - const repoRoot = path.join(tempRoot, 'repo'); - await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); - const filePath = path.join(repoRoot, 'src', 'index.js'); - await fs.writeFile(filePath, 'export const value = 1;\n'); - const fileStat = await fs.stat(filePath); - const repoCacheRoot = getRepoCacheRoot(repoRoot, {}); - const runtime = await createRuntime(repoRoot, repoCacheRoot); - - let onEventRef = null; - let buildStartedResolve; - const buildStarted = new Promise((resolve) => { buildStartedResolve = resolve; }); - const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: filePath, rel: 'src/index.js', stat: fileStat }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async ({ abortSignal }) => { - buildStartedResolve(); - if (abortSignal?.aborted) return; - await new Promise((resolve) => { - abortSignal?.addEventListener('abort', resolve, { once: true }); - }); - }, - validateIndexArtifacts: async () => ({ ok: true, issues: [], warnings: [] }), - promoteBuild: async () => ({}) - }; - - const abortController = new AbortController(); - const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps - }); - - await waitFor(() => Boolean(onEventRef)); - await onEventRef({ type: 'change', absPath: filePath }); - await buildStarted; - abortController.abort(); - await watchPromise; - - const lockPath = path.join(repoCacheRoot, 'locks', 'index.lock'); - assert.equal(fsSync.existsSync(lockPath), false, 'expected lock to be released'); -} - -console.log('watch shutdown tests passed'); diff --git a/tests/indexing/watch/watch-stability-requeue.test.js b/tests/indexing/watch/watch-stability-requeue.test.js deleted file mode 100644 index eda2f1426..000000000 --- a/tests/indexing/watch/watch-stability-requeue.test.js +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const waitFor = async (predicate, timeoutMs = 7000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-stability-requeue-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -const fileA = path.join(repoRoot, 'src', 'a.js'); -await fs.writeFile(fileA, 'export const a = 1;\n'); -const statA = await fs.stat(fileA); - -const userConfig = {}; -const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const runtime = { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } -}; - -let onEventRef = null; -let readyResolve; -const ready = new Promise((resolve) => { readyResolve = resolve; }); -let buildCount = 0; - -const deps = { - resolveWatcherBackend: () => ({ - requested: 'parcel', - resolved: 'parcel', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: fileA, rel: 'src/a.js', stat: statA }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async () => { - buildCount += 1; - }, - validateIndexArtifacts: async () => ({ ok: true, issues: [], warnings: [] }), - promoteBuild: async () => ({}) -}; - -const abortController = new AbortController(); -const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 120, - abortSignal: abortController.signal, - handleSignals: false, - deps, - onReady: () => readyResolve() -}); - -let churnTimer = null; -let stopChurnTimer = null; -let testError = null; -try { - await ready; - assert.ok(onEventRef, 'expected watcher to register event handler'); - churnTimer = setInterval(() => { - void fs.appendFile(fileA, '// churn\n').catch(() => {}); - }, 25); - stopChurnTimer = setTimeout(() => { - clearInterval(churnTimer); - churnTimer = null; - }, 700); - await onEventRef({ type: 'change', absPath: fileA }); - await waitFor(() => buildCount >= 1, 7000); -} catch (error) { - testError = error; -} finally { - if (stopChurnTimer) clearTimeout(stopChurnTimer); - if (churnTimer) clearInterval(churnTimer); - abortController.abort(); - await watchPromise; - await fs.rm(tempRoot, { recursive: true, force: true }); -} - -if (testError) { - throw testError; -} - -assert.ok(buildCount >= 1, 'expected unstable updates to be requeued until stable'); -console.log('watch stability requeue test passed'); diff --git a/tests/indexing/watch/watch-update-queue-no-loss.test.js b/tests/indexing/watch/watch-update-queue-no-loss.test.js deleted file mode 100644 index 83cab446e..000000000 --- a/tests/indexing/watch/watch-update-queue-no-loss.test.js +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildIgnoreMatcher } from '../../../src/index/build/ignore.js'; -import { watchIndex } from '../../../src/index/build/watch.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const normalizeAbsPath = (value) => path.resolve(String(value || '')).replace(/\\/g, '/').toLowerCase(); - -const waitFor = async (predicate, timeoutMs = 5000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition.'); -}; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-watch-update-queue-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -const fileA = path.join(repoRoot, 'src', 'a.js'); -const fileB = path.join(repoRoot, 'src', 'b.js'); -const fileBKey = normalizeAbsPath(fileB); -await fs.writeFile(fileA, 'export const a = 1;\n'); -await fs.writeFile(fileB, 'export const b = 2;\n'); -const statA = await fs.stat(fileA); - -const userConfig = {}; -const { ignoreMatcher } = await buildIgnoreMatcher({ root: repoRoot, userConfig }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const runtime = { - root: repoRoot, - repoCacheRoot, - userConfig, - ignoreMatcher, - maxFileBytes: null, - fileCaps: { default: {} }, - guardrails: {}, - recordsDir: path.join(repoCacheRoot, 'triage', 'records'), - recordsConfig: {}, - ignoreFiles: [], - ignoreWarnings: [], - stage: null, - configHash: 'test', - toolInfo: { version: 'test' } -}; - -const observedBuildSnapshots = []; -let onEventRef = null; -let readyResolve; -const ready = new Promise((resolve) => { readyResolve = resolve; }); - -const deps = { - resolveWatcherBackend: () => ({ - requested: 'chokidar', - resolved: 'chokidar', - warning: null, - pollingEnabled: false - }), - discoverFilesForModes: async () => ({ - code: [{ abs: fileA, rel: 'src/a.js', stat: statA }] - }), - startWatcher: async ({ onEvent }) => { - onEventRef = onEvent; - return { close: async () => {} }; - }, - buildIndexForMode: async ({ discovery }) => { - const entries = Array.isArray(discovery?.entries) - ? discovery.entries.map((entry) => normalizeAbsPath(entry.abs)).filter(Boolean).sort() - : []; - const skipped = Array.isArray(discovery?.skippedFiles) - ? discovery.skippedFiles.map((entry) => normalizeAbsPath(entry.file)).filter(Boolean).sort() - : []; - observedBuildSnapshots.push({ entries, skipped }); - }, - validateIndexArtifacts: async () => ({ ok: true, issues: [], warnings: [] }), - promoteBuild: async () => ({}) -}; - -const abortController = new AbortController(); -const watchPromise = watchIndex({ - runtime, - modes: ['code'], - pollMs: 0, - debounceMs: 10, - abortSignal: abortController.signal, - handleSignals: false, - deps, - onReady: () => readyResolve() -}); - -await ready; -assert.ok(onEventRef, 'expected watcher to register event handler'); - -const originalArrayFrom = Array.from; -let injectedSecondEvent = false; - -Array.from = function patchedArrayFrom(value, ...rest) { - const output = originalArrayFrom.call(Array, value, ...rest); - if ( - !injectedSecondEvent - && value instanceof Set - && output.includes(fileA) - && !output.includes(fileB) - && onEventRef - ) { - injectedSecondEvent = true; - onEventRef({ type: 'change', absPath: fileB }); - } - return output; -}; - -let testError = null; -try { - await onEventRef({ type: 'change', absPath: fileA }); - await waitFor(() => observedBuildSnapshots.length >= 1); - try { - await waitFor( - () => observedBuildSnapshots.some((snapshot) => ( - snapshot.entries.includes(fileBKey) || snapshot.skipped.includes(fileBKey) - )), - 5000 - ); - } catch (error) { - throw new Error(`Timed out waiting for fileB update. snapshots=${JSON.stringify(observedBuildSnapshots)}`, { - cause: error - }); - } - assert.equal(injectedSecondEvent, true, 'expected second event injection during update flush'); -} catch (error) { - testError = error; -} finally { - Array.from = originalArrayFrom; - abortController.abort(); - await watchPromise; -} - -if (testError) { - throw testError; -} - -console.log('watch update queue no-loss test passed'); diff --git a/tests/indexing/workers/worker-bounded-object-pool.test.js b/tests/indexing/workers/worker-bounded-object-pool.test.js index 9518d6ac7..c5beab54e 100644 --- a/tests/indexing/workers/worker-bounded-object-pool.test.js +++ b/tests/indexing/workers/worker-bounded-object-pool.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBoundedObjectPool } from '../../../src/shared/bounded-object-pool.js'; +import { createBoundedObjectPool } from '../../../src/shared/workers/bounded-object-pool.js'; const pool = createBoundedObjectPool({ maxSize: 2, diff --git a/tests/indexing/workers/worker-pool-destroy-timeout.test.js b/tests/indexing/workers/worker-pool-destroy-timeout.test.js new file mode 100644 index 000000000..60a44367e --- /dev/null +++ b/tests/indexing/workers/worker-pool-destroy-timeout.test.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { destroyWorkerPoolLifecycleWithTimeout } from '../../../src/index/build/workers/pool.js'; + +applyTestEnv(); + +const messages = []; +const result = await destroyWorkerPoolLifecycleWithTimeout({ + poolLabel: 'destroy-timeout-contract', + timeoutMs: 20, + log: (line) => messages.push(String(line || '')), + lifecycle: { + async destroy() { + await new Promise(() => {}); + } + } +}); + +assert.equal(result.skipped, false, 'expected worker-pool destroy helper to run cleanup'); +assert.equal(result.timedOut, true, 'expected worker-pool destroy helper to fail open on timeout'); +assert.ok(messages.some((line) => line.includes('worker-pool.destroy-timeout-contract.lifecycle-destroy timed out')), 'expected timeout log to include pool label'); + +console.log('worker pool destroy timeout test passed'); diff --git a/tests/indexing/workers/worker-pool-fixture.js b/tests/indexing/workers/worker-pool-fixture.js new file mode 100644 index 000000000..1c206af46 --- /dev/null +++ b/tests/indexing/workers/worker-pool-fixture.js @@ -0,0 +1,41 @@ +import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; +import { createTokenizationContext, tokenizeChunkText } from '../../../src/index/build/tokenization.js'; +import { createIndexerWorkerPool, normalizeWorkerPoolConfig } from '../../../src/index/build/worker-pool.js'; + +export const WORKER_POOL_SAMPLE = 'helloWorld fooBar'; + +export const createWorkerPoolTestResources = async () => { + const postingsConfig = normalizePostingsConfig({ + enablePhraseNgrams: true, + phraseMinN: 2, + phraseMaxN: 3, + enableChargrams: true, + chargramMinN: 3, + chargramMaxN: 3 + }); + const dictWords = new Set(['hello', 'world', 'foo', 'bar']); + const dictConfig = { segmentation: 'greedy' }; + const workerConfig = normalizeWorkerPoolConfig({ + enabled: true, + maxWorkers: 1, + maxFileBytes: 4096, + quantizeBatchSize: 2, + taskTimeoutMs: 5000 + }, { cpuLimit: 1 }); + + const workerPool = await createIndexerWorkerPool({ + config: workerConfig, + dictWords, + dictConfig, + postingsConfig + }); + const context = createTokenizationContext({ dictWords, dictConfig, postingsConfig }); + const syncTokens = tokenizeChunkText({ + text: WORKER_POOL_SAMPLE, + mode: 'code', + ext: '.js', + context + }); + + return { syncTokens, workerPool }; +}; diff --git a/tests/indexing/workers/worker-pool-lifecycle-destroy-during-restart.test.js b/tests/indexing/workers/worker-pool-lifecycle-destroy-during-restart.test.js new file mode 100644 index 000000000..bfe8adc96 --- /dev/null +++ b/tests/indexing/workers/worker-pool-lifecycle-destroy-during-restart.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { createWorkerPoolLifecycle } from '../../../src/index/build/workers/pool/lifecycle.js'; + +applyTestEnv(); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +let createCalls = 0; +let destroyCalls = 0; +let activeTasks = 1; +let restartRelease = null; + +const lifecycle = createWorkerPoolLifecycle({ + poolLabel: 'destroy-during-restart', + createPool: () => { + createCalls += 1; + const instanceId = createCalls; + return { + async destroy() { + destroyCalls += 1; + if (instanceId === 1) { + await new Promise((resolve) => { + restartRelease = resolve; + }); + } + } + }; + }, + getActiveTasks: () => activeTasks, + log: () => {} +}); + +lifecycle.initialize(); +await lifecycle.scheduleRestart('synthetic restart'); +activeTasks = 0; +const ensurePromise = lifecycle.handleTaskDrained(); +await sleep(10); +const destroyPromise = lifecycle.destroy(); +assert.ok(restartRelease, 'expected restart destroy to be waiting'); +restartRelease(); +await Promise.all([ensurePromise, destroyPromise]); + +assert.equal(createCalls, 1, 'expected destroy to prevent replacement pool creation during restart'); +assert.equal(destroyCalls, 1, 'expected original pool to be destroyed exactly once'); +assert.equal(lifecycle.getPool(), null, 'expected pool reference to remain cleared after destroy'); + +console.log('worker pool lifecycle destroy-during-restart test passed'); diff --git a/tests/indexing/workers/worker-pool-lifecycle-destroy.test.js b/tests/indexing/workers/worker-pool-lifecycle-destroy.test.js new file mode 100644 index 000000000..e809b7b39 --- /dev/null +++ b/tests/indexing/workers/worker-pool-lifecycle-destroy.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { createWorkerPoolLifecycle } from '../../../src/index/build/workers/pool/lifecycle.js'; + +applyTestEnv(); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +let activeTasks = 1; +let destroyCalls = 0; +const lifecycle = createWorkerPoolLifecycle({ + poolLabel: 'destroy-contract', + createPool: () => ({ + async destroy() { + destroyCalls += 1; + await sleep(10); + } + }), + getActiveTasks: () => activeTasks, + log: () => {} +}); + +lifecycle.initialize(); + +const firstDestroy = lifecycle.destroy(); +const secondDestroy = lifecycle.destroy(); + +await sleep(20); +assert.equal(destroyCalls, 0, 'expected destroy to wait for active worker tasks to drain'); + +activeTasks = 0; +await lifecycle.handleTaskDrained(); +await Promise.all([firstDestroy, secondDestroy]); + +assert.equal(destroyCalls, 1, 'expected worker pool destroy to be serialized and idempotent'); +assert.equal(lifecycle.getPool(), null, 'expected pool reference to clear after destroy'); + +console.log('worker pool lifecycle destroy test passed'); diff --git a/tests/indexing/workers/worker-pool-windows.test.js b/tests/indexing/workers/worker-pool-windows.test.js index d66554e80..adada92bb 100644 --- a/tests/indexing/workers/worker-pool-windows.test.js +++ b/tests/indexing/workers/worker-pool-windows.test.js @@ -1,9 +1,10 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; -import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; -import { createTokenizationContext, tokenizeChunkText } from '../../../src/index/build/tokenization.js'; -import { createIndexerWorkerPool, normalizeWorkerPoolConfig } from '../../../src/index/build/worker-pool.js'; +import { + createWorkerPoolTestResources, + WORKER_POOL_SAMPLE +} from './worker-pool-fixture.js'; if (process.platform !== 'win32') { console.log('worker pool windows test skipped (non-windows).'); @@ -17,47 +18,20 @@ await fs.mkdir(deepDir, { recursive: true }); const originalCwd = process.cwd(); try { process.chdir(deepDir); - const postingsConfig = normalizePostingsConfig({ - enablePhraseNgrams: true, - phraseMinN: 2, - phraseMaxN: 3, - enableChargrams: true, - chargramMinN: 3, - chargramMaxN: 3 - }); - const dictWords = new Set(['hello', 'world', 'foo', 'bar']); - const dictConfig = { segmentation: 'greedy' }; - const workerConfig = normalizeWorkerPoolConfig({ - enabled: true, - maxWorkers: 1, - maxFileBytes: 4096, - quantizeBatchSize: 2, - taskTimeoutMs: 5000 - }, { cpuLimit: 1 }); - - const workerPool = await createIndexerWorkerPool({ - config: workerConfig, - dictWords, - dictConfig, - postingsConfig - }); + const { syncTokens, workerPool } = await createWorkerPoolTestResources(); if (!workerPool) { console.log('worker pool windows test skipped (worker pool unavailable).'); process.exit(0); } - const context = createTokenizationContext({ dictWords, dictConfig, postingsConfig }); - const sample = 'helloWorld fooBar'; - const syncTokens = tokenizeChunkText({ text: sample, mode: 'code', ext: '.js', context }); - const runs = []; for (let i = 0; i < 50; i += 1) { runs.push(workerPool.tokenizeChunk({ - text: sample, + text: WORKER_POOL_SAMPLE, mode: 'code', ext: '.js', file: `task-${i}`, - size: sample.length + size: WORKER_POOL_SAMPLE.length })); } const results = await Promise.all(runs); @@ -75,19 +49,19 @@ try { if (workerPool.pool?.destroy) { await workerPool.pool.destroy(); await workerPool.tokenizeChunk({ - text: sample, + text: WORKER_POOL_SAMPLE, mode: 'code', ext: '.js', file: 'restart', - size: sample.length + size: WORKER_POOL_SAMPLE.length }); await new Promise((resolve) => setTimeout(resolve, 1200)); const restarted = await workerPool.tokenizeChunk({ - text: sample, + text: WORKER_POOL_SAMPLE, mode: 'code', ext: '.js', file: 'restart-2', - size: sample.length + size: WORKER_POOL_SAMPLE.length }); if (!restarted) { console.error('worker pool windows test failed: restart did not recover.'); diff --git a/tests/indexing/workers/worker-pool.test.js b/tests/indexing/workers/worker-pool.test.js index 6ef85753d..5ab3cdd01 100644 --- a/tests/indexing/workers/worker-pool.test.js +++ b/tests/indexing/workers/worker-pool.test.js @@ -1,42 +1,21 @@ #!/usr/bin/env node -import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; import { quantizeVec } from '../../../src/index/embedding.js'; -import { createTokenizationContext, tokenizeChunkText } from '../../../src/index/build/tokenization.js'; -import { createIndexerWorkerPool, normalizeWorkerPoolConfig } from '../../../src/index/build/worker-pool.js'; +import { + createWorkerPoolTestResources, + WORKER_POOL_SAMPLE +} from './worker-pool-fixture.js'; -const postingsConfig = normalizePostingsConfig({ - enablePhraseNgrams: true, - phraseMinN: 2, - phraseMaxN: 3, - enableChargrams: true, - chargramMinN: 3, - chargramMaxN: 3 -}); -const dictWords = new Set(['hello', 'world', 'foo', 'bar']); -const dictConfig = { segmentation: 'greedy' }; -const workerConfig = normalizeWorkerPoolConfig({ - enabled: true, - maxWorkers: 1, - maxFileBytes: 4096, - quantizeBatchSize: 2, - taskTimeoutMs: 5000 -}, { cpuLimit: 1 }); - -const workerPool = await createIndexerWorkerPool({ - config: workerConfig, - dictWords, - dictConfig, - postingsConfig -}); +const { syncTokens, workerPool } = await createWorkerPoolTestResources(); if (!workerPool) { console.log('worker pool test skipped (worker pool unavailable).'); process.exit(0); } -const context = createTokenizationContext({ dictWords, dictConfig, postingsConfig }); -const sample = 'helloWorld fooBar'; -const syncTokens = tokenizeChunkText({ text: sample, mode: 'code', ext: '.js', context }); -const workerTokens = await workerPool.tokenizeChunk({ text: sample, mode: 'code', ext: '.js' }); +const workerTokens = await workerPool.tokenizeChunk({ + text: WORKER_POOL_SAMPLE, + mode: 'code', + ext: '.js' +}); if (JSON.stringify(syncTokens.tokens) !== JSON.stringify(workerTokens.tokens)) { console.error('worker pool test failed: tokens mismatch.'); diff --git a/tests/lang/clike/clike-doc-comments.test.js b/tests/lang/clike/doc-comments.test.js similarity index 100% rename from tests/lang/clike/clike-doc-comments.test.js rename to tests/lang/clike/doc-comments.test.js diff --git a/tests/lang/clike/clike-flow-callable-only.test.js b/tests/lang/clike/flow-callable-only.test.js similarity index 100% rename from tests/lang/clike/clike-flow-callable-only.test.js rename to tests/lang/clike/flow-callable-only.test.js diff --git a/tests/lang/clike/clike-objc-header-routing.test.js b/tests/lang/clike/objc-header-routing.test.js similarity index 100% rename from tests/lang/clike/clike-objc-header-routing.test.js rename to tests/lang/clike/objc-header-routing.test.js diff --git a/tests/lang/contracts/build-dsl-heuristic-adapters.test.js b/tests/lang/contracts/build-dsl-heuristic-adapters.test.js index a2d0258c7..d1147abdc 100644 --- a/tests/lang/contracts/build-dsl-heuristic-adapters.test.js +++ b/tests/lang/contracts/build-dsl-heuristic-adapters.test.js @@ -1,7 +1,6 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { assertHeuristicAdapterCases } from '../helpers/heuristic-adapter-contracts.js'; applyTestEnv(); @@ -20,7 +19,8 @@ const CASES = [ ].join('\n'), expectedImport: 'deps/core.cmake', expectedExport: 'register_target', - expectedUsage: 'add_library' + expectedUsage: 'add_library', + expectedCapabilityState: 'partial' }, { id: 'starlark', @@ -33,7 +33,8 @@ const CASES = [ ].join('\n'), expectedImport: '//tools:defs.bzl', expectedExport: 'build_target', - expectedUsage: 'cc_library' + expectedUsage: 'cc_library', + expectedCapabilityState: 'partial' }, { id: 'nix', @@ -45,7 +46,8 @@ const CASES = [ ].join('\n'), expectedImport: './deps.nix', expectedExport: 'deps', - expectedUsage: 'callPackage' + expectedUsage: 'callPackage', + expectedCapabilityState: 'partial' }, { id: 'makefile', @@ -60,7 +62,8 @@ const CASES = [ ].join('\n'), expectedImport: 'common.mk', expectedExport: 'build', - expectedUsage: 'prep' + expectedUsage: 'prep', + expectedCapabilityState: 'partial' }, { id: 'dockerfile', @@ -74,43 +77,11 @@ const CASES = [ ].join('\n'), expectedImport: 'node:20', expectedExport: 'builder', - expectedUsage: 'node:20' + expectedUsage: 'node:20', + expectedCapabilityState: 'partial' } ]; -for (const testCase of CASES) { - const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); - assert.ok(entry, `missing registry entry for ${testCase.id}`); - - const capability = entry.capabilityProfile; - assert.ok(capability && capability.state === 'partial', `${testCase.id} should keep explicit partial capability profile`); - assert.ok(Array.isArray(capability.diagnostics), `${testCase.id} should expose capability diagnostics`); - - const relations = entry.buildRelations({ text: testCase.source, relPath: testCase.relPath, options: {} }) || {}; - assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); - assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); - assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); - assert.ok(relations.exports.includes(testCase.expectedExport), `${testCase.id} should emit heuristic export symbol`); - assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); - assert.ok(relations.usages.includes(testCase.expectedUsage), `${testCase.id} should emit DSL usage`); - assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); - assert.ok(relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === testCase.expectedUsage), `${testCase.id} should emit call edges`); - - const chunk = { - name: testCase.expectedExport, - start: 0, - end: testCase.source.length - }; - const docmeta = entry.extractDocMeta({ chunk }); - assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); - - const flow = entry.flow({ - text: testCase.source, - chunk, - options: { astDataflowEnabled: true, controlFlowEnabled: true } - }); - assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); - assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); -} +assertHeuristicAdapterCases(CASES, { usageLabel: 'DSL usage' }); console.log('build DSL heuristic adapters contract test passed'); diff --git a/tests/lang/contracts/data-interface-heuristic-adapters.test.js b/tests/lang/contracts/data-interface-heuristic-adapters.test.js index f373f6512..96b9546a8 100644 --- a/tests/lang/contracts/data-interface-heuristic-adapters.test.js +++ b/tests/lang/contracts/data-interface-heuristic-adapters.test.js @@ -1,7 +1,6 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { assertHeuristicAdapterCases } from '../helpers/heuristic-adapter-contracts.js'; applyTestEnv(); @@ -48,36 +47,6 @@ const CASES = [ } ]; -for (const testCase of CASES) { - const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); - assert.ok(entry, `missing registry entry for ${testCase.id}`); - assert.equal(entry.capabilityProfile, undefined, `${testCase.id} should not be marked as import-collector downgrade`); - - const relations = entry.buildRelations({ text: testCase.source, options: {} }) || {}; - assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); - assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); - assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); - assert.ok(relations.exports.includes(testCase.expectedExport), `${testCase.id} should emit heuristic export symbol`); - assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); - assert.ok(relations.usages.includes(testCase.expectedUsage), `${testCase.id} should emit schema usage`); - assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); - assert.ok(relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === testCase.expectedUsage), `${testCase.id} should emit call edges`); - - const chunk = { - name: testCase.expectedExport, - start: 0, - end: testCase.source.length - }; - const docmeta = entry.extractDocMeta({ chunk }); - assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); - - const flow = entry.flow({ - text: testCase.source, - chunk, - options: { astDataflowEnabled: true, controlFlowEnabled: true } - }); - assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); - assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); -} +assertHeuristicAdapterCases(CASES, { usageLabel: 'schema usage' }); console.log('data-interface heuristic adapters contract test passed'); diff --git a/tests/lang/contracts/dynamic-heuristic-adapters.test.js b/tests/lang/contracts/dynamic-heuristic-adapters.test.js index 141673347..c13ea24d4 100644 --- a/tests/lang/contracts/dynamic-heuristic-adapters.test.js +++ b/tests/lang/contracts/dynamic-heuristic-adapters.test.js @@ -1,7 +1,6 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { assertHeuristicAdapterCases } from '../helpers/heuristic-adapter-contracts.js'; applyTestEnv(); @@ -42,36 +41,6 @@ const CASES = [ } ]; -for (const testCase of CASES) { - const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); - assert.ok(entry, `missing registry entry for ${testCase.id}`); - assert.equal(entry.capabilityProfile, undefined, `${testCase.id} should not be marked as import-collector downgrade`); - - const relations = entry.buildRelations({ text: testCase.source, options: {} }) || {}; - assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); - assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); - assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); - assert.ok(relations.exports.includes(testCase.expectedExport), `${testCase.id} should emit heuristic exported symbol`); - assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); - assert.ok(relations.usages.includes(testCase.expectedCall), `${testCase.id} should emit heuristic call usage`); - assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); - assert.ok(relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === testCase.expectedCall), `${testCase.id} should emit call edges`); - - const chunk = { - name: testCase.expectedExport, - start: 0, - end: testCase.source.length - }; - const docmeta = entry.extractDocMeta({ chunk }); - assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); - - const flow = entry.flow({ - text: testCase.source, - chunk, - options: { astDataflowEnabled: true, controlFlowEnabled: true } - }); - assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); - assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); -} +assertHeuristicAdapterCases(CASES); console.log('dynamic heuristic adapters contract test passed'); diff --git a/tests/lang/contracts/go.test.js b/tests/lang/contracts/go.test.js deleted file mode 100644 index c72d2e3ac..000000000 --- a/tests/lang/contracts/go.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const { chunkMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -const goStruct = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/go_advanced.go' - && String(chunk.kind || '').includes('Struct') - && String(chunk.name || '').includes('Widget') -); -if (!goStruct) { - console.error('Missing Go struct chunk (Widget).'); - process.exit(1); -} -if (!String(goStruct.docmeta?.doc || '').includes('Widget holds a name')) { - console.error('Go docstring missing for Widget struct.'); - process.exit(1); -} - -const goFunc = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/go_advanced.go' - && String(chunk.kind || '').includes('Function') - && String(chunk.name || '').includes('MakeWidget') -); -if (!goFunc) { - console.error('Missing Go function chunk (MakeWidget).'); - process.exit(1); -} -const controlFlow = goFunc.docmeta?.controlFlow; -if (!controlFlow || !(controlFlow.returns >= 1)) { - console.error('Go controlFlow missing returns for MakeWidget.'); - process.exit(1); -} - -console.log('Go contract checks ok.'); diff --git a/tests/lang/contracts/javascript.test.js b/tests/lang/contracts/javascript.test.js deleted file mode 100644 index fc1817423..000000000 --- a/tests/lang/contracts/javascript.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const { chunkMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -const jsWidgetClass = chunkMeta.find((chunk) => { - if (!chunk || resolveChunkFile(chunk) !== 'src/javascript_advanced.js') return false; - if (chunk.name !== 'Widget') return false; - return chunk.kind === 'ClassDeclaration' - || chunk.kind === 'ExportedClass' - || chunk.kind === 'ExportDefaultClassDeclaration'; -}); -if (!jsWidgetClass) { - console.error('Missing JS class chunk (Widget).'); - process.exit(1); -} -const bases = jsWidgetClass.docmeta?.extends || []; -if (!bases.includes('BaseWidget')) { - console.error('JS class metadata missing BaseWidget extends.'); - process.exit(1); -} - -const jsLoad = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/javascript_advanced.js' - && String(chunk.name || '').includes('Widget.load') -); -if (!jsLoad) { - console.error('Missing JS async method chunk (Widget.load).'); - process.exit(1); -} -if (!jsLoad.docmeta?.modifiers?.async) { - console.error('JS async modifier missing for Widget.load.'); - process.exit(1); -} - -console.log('JavaScript contract checks ok.'); diff --git a/tests/lang/contracts/language-fixture-contracts.test.js b/tests/lang/contracts/language-fixture-contracts.test.js new file mode 100644 index 000000000..11c35829c --- /dev/null +++ b/tests/lang/contracts/language-fixture-contracts.test.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; +import { fail, findSafely, hasPython, runEnabledCases } from '../helpers/fixture-metadata.js'; + +const { fixtureRoot, userConfig } = await ensureFixtureIndex({ + fixtureName: 'languages', + cacheName: 'language-fixture', + cacheScope: 'shared', + requiredModes: ['code'] +}); +const { chunkMeta, fileMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); + +const findChunk = (predicate) => findSafely(chunkMeta, predicate); + +const pythonEnabled = hasPython(); + +if (!Array.isArray(chunkMeta) || chunkMeta.length === 0) { + fail('Language fixture chunk_meta.json missing or empty.'); +} + +const sampleChunk = chunkMeta.find((chunk) => chunk && (chunk.file || chunk.fileId)); +const resolvedFile = sampleChunk ? resolveChunkFile(sampleChunk) : null; +if (!resolvedFile) { + fail('Language fixture chunk_meta entries missing file references.'); +} + +if (fileMeta && !Array.isArray(fileMeta)) { + fail('Language fixture file_meta.json should be an array.'); +} + +const cases = [ + { + label: 'Go struct doc metadata', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/go_advanced.go' + && String(chunk.kind || '').includes('Struct') + && String(chunk.name || '').includes('Widget') + ), + validate: (chunk) => { + if (!chunk) fail('Missing Go struct chunk (Widget).'); + if (!String(chunk.docmeta?.doc || '').includes('Widget holds a name')) { + fail('Go docstring missing for Widget struct.'); + } + } + }, + { + label: 'Go function control flow', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/go_advanced.go' + && String(chunk.kind || '').includes('Function') + && String(chunk.name || '').includes('MakeWidget') + ), + validate: (chunk) => { + if (!chunk) fail('Missing Go function chunk (MakeWidget).'); + const controlFlow = chunk.docmeta?.controlFlow; + if (!controlFlow || !(controlFlow.returns >= 1)) { + fail('Go controlFlow missing returns for MakeWidget.'); + } + } + }, + { + label: 'JavaScript class inheritance', + enabled: true, + find: () => findChunk((chunk) => { + if (!chunk || resolveChunkFile(chunk) !== 'src/javascript_advanced.js') return false; + if (chunk.name !== 'Widget') return false; + return chunk.kind === 'ClassDeclaration' + || chunk.kind === 'ExportedClass' + || chunk.kind === 'ExportDefaultClassDeclaration'; + }), + validate: (chunk) => { + if (!chunk) fail('Missing JS class chunk (Widget).'); + const bases = chunk.docmeta?.extends || []; + if (!bases.includes('BaseWidget')) { + fail('JS class metadata missing BaseWidget extends.'); + } + } + }, + { + label: 'JavaScript async method modifiers', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/javascript_advanced.js' + && String(chunk.name || '').includes('Widget.load') + ), + validate: (chunk) => { + if (!chunk) fail('Missing JS async method chunk (Widget.load).'); + if (!chunk.docmeta?.modifiers?.async) { + fail('JS async modifier missing for Widget.load.'); + } + } + }, + { + label: 'Python dataclass fields', + enabled: pythonEnabled, + skipMessage: 'Skipping Python language-fixture contract checks (python not available).', + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/python_advanced.py' + && String(chunk.name || '').includes('Point') + && String(chunk.kind || '').includes('Class') + ), + validate: (chunk) => { + if (!chunk) fail('Missing Python dataclass chunk (Point).'); + const fieldNames = (chunk.docmeta?.fields || []).map((field) => field.name); + if (!fieldNames.includes('x') || !fieldNames.includes('y')) { + fail('Python dataclass fields missing for Point (expected x,y).'); + } + } + }, + { + label: 'Python async metadata', + enabled: pythonEnabled, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/python_advanced.py' + && String(chunk.name || '').includes('fetch_data') + ), + validate: (chunk) => { + if (!chunk) fail('Missing Python async chunk (fetch_data).'); + if (!chunk.docmeta?.async) { + fail('Python async metadata missing for fetch_data.'); + } + } + }, + { + label: 'SQL table control flow', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/sql_advanced.sql' + && String(chunk.kind || '').includes('Table') + && String(chunk.name || '').includes('widgets') + ), + validate: (chunk) => { + if (!chunk) fail('Missing SQL table chunk (widgets).'); + if (typeof chunk.docmeta?.controlFlow?.branches !== 'number') { + fail('SQL control flow missing for widgets.'); + } + } + }, + { + label: 'Postgres dialect metadata', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/sql_postgres.psql' + && String(chunk.kind || '').includes('Table') + && String(chunk.name || '').includes('pg_widgets') + ), + validate: (chunk) => { + if (!chunk) fail('Missing Postgres SQL table chunk (pg_widgets).'); + if (chunk.docmeta?.dialect !== 'postgres') { + fail('Postgres dialect metadata missing for pg_widgets.'); + } + } + }, + { + label: 'TypeScript class inheritance', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/typescript_advanced.ts' + && chunk.kind === 'ClassDeclaration' + && chunk.name === 'Widget' + ), + validate: (chunk) => { + if (!chunk) fail('Missing TypeScript class chunk (Widget).'); + const extendsList = chunk.docmeta?.extends || []; + if (!extendsList.some((name) => String(name).includes('BaseWidget'))) { + fail('TypeScript extends metadata missing BaseWidget.'); + } + } + }, + { + label: 'TypeScript function control flow', + enabled: true, + find: () => findChunk((chunk) => + resolveChunkFile(chunk) === 'src/typescript_advanced.ts' + && chunk.kind === 'FunctionDeclaration' + && String(chunk.name || '').includes('makeWidget') + ), + validate: (chunk) => { + if (!chunk) fail('Missing TypeScript function chunk (makeWidget).'); + const controlFlow = chunk.docmeta?.controlFlow; + if (!controlFlow || !(controlFlow.returns >= 1)) { + fail('TypeScript controlFlow missing returns for makeWidget.'); + } + } + } +]; + +if (!pythonEnabled) { + console.log('Skipping Python language-fixture contract checks (python not available).'); +} + +await runEnabledCases(cases); + +console.log('Language fixture contracts ok.'); diff --git a/tests/lang/contracts/managed-heuristic-adapters.test.js b/tests/lang/contracts/managed-heuristic-adapters.test.js index db40423a4..102b271a5 100644 --- a/tests/lang/contracts/managed-heuristic-adapters.test.js +++ b/tests/lang/contracts/managed-heuristic-adapters.test.js @@ -1,7 +1,6 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { assertHeuristicAdapterCases } from '../helpers/heuristic-adapter-contracts.js'; applyTestEnv(); @@ -58,36 +57,6 @@ const CASES = [ } ]; -for (const testCase of CASES) { - const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); - assert.ok(entry, `missing registry entry for ${testCase.id}`); - assert.equal(entry.capabilityProfile, undefined, `${testCase.id} should not be marked as import-collector downgrade`); - - const relations = entry.buildRelations({ text: testCase.source, options: {} }) || {}; - assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); - assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); - assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); - assert.ok(relations.exports.includes(testCase.expectedExport), `${testCase.id} should emit at least one exported symbol`); - assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); - assert.ok(relations.usages.includes(testCase.expectedCall), `${testCase.id} should emit heuristic call usage`); - assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); - assert.ok(relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === testCase.expectedCall), `${testCase.id} should emit call edges`); - - const chunk = { - name: testCase.expectedExport, - start: 0, - end: testCase.source.length - }; - const docmeta = entry.extractDocMeta({ chunk }); - assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); - - const flow = entry.flow({ - text: testCase.source, - chunk, - options: { astDataflowEnabled: true, controlFlowEnabled: true } - }); - assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); - assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); -} +assertHeuristicAdapterCases(CASES); console.log('managed heuristic adapters contract test passed'); diff --git a/tests/lang/contracts/python.test.js b/tests/lang/contracts/python.test.js deleted file mode 100644 index fb53ff978..000000000 --- a/tests/lang/contracts/python.test.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const hasPython = () => { - const candidates = ['python', 'python3']; - for (const cmd of candidates) { - const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); - if (result.status === 0 && result.stdout.trim() === 'ok') return true; - } - return false; -}; - -if (!hasPython()) { - console.log('Skipping Python contract checks (python not available).'); - process.exit(0); -} - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const { chunkMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -const pointChunk = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/python_advanced.py' - && String(chunk.name || '').includes('Point') - && String(chunk.kind || '').includes('Class') -); -if (!pointChunk) { - console.error('Missing Python dataclass chunk (Point).'); - process.exit(1); -} -const fields = pointChunk.docmeta?.fields || []; -const fieldNames = fields.map((field) => field.name); -if (!fieldNames.includes('x') || !fieldNames.includes('y')) { - console.error('Python dataclass fields missing for Point (expected x,y).'); - process.exit(1); -} - -const fetchData = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/python_advanced.py' - && String(chunk.name || '').includes('fetch_data') -); -if (!fetchData) { - console.error('Missing Python async chunk (fetch_data).'); - process.exit(1); -} -if (!fetchData.docmeta?.async) { - console.error('Python async metadata missing for fetch_data.'); - process.exit(1); -} - -console.log('Python contract checks ok.'); diff --git a/tests/lang/contracts/sql.test.js b/tests/lang/contracts/sql.test.js deleted file mode 100644 index a4c0b4d83..000000000 --- a/tests/lang/contracts/sql.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const { chunkMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -const sqlTable = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/sql_advanced.sql' - && String(chunk.kind || '').includes('Table') - && String(chunk.name || '').includes('widgets') -); -if (!sqlTable) { - console.error('Missing SQL table chunk (widgets).'); - process.exit(1); -} -if (typeof sqlTable.docmeta?.controlFlow?.branches !== 'number') { - console.error('SQL control flow missing for widgets.'); - process.exit(1); -} - -const pgTable = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/sql_postgres.psql' - && String(chunk.kind || '').includes('Table') - && String(chunk.name || '').includes('pg_widgets') -); -if (!pgTable) { - console.error('Missing Postgres SQL table chunk (pg_widgets).'); - process.exit(1); -} -if (pgTable.docmeta?.dialect !== 'postgres') { - console.error('Postgres dialect metadata missing for pg_widgets.'); - process.exit(1); -} - -console.log('SQL contract checks ok.'); diff --git a/tests/lang/contracts/template-heuristic-adapters.test.js b/tests/lang/contracts/template-heuristic-adapters.test.js index c155cf958..7139f8d28 100644 --- a/tests/lang/contracts/template-heuristic-adapters.test.js +++ b/tests/lang/contracts/template-heuristic-adapters.test.js @@ -1,7 +1,6 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; import { applyTestEnv } from '../../helpers/test-env.js'; -import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { assertHeuristicAdapterCases } from '../helpers/heuristic-adapter-contracts.js'; applyTestEnv(); @@ -57,36 +56,6 @@ const CASES = [ } ]; -for (const testCase of CASES) { - const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); - assert.ok(entry, `missing registry entry for ${testCase.id}`); - assert.equal(entry.capabilityProfile, undefined, `${testCase.id} should not be marked as import-collector downgrade`); - - const relations = entry.buildRelations({ text: testCase.source, options: {} }) || {}; - assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); - assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); - assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); - assert.ok(relations.exports.includes(testCase.expectedExport), `${testCase.id} should emit heuristic export symbol`); - assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); - assert.ok(relations.usages.includes(testCase.expectedUsage), `${testCase.id} should emit template usage`); - assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); - assert.ok(relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === testCase.expectedUsage), `${testCase.id} should emit call edges`); - - const chunk = { - name: testCase.expectedExport, - start: 0, - end: testCase.source.length - }; - const docmeta = entry.extractDocMeta({ chunk }); - assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); - - const flow = entry.flow({ - text: testCase.source, - chunk, - options: { astDataflowEnabled: true, controlFlowEnabled: true } - }); - assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); - assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); -} +assertHeuristicAdapterCases(CASES, { usageLabel: 'template usage' }); console.log('template heuristic adapters contract test passed'); diff --git a/tests/lang/contracts/typescript.test.js b/tests/lang/contracts/typescript.test.js deleted file mode 100644 index 771b80b2e..000000000 --- a/tests/lang/contracts/typescript.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { ensureFixtureIndex, loadFixtureIndexMeta } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, userConfig } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const { chunkMeta, resolveChunkFile } = loadFixtureIndexMeta(fixtureRoot, userConfig); - -const tsClass = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/typescript_advanced.ts' - && chunk.kind === 'ClassDeclaration' - && chunk.name === 'Widget' -); -if (!tsClass) { - console.error('Missing TypeScript class chunk (Widget).'); - process.exit(1); -} -const extendsList = tsClass.docmeta?.extends || []; -if (!extendsList.some((name) => String(name).includes('BaseWidget'))) { - console.error('TypeScript extends metadata missing BaseWidget.'); - process.exit(1); -} - -const tsFunc = chunkMeta.find((chunk) => - resolveChunkFile(chunk) === 'src/typescript_advanced.ts' - && chunk.kind === 'FunctionDeclaration' - && String(chunk.name || '').includes('makeWidget') -); -if (!tsFunc) { - console.error('Missing TypeScript function chunk (makeWidget).'); - process.exit(1); -} -const controlFlow = tsFunc.docmeta?.controlFlow; -if (!controlFlow || !(controlFlow.returns >= 1)) { - console.error('TypeScript controlFlow missing returns for makeWidget.'); - process.exit(1); -} - -console.log('TypeScript contract checks ok.'); diff --git a/tests/lang/fixtures-sample/metadata-matrix.test.js b/tests/lang/fixtures-sample/metadata-matrix.test.js new file mode 100644 index 000000000..35ea1cec4 --- /dev/null +++ b/tests/lang/fixtures-sample/metadata-matrix.test.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { fail, findSafely, hasPython, runEnabledCases } from '../helpers/fixture-metadata.js'; + +const { fixtureRoot, env } = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName: 'fixture-sample', + cacheScope: 'shared', + requiredModes: ['code'] +}); +const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); +const pythonEnabled = hasPython(); + +const findCodeHit = (payload, predicate) => findSafely(payload.code || [], predicate); + +const cases = [ + { + label: 'Python decorator metadata', + enabled: pythonEnabled, + query: 'message', + validate: async () => { + const payload = await runSearch({ + query: 'message', + mode: 'code', + args: ['--backend', 'memory'] + }); + const hit = findCodeHit( + payload, + (entry) => entry.file === 'src/sample.py' && String(entry.name || '').endsWith('message') + ); + if (!hit) fail('Python metadata check failed: missing sample.py message chunk.'); + if (!String(hit.docmeta?.signature || '').includes('def message')) { + fail('Python metadata check failed: missing signature metadata.'); + } + if (!(hit.docmeta?.decorators || []).includes('staticmethod')) { + fail('Python metadata check failed: missing decorator metadata.'); + } + } + }, + { + label: 'Rust signature metadata', + enabled: true, + query: 'rust_greet', + validate: async () => { + const payload = await runSearch({ + query: 'rust_greet', + mode: 'code', + args: ['--backend', 'memory'] + }); + const hit = findCodeHit( + payload, + (entry) => entry.file === 'src/sample.rs' && entry.name === 'rust_greet' + ); + if (!hit) fail('Rust metadata check failed: missing sample.rs rust_greet chunk.'); + if (!String(hit.docmeta?.signature || '').includes('fn rust_greet')) { + fail('Rust metadata check failed: missing signature metadata.'); + } + } + }, + { + label: 'Swift attribute metadata', + enabled: true, + query: 'sayHello', + validate: async () => { + const payload = await runSearch({ + query: 'sayHello', + mode: 'code', + args: ['--backend', 'memory'] + }); + const hit = findCodeHit( + payload, + (entry) => entry.file === 'src/sample.swift' && entry.name === 'Greeter.sayHello' + ); + if (!hit) fail('Swift metadata check failed: missing sample.swift sayHello chunk.'); + if (!String(hit.docmeta?.signature || '').includes('func sayHello')) { + fail('Swift metadata check failed: missing signature metadata.'); + } + if (!(hit.docmeta?.decorators || []).includes('available')) { + fail('Swift metadata check failed: missing attribute metadata.'); + } + } + } +]; + +if (!pythonEnabled) { + console.log('Skipping Python sample metadata checks (python not available).'); +} + +await runEnabledCases(cases); + +console.log('Fixture sample metadata matrix ok.'); diff --git a/tests/lang/fixtures-sample/python-metadata.test.js b/tests/lang/fixtures-sample/python-metadata.test.js deleted file mode 100644 index 4c89286b6..000000000 --- a/tests/lang/fixtures-sample/python-metadata.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const hasPython = () => { - const candidates = ['python', 'python3']; - for (const cmd of candidates) { - const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); - if (result.status === 0 && result.stdout.trim() === 'ok') return true; - } - return false; -}; - -if (!hasPython()) { - console.log('Skipping Python metadata checks (python not available).'); - process.exit(0); -} - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName: 'fixture-sample-python', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const payload = await runSearch({ - query: 'message', - mode: 'code', - args: ['--backend', 'memory'] -}); -const hit = (payload.code || []).find( - (entry) => entry.file === 'src/sample.py' && String(entry.name || '').endsWith('message') -); -if (!hit) { - console.error('Python metadata check failed: missing sample.py message chunk.'); - process.exit(1); -} -const signature = hit.docmeta?.signature || ''; -const decorators = hit.docmeta?.decorators || []; -if (!signature.includes('def message')) { - console.error('Python metadata check failed: missing signature metadata.'); - process.exit(1); -} -if (!decorators.includes('staticmethod')) { - console.error('Python metadata check failed: missing decorator metadata.'); - process.exit(1); -} - -console.log('Python fixture metadata ok.'); diff --git a/tests/lang/fixtures-sample/rust-metadata.test.js b/tests/lang/fixtures-sample/rust-metadata.test.js deleted file mode 100644 index a2b203ddd..000000000 --- a/tests/lang/fixtures-sample/rust-metadata.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName: 'fixture-sample', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const payload = await runSearch({ - query: 'rust_greet', - mode: 'code', - args: ['--backend', 'memory'] -}); -const hit = (payload.code || []).find( - (entry) => entry.file === 'src/sample.rs' && entry.name === 'rust_greet' -); -if (!hit) { - console.error('Rust metadata check failed: missing sample.rs rust_greet chunk.'); - process.exit(1); -} -const signature = hit.docmeta?.signature || ''; -if (!signature.includes('fn rust_greet')) { - console.error('Rust metadata check failed: missing signature metadata.'); - process.exit(1); -} - -console.log('Rust fixture metadata ok.'); diff --git a/tests/lang/fixtures-sample/swift-metadata.test.js b/tests/lang/fixtures-sample/swift-metadata.test.js deleted file mode 100644 index eadd30cef..000000000 --- a/tests/lang/fixtures-sample/swift-metadata.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName: 'fixture-sample', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const payload = await runSearch({ - query: 'sayHello', - mode: 'code', - args: ['--backend', 'memory'] -}); -const hit = (payload.code || []).find( - (entry) => entry.file === 'src/sample.swift' && entry.name === 'Greeter.sayHello' -); -if (!hit) { - console.error('Swift metadata check failed: missing sample.swift sayHello chunk.'); - process.exit(1); -} -const signature = hit.docmeta?.signature || ''; -const decorators = hit.docmeta?.decorators || []; -if (!signature.includes('func sayHello')) { - console.error('Swift metadata check failed: missing signature metadata.'); - process.exit(1); -} -if (!decorators.includes('available')) { - console.error('Swift metadata check failed: missing attribute metadata.'); - process.exit(1); -} - -console.log('Swift fixture metadata ok.'); diff --git a/tests/lang/helpers/fixture-metadata.js b/tests/lang/helpers/fixture-metadata.js new file mode 100644 index 000000000..c649caa0c --- /dev/null +++ b/tests/lang/helpers/fixture-metadata.js @@ -0,0 +1,30 @@ +import { spawnSync } from 'node:child_process'; + +export const fail = (message) => { + console.error(message); + process.exit(1); +}; + +export const hasPython = () => { + const candidates = ['python', 'python3']; + for (const cmd of candidates) { + const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); + if (result.status === 0 && result.stdout.trim() === 'ok') return true; + } + return false; +}; + +export const findSafely = (items, predicate) => items.find((item) => { + try { + return predicate(item); + } catch { + return false; + } +}); + +export const runEnabledCases = async (cases) => { + for (const testCase of cases) { + if (!testCase.enabled) continue; + await testCase.validate(testCase.find?.()); + } +}; diff --git a/tests/lang/helpers/heuristic-adapter-contracts.js b/tests/lang/helpers/heuristic-adapter-contracts.js new file mode 100644 index 000000000..b579e2908 --- /dev/null +++ b/tests/lang/helpers/heuristic-adapter-contracts.js @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; + +const resolveExpectedUsage = (testCase) => testCase.expectedUsage ?? testCase.expectedCall; + +const assertCapabilityProfile = (entry, testCase) => { + const capability = entry.capabilityProfile; + if (testCase.expectedCapabilityState) { + assert.ok( + capability && capability.state === testCase.expectedCapabilityState, + `${testCase.id} should keep explicit ${testCase.expectedCapabilityState} capability profile` + ); + assert.ok(Array.isArray(capability.diagnostics), `${testCase.id} should expose capability diagnostics`); + return; + } + assert.equal(capability, undefined, `${testCase.id} should not be marked as import-collector downgrade`); +}; + +export const assertHeuristicAdapterCases = (cases, { usageLabel = 'heuristic call usage' } = {}) => { + for (const testCase of cases) { + const entry = LANGUAGE_REGISTRY.find((row) => row.id === testCase.id); + assert.ok(entry, `missing registry entry for ${testCase.id}`); + assertCapabilityProfile(entry, testCase); + + const expectedUsage = resolveExpectedUsage(testCase); + const relations = entry.buildRelations({ + text: testCase.source, + relPath: testCase.relPath, + options: {} + }) || {}; + assert.ok(Array.isArray(relations.imports), `${testCase.id} should emit imports array`); + assert.ok(relations.imports.includes(testCase.expectedImport), `${testCase.id} should keep expected import`); + assert.ok(Array.isArray(relations.exports), `${testCase.id} should emit exports array`); + assert.ok( + relations.exports.includes(testCase.expectedExport), + `${testCase.id} should emit heuristic export symbol` + ); + assert.ok(Array.isArray(relations.usages), `${testCase.id} should emit usages array`); + assert.ok(relations.usages.includes(expectedUsage), `${testCase.id} should emit ${usageLabel}`); + assert.ok(Array.isArray(relations.calls), `${testCase.id} should emit calls array`); + assert.ok( + relations.calls.some((entryCall) => Array.isArray(entryCall) && entryCall[1] === expectedUsage), + `${testCase.id} should emit call edges` + ); + + const chunk = { + name: testCase.expectedExport, + start: 0, + end: testCase.source.length + }; + const docmeta = entry.extractDocMeta({ chunk }); + assert.equal(docmeta?.symbol, testCase.expectedExport, `${testCase.id} should emit heuristic docmeta symbol`); + + const flow = entry.flow({ + text: testCase.source, + chunk, + options: { astDataflowEnabled: true, controlFlowEnabled: true } + }); + assert.ok(flow && flow.controlFlow, `${testCase.id} should emit control flow summary`); + assert.equal(typeof flow.controlFlow.branches, 'number', `${testCase.id} controlFlow.branches must be numeric`); + } +}; diff --git a/tests/lang/javascript/javascript-contract-matrix.test.js b/tests/lang/javascript/javascript-contract-matrix.test.js new file mode 100644 index 000000000..92956351a --- /dev/null +++ b/tests/lang/javascript/javascript-contract-matrix.test.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildJsChunks, buildCodeRelations, collectImports } from '../../../src/lang/javascript.js'; +import { extractDocMeta } from '../../../src/lang/javascript/docmeta.js'; +import { applyCrossFileInference } from '../../../src/index/type-inference-crossfile/pipeline.js'; + +const cases = [ + { + name: 'collectImports extracts static, export, require, and dynamic imports', + async run() { + const source = [ + "import fs from 'fs';", + "import { join as joinPath } from 'path';", + "export * from 'module-a';", + "export { foo } from 'module-b';", + "const mod = require('module-c');", + "async function load() { return import('module-d'); }" + ].join('\n'); + + const imports = collectImports(source).slice().sort(); + const expected = ['fs', 'path', 'module-a', 'module-b', 'module-c', 'module-d'].sort(); + assert.deepEqual(imports, expected); + } + }, + { + name: 'buildJsChunks discovers common function and class shapes', + async run() { + const source = [ + 'export function alpha() {}', + 'class Foo {', + ' method() {}', + ' static bar() {}', + '}', + 'const beta = () => {};', + 'export default function gamma() {}', + 'exports.qux = function() {};' + ].join('\n'); + + const chunks = buildJsChunks(source) || []; + const names = new Set(chunks.map((chunk) => chunk.name)); + for (const name of ['alpha', 'Foo', 'Foo.method', 'Foo.bar', 'beta', 'gamma']) { + assert.ok(names.has(name), `missing JS chunk ${name}`); + } + assert.ok(names.has('exports.qux') || names.has('qux'), 'missing assignment function chunk'); + } + }, + { + name: 'buildCodeRelations preserves imports, calls, and exports', + async run() { + const source = [ + "import { readFile } from 'fs';", + 'export function run(path) {', + ' return readFile(path);', + '}', + 'const local = () => run("x");', + 'module.exports = { run };' + ].join('\n'); + + const rel = buildCodeRelations(source, 'sample.js', { fs: ['fs.js'] }) || {}; + const calls = Array.isArray(rel.calls) ? rel.calls : []; + const imports = Array.isArray(rel.imports) ? rel.imports : []; + const exportsList = Array.isArray(rel.exports) ? rel.exports : []; + + assert.ok(calls.some(([from, to]) => from === 'run' && to === 'readFile')); + assert.ok(imports.includes('fs')); + assert.ok(exportsList.includes('run')); + assert.ok(exportsList.includes('default')); + } + }, + { + name: 'docmeta and cross-file call summaries use stable placeholder param names', + async run() { + const text = `function f({a,b}, x=1, ...rest) {} + f({a:1,b:2}, 2, 3); +`; + const relPath = 'src/sample.js'; + const relations = buildCodeRelations(text, relPath, { dataflow: false, controlFlow: false }); + + const fnStart = text.indexOf('function f'); + const fnEnd = text.indexOf('}', fnStart); + const fnChunk = { start: fnStart, end: fnEnd + 1, name: 'f' }; + const docmeta = extractDocMeta(text, fnChunk, relations); + assert.deepEqual(docmeta.paramNames, ['arg0', 'x', 'rest']); + + const functionChunk = { + name: 'f', + file: relPath, + kind: 'Function', + chunkUid: 'uid-f', + docmeta, + metaV2: { + symbol: { + symbolId: 'sym-f', + chunkUid: 'uid-f', + symbolKey: 'sym:f', + signatureKey: 'sig:f', + kindGroup: 'function', + qualifiedName: 'f' + } + } + }; + + const moduleChunk = { + name: '(module)', + file: relPath, + kind: 'Module', + chunkUid: 'uid-module', + docmeta: {}, + codeRelations: relations + }; + + await applyCrossFileInference({ + rootDir: process.cwd(), + buildRoot: process.cwd(), + chunks: [moduleChunk, functionChunk], + enabled: true, + log: () => {}, + useTooling: false, + enableTypeInference: false, + enableRiskCorrelation: false, + fileRelations: null + }); + + const summary = (moduleChunk.codeRelations?.callSummaries || []).find((entry) => entry?.name === 'f'); + assert.ok(summary, 'call summary for f missing'); + assert.deepEqual(summary.params, ['arg0', 'x', 'rest']); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('javascript contract matrix test passed'); diff --git a/tests/lang/javascript/javascript-paramnames.test.js b/tests/lang/javascript/javascript-paramnames.test.js deleted file mode 100644 index 7bdb04727..000000000 --- a/tests/lang/javascript/javascript-paramnames.test.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildCodeRelations } from '../../../src/lang/javascript/relations.js'; -import { extractDocMeta } from '../../../src/lang/javascript/docmeta.js'; -import { applyCrossFileInference } from '../../../src/index/type-inference-crossfile/pipeline.js'; - -const text = `function f({a,b}, x=1, ...rest) {} - f({a:1,b:2}, 2, 3); -`; -const relPath = 'src/sample.js'; -const relations = buildCodeRelations(text, relPath, { dataflow: false, controlFlow: false }); - -const fnStart = text.indexOf('function f'); -const fnEnd = text.indexOf('}', fnStart); -const fnChunk = { start: fnStart, end: fnEnd + 1, name: 'f' }; -const docmeta = extractDocMeta(text, fnChunk, relations); - -assert.deepEqual( - docmeta.paramNames, - ['arg0', 'x', 'rest'], - 'docmeta.paramNames should use stable placeholders for patterns' -); - -const functionChunk = { - name: 'f', - file: relPath, - kind: 'Function', - chunkUid: 'uid-f', - docmeta, - metaV2: { - symbol: { - symbolId: 'sym-f', - chunkUid: 'uid-f', - symbolKey: 'sym:f', - signatureKey: 'sig:f', - kindGroup: 'function', - qualifiedName: 'f' - } - } -}; - -const moduleChunk = { - name: '(module)', - file: relPath, - kind: 'Module', - chunkUid: 'uid-module', - docmeta: {}, - codeRelations: relations -}; - -await applyCrossFileInference({ - rootDir: process.cwd(), - buildRoot: process.cwd(), - chunks: [moduleChunk, functionChunk], - enabled: true, - log: () => {}, - useTooling: false, - enableTypeInference: false, - enableRiskCorrelation: false, - fileRelations: null -}); - -const summaries = moduleChunk.codeRelations?.callSummaries || []; -const summary = summaries.find((entry) => entry?.name === 'f'); -assert.ok(summary, 'call summary for f missing'); -assert.deepEqual( - summary.params, - ['arg0', 'x', 'rest'], - 'call summary param names should use stable placeholders' -); - -console.log('javascript param names test passed'); diff --git a/tests/lang/javascript/js-chunking.test.js b/tests/lang/javascript/js-chunking.test.js deleted file mode 100644 index ec67f2984..000000000 --- a/tests/lang/javascript/js-chunking.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import { buildJsChunks } from '../../../src/lang/javascript.js'; - -const source = [ - 'export function alpha() {}', - 'class Foo {', - ' method() {}', - ' static bar() {}', - '}', - 'const beta = () => {};', - 'export default function gamma() {}', - 'exports.qux = function() {};' -].join('\n'); - -const chunks = buildJsChunks(source) || []; -const names = new Set(chunks.map((chunk) => chunk.name)); - -const expect = (condition, message) => { - if (!condition) { - console.error(message); - process.exit(1); - } -}; - -expect(names.has('alpha'), 'Missing exported function chunk (alpha).'); -expect(names.has('Foo'), 'Missing class chunk (Foo).'); -expect(names.has('Foo.method'), 'Missing class method chunk (Foo.method).'); -expect(names.has('Foo.bar'), 'Missing class method chunk (Foo.bar).'); -expect(names.has('beta'), 'Missing arrow function chunk (beta).'); -expect(names.has('gamma'), 'Missing default function chunk (gamma).'); -expect(names.has('exports.qux') || names.has('qux'), 'Missing assignment function chunk (exports.qux).'); - -console.log('JS chunking test passed.'); diff --git a/tests/lang/javascript/js-imports.test.js b/tests/lang/javascript/js-imports.test.js deleted file mode 100644 index 66899e388..000000000 --- a/tests/lang/javascript/js-imports.test.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import { collectImports } from '../../../src/lang/javascript.js'; - -const source = [ - "import fs from 'fs';", - "import { join as joinPath } from 'path';", - "export * from 'module-a';", - "export { foo } from 'module-b';", - "const mod = require('module-c');", - "async function load() { return import('module-d'); }" -].join('\n'); - -const imports = collectImports(source); -const sorted = imports.slice().sort(); -const expected = ['fs', 'path', 'module-a', 'module-b', 'module-c', 'module-d'].sort(); - -if (JSON.stringify(sorted) !== JSON.stringify(expected)) { - console.error(`JS imports mismatch: ${JSON.stringify(sorted)} !== ${JSON.stringify(expected)}`); - process.exit(1); -} - -console.log('JS imports test passed.'); diff --git a/tests/lang/javascript/js-relations.test.js b/tests/lang/javascript/js-relations.test.js deleted file mode 100644 index ea1a99f76..000000000 --- a/tests/lang/javascript/js-relations.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import { buildCodeRelations } from '../../../src/lang/javascript.js'; - -const source = [ - "import { readFile } from 'fs';", - 'export function run(path) {', - ' return readFile(path);', - '}', - 'const local = () => run("x");', - 'module.exports = { run };' -].join('\n'); - -const rel = buildCodeRelations(source, 'sample.js', { fs: ['fs.js'] }) || {}; -const calls = Array.isArray(rel.calls) ? rel.calls : []; -const imports = Array.isArray(rel.imports) ? rel.imports : []; -const exports = Array.isArray(rel.exports) ? rel.exports : []; - -const hasCall = calls.some(([from, to]) => from === 'run' && to === 'readFile'); -if (!hasCall) { - console.error(`Missing call relation from run -> readFile: ${JSON.stringify(calls)}`); - process.exit(1); -} - -if (!imports.includes('fs')) { - console.error(`Missing import for fs: ${JSON.stringify(imports)}`); - process.exit(1); -} - -if (!exports.includes('run') || !exports.includes('default')) { - console.error(`Missing exports for run/default: ${JSON.stringify(exports)}`); - process.exit(1); -} - -console.log('JS relations test passed.'); diff --git a/tests/lang/kotlin/kotlin-perf-guard.test.js b/tests/lang/kotlin/perf-guard.test.js similarity index 100% rename from tests/lang/kotlin/kotlin-perf-guard.test.js rename to tests/lang/kotlin/perf-guard.test.js diff --git a/tests/lang/lua/lua-modules.test.js b/tests/lang/lua/modules.test.js similarity index 100% rename from tests/lang/lua/lua-modules.test.js rename to tests/lang/lua/modules.test.js diff --git a/tests/lang/matrix/README.md b/tests/lang/matrix/README.md index b7035c073..6226c3974 100644 --- a/tests/lang/matrix/README.md +++ b/tests/lang/matrix/README.md @@ -4,7 +4,7 @@ This directory contains machine-readable USR registry and matrix artifacts refer - `docs/specs/unified-syntax-representation.md` section 23 - `docs/specs/usr-core-artifact-schema-catalog.md` -- `TES_LAYN_ROADMAP.md` Phase A +- `docs/roadmap.md` USR rollout status Baseline files are generated by: diff --git a/tests/lang/matrix/usr-artifact-expectations.json b/tests/lang/matrix/usr-artifact-expectations.json new file mode 100644 index 000000000..bfef6c226 --- /dev/null +++ b/tests/lang/matrix/usr-artifact-expectations.json @@ -0,0 +1,6502 @@ +{ + "schemaVersion": "usr-registry-1.0.0", + "registryId": "usr-artifact-expectations", + "rows": [ + { + "id": "framework:angular:framework-binding", + "profileType": "framework", + "profileId": "angular", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:angular:framework-hydration", + "profileType": "framework", + "profileId": "angular", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:angular:framework-routing", + "profileType": "framework", + "profileId": "angular", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:angular:framework-segmentation", + "profileType": "framework", + "profileId": "angular", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:astro:framework-binding", + "profileType": "framework", + "profileId": "astro", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:astro:framework-hydration", + "profileType": "framework", + "profileId": "astro", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:astro:framework-routing", + "profileType": "framework", + "profileId": "astro", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:astro:framework-segmentation", + "profileType": "framework", + "profileId": "astro", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:next:framework-binding", + "profileType": "framework", + "profileId": "next", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:next:framework-hydration", + "profileType": "framework", + "profileId": "next", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:next:framework-routing", + "profileType": "framework", + "profileId": "next", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:next:framework-segmentation", + "profileType": "framework", + "profileId": "next", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:nuxt:framework-binding", + "profileType": "framework", + "profileId": "nuxt", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:nuxt:framework-hydration", + "profileType": "framework", + "profileId": "nuxt", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:nuxt:framework-routing", + "profileType": "framework", + "profileId": "nuxt", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:nuxt:framework-segmentation", + "profileType": "framework", + "profileId": "nuxt", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:react:framework-binding", + "profileType": "framework", + "profileId": "react", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:react:framework-hydration", + "profileType": "framework", + "profileId": "react", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:react:framework-routing", + "profileType": "framework", + "profileId": "react", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:react:framework-segmentation", + "profileType": "framework", + "profileId": "react", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:svelte:framework-binding", + "profileType": "framework", + "profileId": "svelte", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:svelte:framework-hydration", + "profileType": "framework", + "profileId": "svelte", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:svelte:framework-routing", + "profileType": "framework", + "profileId": "svelte", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C4" + ], + "blocking": false, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:svelte:framework-segmentation", + "profileType": "framework", + "profileId": "svelte", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:sveltekit:framework-binding", + "profileType": "framework", + "profileId": "sveltekit", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:sveltekit:framework-hydration", + "profileType": "framework", + "profileId": "sveltekit", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:sveltekit:framework-routing", + "profileType": "framework", + "profileId": "sveltekit", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:sveltekit:framework-segmentation", + "profileType": "framework", + "profileId": "sveltekit", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "framework:vue:framework-binding", + "profileType": "framework", + "profileId": "vue", + "capability": "framework-binding", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Framework binding edges should materialize through graph relations." + }, + { + "id": "framework:vue:framework-hydration", + "profileType": "framework", + "profileId": "vue", + "capability": "framework-hydration", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Hydration boundary expectations should materialize through graph relations." + }, + { + "id": "framework:vue:framework-routing", + "profileType": "framework", + "profileId": "vue", + "capability": "framework-routing", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Route-bearing framework profiles should expose route/file linkage." + }, + { + "id": "framework:vue:framework-segmentation", + "profileType": "framework", + "profileId": "vue", + "capability": "framework-segmentation", + "artifactId": "vfs_manifest", + "expectation": "required", + "requiredConformance": [ + "C4" + ], + "blocking": true, + "notes": "Virtual document and segment routing expectations for framework overlays." + }, + { + "id": "language:clike:ast", + "profileType": "language", + "profileId": "clike", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for clike." + }, + { + "id": "language:clike:controlFlow", + "profileType": "language", + "profileId": "clike", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for clike." + }, + { + "id": "language:clike:dataFlow", + "profileType": "language", + "profileId": "clike", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for clike." + }, + { + "id": "language:clike:docmeta", + "profileType": "language", + "profileId": "clike", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for clike." + }, + { + "id": "language:clike:graphRelations", + "profileType": "language", + "profileId": "clike", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for clike." + }, + { + "id": "language:clike:imports", + "profileType": "language", + "profileId": "clike", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for clike." + }, + { + "id": "language:clike:relations", + "profileType": "language", + "profileId": "clike", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for clike." + }, + { + "id": "language:clike:riskInterprocedural", + "profileType": "language", + "profileId": "clike", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for clike." + }, + { + "id": "language:clike:riskLocal", + "profileType": "language", + "profileId": "clike", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for clike." + }, + { + "id": "language:clike:symbolGraph", + "profileType": "language", + "profileId": "clike", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for clike." + }, + { + "id": "language:cmake:ast", + "profileType": "language", + "profileId": "cmake", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for cmake." + }, + { + "id": "language:cmake:controlFlow", + "profileType": "language", + "profileId": "cmake", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial controlFlow coverage for cmake." + }, + { + "id": "language:cmake:dataFlow", + "profileType": "language", + "profileId": "cmake", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial dataFlow coverage for cmake." + }, + { + "id": "language:cmake:docmeta", + "profileType": "language", + "profileId": "cmake", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for cmake." + }, + { + "id": "language:cmake:graphRelations", + "profileType": "language", + "profileId": "cmake", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for cmake." + }, + { + "id": "language:cmake:imports", + "profileType": "language", + "profileId": "cmake", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported imports coverage for cmake." + }, + { + "id": "language:cmake:relations", + "profileType": "language", + "profileId": "cmake", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for cmake." + }, + { + "id": "language:cmake:riskInterprocedural", + "profileType": "language", + "profileId": "cmake", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for cmake." + }, + { + "id": "language:cmake:riskLocal", + "profileType": "language", + "profileId": "cmake", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for cmake." + }, + { + "id": "language:cmake:symbolGraph", + "profileType": "language", + "profileId": "cmake", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for cmake." + }, + { + "id": "language:csharp:ast", + "profileType": "language", + "profileId": "csharp", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for csharp." + }, + { + "id": "language:csharp:controlFlow", + "profileType": "language", + "profileId": "csharp", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for csharp." + }, + { + "id": "language:csharp:dataFlow", + "profileType": "language", + "profileId": "csharp", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for csharp." + }, + { + "id": "language:csharp:docmeta", + "profileType": "language", + "profileId": "csharp", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for csharp." + }, + { + "id": "language:csharp:graphRelations", + "profileType": "language", + "profileId": "csharp", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for csharp." + }, + { + "id": "language:csharp:imports", + "profileType": "language", + "profileId": "csharp", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for csharp." + }, + { + "id": "language:csharp:relations", + "profileType": "language", + "profileId": "csharp", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for csharp." + }, + { + "id": "language:csharp:riskInterprocedural", + "profileType": "language", + "profileId": "csharp", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for csharp." + }, + { + "id": "language:csharp:riskLocal", + "profileType": "language", + "profileId": "csharp", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for csharp." + }, + { + "id": "language:csharp:symbolGraph", + "profileType": "language", + "profileId": "csharp", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for csharp." + }, + { + "id": "language:css:ast", + "profileType": "language", + "profileId": "css", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for css." + }, + { + "id": "language:css:controlFlow", + "profileType": "language", + "profileId": "css", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for css." + }, + { + "id": "language:css:dataFlow", + "profileType": "language", + "profileId": "css", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for css." + }, + { + "id": "language:css:docmeta", + "profileType": "language", + "profileId": "css", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for css." + }, + { + "id": "language:css:graphRelations", + "profileType": "language", + "profileId": "css", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for css." + }, + { + "id": "language:css:imports", + "profileType": "language", + "profileId": "css", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported imports coverage for css." + }, + { + "id": "language:css:relations", + "profileType": "language", + "profileId": "css", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for css." + }, + { + "id": "language:css:riskInterprocedural", + "profileType": "language", + "profileId": "css", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for css." + }, + { + "id": "language:css:riskLocal", + "profileType": "language", + "profileId": "css", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for css." + }, + { + "id": "language:css:symbolGraph", + "profileType": "language", + "profileId": "css", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for css." + }, + { + "id": "language:dart:ast", + "profileType": "language", + "profileId": "dart", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for dart." + }, + { + "id": "language:dart:controlFlow", + "profileType": "language", + "profileId": "dart", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for dart." + }, + { + "id": "language:dart:dataFlow", + "profileType": "language", + "profileId": "dart", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for dart." + }, + { + "id": "language:dart:docmeta", + "profileType": "language", + "profileId": "dart", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for dart." + }, + { + "id": "language:dart:graphRelations", + "profileType": "language", + "profileId": "dart", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for dart." + }, + { + "id": "language:dart:imports", + "profileType": "language", + "profileId": "dart", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for dart." + }, + { + "id": "language:dart:relations", + "profileType": "language", + "profileId": "dart", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for dart." + }, + { + "id": "language:dart:riskInterprocedural", + "profileType": "language", + "profileId": "dart", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for dart." + }, + { + "id": "language:dart:riskLocal", + "profileType": "language", + "profileId": "dart", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for dart." + }, + { + "id": "language:dart:symbolGraph", + "profileType": "language", + "profileId": "dart", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for dart." + }, + { + "id": "language:dockerfile:ast", + "profileType": "language", + "profileId": "dockerfile", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for dockerfile." + }, + { + "id": "language:dockerfile:controlFlow", + "profileType": "language", + "profileId": "dockerfile", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial controlFlow coverage for dockerfile." + }, + { + "id": "language:dockerfile:dataFlow", + "profileType": "language", + "profileId": "dockerfile", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial dataFlow coverage for dockerfile." + }, + { + "id": "language:dockerfile:docmeta", + "profileType": "language", + "profileId": "dockerfile", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for dockerfile." + }, + { + "id": "language:dockerfile:graphRelations", + "profileType": "language", + "profileId": "dockerfile", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for dockerfile." + }, + { + "id": "language:dockerfile:imports", + "profileType": "language", + "profileId": "dockerfile", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported imports coverage for dockerfile." + }, + { + "id": "language:dockerfile:relations", + "profileType": "language", + "profileId": "dockerfile", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for dockerfile." + }, + { + "id": "language:dockerfile:riskInterprocedural", + "profileType": "language", + "profileId": "dockerfile", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for dockerfile." + }, + { + "id": "language:dockerfile:riskLocal", + "profileType": "language", + "profileId": "dockerfile", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for dockerfile." + }, + { + "id": "language:dockerfile:symbolGraph", + "profileType": "language", + "profileId": "dockerfile", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for dockerfile." + }, + { + "id": "language:go:ast", + "profileType": "language", + "profileId": "go", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for go." + }, + { + "id": "language:go:controlFlow", + "profileType": "language", + "profileId": "go", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for go." + }, + { + "id": "language:go:dataFlow", + "profileType": "language", + "profileId": "go", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for go." + }, + { + "id": "language:go:docmeta", + "profileType": "language", + "profileId": "go", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for go." + }, + { + "id": "language:go:graphRelations", + "profileType": "language", + "profileId": "go", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for go." + }, + { + "id": "language:go:imports", + "profileType": "language", + "profileId": "go", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for go." + }, + { + "id": "language:go:relations", + "profileType": "language", + "profileId": "go", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for go." + }, + { + "id": "language:go:riskInterprocedural", + "profileType": "language", + "profileId": "go", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for go." + }, + { + "id": "language:go:riskLocal", + "profileType": "language", + "profileId": "go", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for go." + }, + { + "id": "language:go:symbolGraph", + "profileType": "language", + "profileId": "go", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for go." + }, + { + "id": "language:graphql:ast", + "profileType": "language", + "profileId": "graphql", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported ast coverage for graphql." + }, + { + "id": "language:graphql:controlFlow", + "profileType": "language", + "profileId": "graphql", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for graphql." + }, + { + "id": "language:graphql:dataFlow", + "profileType": "language", + "profileId": "graphql", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for graphql." + }, + { + "id": "language:graphql:docmeta", + "profileType": "language", + "profileId": "graphql", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for graphql." + }, + { + "id": "language:graphql:graphRelations", + "profileType": "language", + "profileId": "graphql", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "supported graphRelations coverage for graphql." + }, + { + "id": "language:graphql:imports", + "profileType": "language", + "profileId": "graphql", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for graphql." + }, + { + "id": "language:graphql:relations", + "profileType": "language", + "profileId": "graphql", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "supported relations coverage for graphql." + }, + { + "id": "language:graphql:riskInterprocedural", + "profileType": "language", + "profileId": "graphql", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for graphql." + }, + { + "id": "language:graphql:riskLocal", + "profileType": "language", + "profileId": "graphql", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for graphql." + }, + { + "id": "language:graphql:symbolGraph", + "profileType": "language", + "profileId": "graphql", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for graphql." + }, + { + "id": "language:groovy:ast", + "profileType": "language", + "profileId": "groovy", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for groovy." + }, + { + "id": "language:groovy:controlFlow", + "profileType": "language", + "profileId": "groovy", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for groovy." + }, + { + "id": "language:groovy:dataFlow", + "profileType": "language", + "profileId": "groovy", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for groovy." + }, + { + "id": "language:groovy:docmeta", + "profileType": "language", + "profileId": "groovy", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for groovy." + }, + { + "id": "language:groovy:graphRelations", + "profileType": "language", + "profileId": "groovy", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for groovy." + }, + { + "id": "language:groovy:imports", + "profileType": "language", + "profileId": "groovy", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for groovy." + }, + { + "id": "language:groovy:relations", + "profileType": "language", + "profileId": "groovy", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for groovy." + }, + { + "id": "language:groovy:riskInterprocedural", + "profileType": "language", + "profileId": "groovy", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for groovy." + }, + { + "id": "language:groovy:riskLocal", + "profileType": "language", + "profileId": "groovy", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for groovy." + }, + { + "id": "language:groovy:symbolGraph", + "profileType": "language", + "profileId": "groovy", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for groovy." + }, + { + "id": "language:handlebars:ast", + "profileType": "language", + "profileId": "handlebars", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for handlebars." + }, + { + "id": "language:handlebars:controlFlow", + "profileType": "language", + "profileId": "handlebars", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for handlebars." + }, + { + "id": "language:handlebars:dataFlow", + "profileType": "language", + "profileId": "handlebars", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for handlebars." + }, + { + "id": "language:handlebars:docmeta", + "profileType": "language", + "profileId": "handlebars", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for handlebars." + }, + { + "id": "language:handlebars:graphRelations", + "profileType": "language", + "profileId": "handlebars", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for handlebars." + }, + { + "id": "language:handlebars:imports", + "profileType": "language", + "profileId": "handlebars", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial imports coverage for handlebars." + }, + { + "id": "language:handlebars:relations", + "profileType": "language", + "profileId": "handlebars", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for handlebars." + }, + { + "id": "language:handlebars:riskInterprocedural", + "profileType": "language", + "profileId": "handlebars", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for handlebars." + }, + { + "id": "language:handlebars:riskLocal", + "profileType": "language", + "profileId": "handlebars", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for handlebars." + }, + { + "id": "language:handlebars:symbolGraph", + "profileType": "language", + "profileId": "handlebars", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for handlebars." + }, + { + "id": "language:html:ast", + "profileType": "language", + "profileId": "html", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for html." + }, + { + "id": "language:html:controlFlow", + "profileType": "language", + "profileId": "html", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for html." + }, + { + "id": "language:html:dataFlow", + "profileType": "language", + "profileId": "html", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for html." + }, + { + "id": "language:html:docmeta", + "profileType": "language", + "profileId": "html", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for html." + }, + { + "id": "language:html:graphRelations", + "profileType": "language", + "profileId": "html", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for html." + }, + { + "id": "language:html:imports", + "profileType": "language", + "profileId": "html", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial imports coverage for html." + }, + { + "id": "language:html:relations", + "profileType": "language", + "profileId": "html", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for html." + }, + { + "id": "language:html:riskInterprocedural", + "profileType": "language", + "profileId": "html", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for html." + }, + { + "id": "language:html:riskLocal", + "profileType": "language", + "profileId": "html", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for html." + }, + { + "id": "language:html:symbolGraph", + "profileType": "language", + "profileId": "html", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for html." + }, + { + "id": "language:ini:ast", + "profileType": "language", + "profileId": "ini", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for ini." + }, + { + "id": "language:ini:controlFlow", + "profileType": "language", + "profileId": "ini", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for ini." + }, + { + "id": "language:ini:dataFlow", + "profileType": "language", + "profileId": "ini", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for ini." + }, + { + "id": "language:ini:docmeta", + "profileType": "language", + "profileId": "ini", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for ini." + }, + { + "id": "language:ini:graphRelations", + "profileType": "language", + "profileId": "ini", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for ini." + }, + { + "id": "language:ini:imports", + "profileType": "language", + "profileId": "ini", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for ini." + }, + { + "id": "language:ini:relations", + "profileType": "language", + "profileId": "ini", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for ini." + }, + { + "id": "language:ini:riskInterprocedural", + "profileType": "language", + "profileId": "ini", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for ini." + }, + { + "id": "language:ini:riskLocal", + "profileType": "language", + "profileId": "ini", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for ini." + }, + { + "id": "language:ini:symbolGraph", + "profileType": "language", + "profileId": "ini", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for ini." + }, + { + "id": "language:java:ast", + "profileType": "language", + "profileId": "java", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for java." + }, + { + "id": "language:java:controlFlow", + "profileType": "language", + "profileId": "java", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for java." + }, + { + "id": "language:java:dataFlow", + "profileType": "language", + "profileId": "java", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for java." + }, + { + "id": "language:java:docmeta", + "profileType": "language", + "profileId": "java", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for java." + }, + { + "id": "language:java:graphRelations", + "profileType": "language", + "profileId": "java", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for java." + }, + { + "id": "language:java:imports", + "profileType": "language", + "profileId": "java", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for java." + }, + { + "id": "language:java:relations", + "profileType": "language", + "profileId": "java", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for java." + }, + { + "id": "language:java:riskInterprocedural", + "profileType": "language", + "profileId": "java", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for java." + }, + { + "id": "language:java:riskLocal", + "profileType": "language", + "profileId": "java", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for java." + }, + { + "id": "language:java:symbolGraph", + "profileType": "language", + "profileId": "java", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for java." + }, + { + "id": "language:javascript:ast", + "profileType": "language", + "profileId": "javascript", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported ast coverage for javascript." + }, + { + "id": "language:javascript:controlFlow", + "profileType": "language", + "profileId": "javascript", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported controlFlow coverage for javascript." + }, + { + "id": "language:javascript:dataFlow", + "profileType": "language", + "profileId": "javascript", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported dataFlow coverage for javascript." + }, + { + "id": "language:javascript:docmeta", + "profileType": "language", + "profileId": "javascript", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for javascript." + }, + { + "id": "language:javascript:graphRelations", + "profileType": "language", + "profileId": "javascript", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported graphRelations coverage for javascript." + }, + { + "id": "language:javascript:imports", + "profileType": "language", + "profileId": "javascript", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported imports coverage for javascript." + }, + { + "id": "language:javascript:relations", + "profileType": "language", + "profileId": "javascript", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported relations coverage for javascript." + }, + { + "id": "language:javascript:riskInterprocedural", + "profileType": "language", + "profileId": "javascript", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for javascript." + }, + { + "id": "language:javascript:riskLocal", + "profileType": "language", + "profileId": "javascript", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported riskLocal coverage for javascript." + }, + { + "id": "language:javascript:symbolGraph", + "profileType": "language", + "profileId": "javascript", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for javascript." + }, + { + "id": "language:jinja:ast", + "profileType": "language", + "profileId": "jinja", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for jinja." + }, + { + "id": "language:jinja:controlFlow", + "profileType": "language", + "profileId": "jinja", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for jinja." + }, + { + "id": "language:jinja:dataFlow", + "profileType": "language", + "profileId": "jinja", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for jinja." + }, + { + "id": "language:jinja:docmeta", + "profileType": "language", + "profileId": "jinja", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for jinja." + }, + { + "id": "language:jinja:graphRelations", + "profileType": "language", + "profileId": "jinja", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for jinja." + }, + { + "id": "language:jinja:imports", + "profileType": "language", + "profileId": "jinja", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial imports coverage for jinja." + }, + { + "id": "language:jinja:relations", + "profileType": "language", + "profileId": "jinja", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for jinja." + }, + { + "id": "language:jinja:riskInterprocedural", + "profileType": "language", + "profileId": "jinja", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for jinja." + }, + { + "id": "language:jinja:riskLocal", + "profileType": "language", + "profileId": "jinja", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for jinja." + }, + { + "id": "language:jinja:symbolGraph", + "profileType": "language", + "profileId": "jinja", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for jinja." + }, + { + "id": "language:json:ast", + "profileType": "language", + "profileId": "json", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for json." + }, + { + "id": "language:json:controlFlow", + "profileType": "language", + "profileId": "json", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for json." + }, + { + "id": "language:json:dataFlow", + "profileType": "language", + "profileId": "json", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for json." + }, + { + "id": "language:json:docmeta", + "profileType": "language", + "profileId": "json", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for json." + }, + { + "id": "language:json:graphRelations", + "profileType": "language", + "profileId": "json", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for json." + }, + { + "id": "language:json:imports", + "profileType": "language", + "profileId": "json", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for json." + }, + { + "id": "language:json:relations", + "profileType": "language", + "profileId": "json", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for json." + }, + { + "id": "language:json:riskInterprocedural", + "profileType": "language", + "profileId": "json", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for json." + }, + { + "id": "language:json:riskLocal", + "profileType": "language", + "profileId": "json", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for json." + }, + { + "id": "language:json:symbolGraph", + "profileType": "language", + "profileId": "json", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for json." + }, + { + "id": "language:julia:ast", + "profileType": "language", + "profileId": "julia", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for julia." + }, + { + "id": "language:julia:controlFlow", + "profileType": "language", + "profileId": "julia", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for julia." + }, + { + "id": "language:julia:dataFlow", + "profileType": "language", + "profileId": "julia", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for julia." + }, + { + "id": "language:julia:docmeta", + "profileType": "language", + "profileId": "julia", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for julia." + }, + { + "id": "language:julia:graphRelations", + "profileType": "language", + "profileId": "julia", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for julia." + }, + { + "id": "language:julia:imports", + "profileType": "language", + "profileId": "julia", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for julia." + }, + { + "id": "language:julia:relations", + "profileType": "language", + "profileId": "julia", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for julia." + }, + { + "id": "language:julia:riskInterprocedural", + "profileType": "language", + "profileId": "julia", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for julia." + }, + { + "id": "language:julia:riskLocal", + "profileType": "language", + "profileId": "julia", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for julia." + }, + { + "id": "language:julia:symbolGraph", + "profileType": "language", + "profileId": "julia", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for julia." + }, + { + "id": "language:kotlin:ast", + "profileType": "language", + "profileId": "kotlin", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for kotlin." + }, + { + "id": "language:kotlin:controlFlow", + "profileType": "language", + "profileId": "kotlin", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for kotlin." + }, + { + "id": "language:kotlin:dataFlow", + "profileType": "language", + "profileId": "kotlin", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for kotlin." + }, + { + "id": "language:kotlin:docmeta", + "profileType": "language", + "profileId": "kotlin", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for kotlin." + }, + { + "id": "language:kotlin:graphRelations", + "profileType": "language", + "profileId": "kotlin", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for kotlin." + }, + { + "id": "language:kotlin:imports", + "profileType": "language", + "profileId": "kotlin", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for kotlin." + }, + { + "id": "language:kotlin:relations", + "profileType": "language", + "profileId": "kotlin", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for kotlin." + }, + { + "id": "language:kotlin:riskInterprocedural", + "profileType": "language", + "profileId": "kotlin", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for kotlin." + }, + { + "id": "language:kotlin:riskLocal", + "profileType": "language", + "profileId": "kotlin", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for kotlin." + }, + { + "id": "language:kotlin:symbolGraph", + "profileType": "language", + "profileId": "kotlin", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for kotlin." + }, + { + "id": "language:lua:ast", + "profileType": "language", + "profileId": "lua", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for lua." + }, + { + "id": "language:lua:controlFlow", + "profileType": "language", + "profileId": "lua", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for lua." + }, + { + "id": "language:lua:dataFlow", + "profileType": "language", + "profileId": "lua", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for lua." + }, + { + "id": "language:lua:docmeta", + "profileType": "language", + "profileId": "lua", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for lua." + }, + { + "id": "language:lua:graphRelations", + "profileType": "language", + "profileId": "lua", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for lua." + }, + { + "id": "language:lua:imports", + "profileType": "language", + "profileId": "lua", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for lua." + }, + { + "id": "language:lua:relations", + "profileType": "language", + "profileId": "lua", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for lua." + }, + { + "id": "language:lua:riskInterprocedural", + "profileType": "language", + "profileId": "lua", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for lua." + }, + { + "id": "language:lua:riskLocal", + "profileType": "language", + "profileId": "lua", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for lua." + }, + { + "id": "language:lua:symbolGraph", + "profileType": "language", + "profileId": "lua", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for lua." + }, + { + "id": "language:makefile:ast", + "profileType": "language", + "profileId": "makefile", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for makefile." + }, + { + "id": "language:makefile:controlFlow", + "profileType": "language", + "profileId": "makefile", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial controlFlow coverage for makefile." + }, + { + "id": "language:makefile:dataFlow", + "profileType": "language", + "profileId": "makefile", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial dataFlow coverage for makefile." + }, + { + "id": "language:makefile:docmeta", + "profileType": "language", + "profileId": "makefile", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for makefile." + }, + { + "id": "language:makefile:graphRelations", + "profileType": "language", + "profileId": "makefile", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for makefile." + }, + { + "id": "language:makefile:imports", + "profileType": "language", + "profileId": "makefile", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported imports coverage for makefile." + }, + { + "id": "language:makefile:relations", + "profileType": "language", + "profileId": "makefile", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for makefile." + }, + { + "id": "language:makefile:riskInterprocedural", + "profileType": "language", + "profileId": "makefile", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for makefile." + }, + { + "id": "language:makefile:riskLocal", + "profileType": "language", + "profileId": "makefile", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for makefile." + }, + { + "id": "language:makefile:symbolGraph", + "profileType": "language", + "profileId": "makefile", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for makefile." + }, + { + "id": "language:mustache:ast", + "profileType": "language", + "profileId": "mustache", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for mustache." + }, + { + "id": "language:mustache:controlFlow", + "profileType": "language", + "profileId": "mustache", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for mustache." + }, + { + "id": "language:mustache:dataFlow", + "profileType": "language", + "profileId": "mustache", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for mustache." + }, + { + "id": "language:mustache:docmeta", + "profileType": "language", + "profileId": "mustache", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for mustache." + }, + { + "id": "language:mustache:graphRelations", + "profileType": "language", + "profileId": "mustache", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for mustache." + }, + { + "id": "language:mustache:imports", + "profileType": "language", + "profileId": "mustache", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial imports coverage for mustache." + }, + { + "id": "language:mustache:relations", + "profileType": "language", + "profileId": "mustache", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for mustache." + }, + { + "id": "language:mustache:riskInterprocedural", + "profileType": "language", + "profileId": "mustache", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for mustache." + }, + { + "id": "language:mustache:riskLocal", + "profileType": "language", + "profileId": "mustache", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for mustache." + }, + { + "id": "language:mustache:symbolGraph", + "profileType": "language", + "profileId": "mustache", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for mustache." + }, + { + "id": "language:nix:ast", + "profileType": "language", + "profileId": "nix", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for nix." + }, + { + "id": "language:nix:controlFlow", + "profileType": "language", + "profileId": "nix", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial controlFlow coverage for nix." + }, + { + "id": "language:nix:dataFlow", + "profileType": "language", + "profileId": "nix", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial dataFlow coverage for nix." + }, + { + "id": "language:nix:docmeta", + "profileType": "language", + "profileId": "nix", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for nix." + }, + { + "id": "language:nix:graphRelations", + "profileType": "language", + "profileId": "nix", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for nix." + }, + { + "id": "language:nix:imports", + "profileType": "language", + "profileId": "nix", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported imports coverage for nix." + }, + { + "id": "language:nix:relations", + "profileType": "language", + "profileId": "nix", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for nix." + }, + { + "id": "language:nix:riskInterprocedural", + "profileType": "language", + "profileId": "nix", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for nix." + }, + { + "id": "language:nix:riskLocal", + "profileType": "language", + "profileId": "nix", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for nix." + }, + { + "id": "language:nix:symbolGraph", + "profileType": "language", + "profileId": "nix", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for nix." + }, + { + "id": "language:perl:ast", + "profileType": "language", + "profileId": "perl", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for perl." + }, + { + "id": "language:perl:controlFlow", + "profileType": "language", + "profileId": "perl", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for perl." + }, + { + "id": "language:perl:dataFlow", + "profileType": "language", + "profileId": "perl", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for perl." + }, + { + "id": "language:perl:docmeta", + "profileType": "language", + "profileId": "perl", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for perl." + }, + { + "id": "language:perl:graphRelations", + "profileType": "language", + "profileId": "perl", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for perl." + }, + { + "id": "language:perl:imports", + "profileType": "language", + "profileId": "perl", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for perl." + }, + { + "id": "language:perl:relations", + "profileType": "language", + "profileId": "perl", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for perl." + }, + { + "id": "language:perl:riskInterprocedural", + "profileType": "language", + "profileId": "perl", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for perl." + }, + { + "id": "language:perl:riskLocal", + "profileType": "language", + "profileId": "perl", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for perl." + }, + { + "id": "language:perl:symbolGraph", + "profileType": "language", + "profileId": "perl", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for perl." + }, + { + "id": "language:php:ast", + "profileType": "language", + "profileId": "php", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for php." + }, + { + "id": "language:php:controlFlow", + "profileType": "language", + "profileId": "php", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for php." + }, + { + "id": "language:php:dataFlow", + "profileType": "language", + "profileId": "php", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for php." + }, + { + "id": "language:php:docmeta", + "profileType": "language", + "profileId": "php", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for php." + }, + { + "id": "language:php:graphRelations", + "profileType": "language", + "profileId": "php", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for php." + }, + { + "id": "language:php:imports", + "profileType": "language", + "profileId": "php", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for php." + }, + { + "id": "language:php:relations", + "profileType": "language", + "profileId": "php", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for php." + }, + { + "id": "language:php:riskInterprocedural", + "profileType": "language", + "profileId": "php", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for php." + }, + { + "id": "language:php:riskLocal", + "profileType": "language", + "profileId": "php", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for php." + }, + { + "id": "language:php:symbolGraph", + "profileType": "language", + "profileId": "php", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for php." + }, + { + "id": "language:proto:ast", + "profileType": "language", + "profileId": "proto", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported ast coverage for proto." + }, + { + "id": "language:proto:controlFlow", + "profileType": "language", + "profileId": "proto", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for proto." + }, + { + "id": "language:proto:dataFlow", + "profileType": "language", + "profileId": "proto", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for proto." + }, + { + "id": "language:proto:docmeta", + "profileType": "language", + "profileId": "proto", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for proto." + }, + { + "id": "language:proto:graphRelations", + "profileType": "language", + "profileId": "proto", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "supported graphRelations coverage for proto." + }, + { + "id": "language:proto:imports", + "profileType": "language", + "profileId": "proto", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for proto." + }, + { + "id": "language:proto:relations", + "profileType": "language", + "profileId": "proto", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "supported relations coverage for proto." + }, + { + "id": "language:proto:riskInterprocedural", + "profileType": "language", + "profileId": "proto", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for proto." + }, + { + "id": "language:proto:riskLocal", + "profileType": "language", + "profileId": "proto", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for proto." + }, + { + "id": "language:proto:symbolGraph", + "profileType": "language", + "profileId": "proto", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for proto." + }, + { + "id": "language:python:ast", + "profileType": "language", + "profileId": "python", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for python." + }, + { + "id": "language:python:controlFlow", + "profileType": "language", + "profileId": "python", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for python." + }, + { + "id": "language:python:dataFlow", + "profileType": "language", + "profileId": "python", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for python." + }, + { + "id": "language:python:docmeta", + "profileType": "language", + "profileId": "python", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for python." + }, + { + "id": "language:python:graphRelations", + "profileType": "language", + "profileId": "python", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for python." + }, + { + "id": "language:python:imports", + "profileType": "language", + "profileId": "python", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for python." + }, + { + "id": "language:python:relations", + "profileType": "language", + "profileId": "python", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for python." + }, + { + "id": "language:python:riskInterprocedural", + "profileType": "language", + "profileId": "python", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for python." + }, + { + "id": "language:python:riskLocal", + "profileType": "language", + "profileId": "python", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for python." + }, + { + "id": "language:python:symbolGraph", + "profileType": "language", + "profileId": "python", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for python." + }, + { + "id": "language:r:ast", + "profileType": "language", + "profileId": "r", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for r." + }, + { + "id": "language:r:controlFlow", + "profileType": "language", + "profileId": "r", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for r." + }, + { + "id": "language:r:dataFlow", + "profileType": "language", + "profileId": "r", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for r." + }, + { + "id": "language:r:docmeta", + "profileType": "language", + "profileId": "r", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for r." + }, + { + "id": "language:r:graphRelations", + "profileType": "language", + "profileId": "r", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for r." + }, + { + "id": "language:r:imports", + "profileType": "language", + "profileId": "r", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for r." + }, + { + "id": "language:r:relations", + "profileType": "language", + "profileId": "r", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for r." + }, + { + "id": "language:r:riskInterprocedural", + "profileType": "language", + "profileId": "r", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for r." + }, + { + "id": "language:r:riskLocal", + "profileType": "language", + "profileId": "r", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for r." + }, + { + "id": "language:r:symbolGraph", + "profileType": "language", + "profileId": "r", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for r." + }, + { + "id": "language:razor:ast", + "profileType": "language", + "profileId": "razor", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial ast coverage for razor." + }, + { + "id": "language:razor:controlFlow", + "profileType": "language", + "profileId": "razor", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for razor." + }, + { + "id": "language:razor:dataFlow", + "profileType": "language", + "profileId": "razor", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for razor." + }, + { + "id": "language:razor:docmeta", + "profileType": "language", + "profileId": "razor", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for razor." + }, + { + "id": "language:razor:graphRelations", + "profileType": "language", + "profileId": "razor", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial graphRelations coverage for razor." + }, + { + "id": "language:razor:imports", + "profileType": "language", + "profileId": "razor", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial imports coverage for razor." + }, + { + "id": "language:razor:relations", + "profileType": "language", + "profileId": "razor", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial relations coverage for razor." + }, + { + "id": "language:razor:riskInterprocedural", + "profileType": "language", + "profileId": "razor", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for razor." + }, + { + "id": "language:razor:riskLocal", + "profileType": "language", + "profileId": "razor", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": false, + "notes": "partial riskLocal coverage for razor." + }, + { + "id": "language:razor:symbolGraph", + "profileType": "language", + "profileId": "razor", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C4" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for razor." + }, + { + "id": "language:ruby:ast", + "profileType": "language", + "profileId": "ruby", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for ruby." + }, + { + "id": "language:ruby:controlFlow", + "profileType": "language", + "profileId": "ruby", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for ruby." + }, + { + "id": "language:ruby:dataFlow", + "profileType": "language", + "profileId": "ruby", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for ruby." + }, + { + "id": "language:ruby:docmeta", + "profileType": "language", + "profileId": "ruby", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for ruby." + }, + { + "id": "language:ruby:graphRelations", + "profileType": "language", + "profileId": "ruby", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for ruby." + }, + { + "id": "language:ruby:imports", + "profileType": "language", + "profileId": "ruby", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for ruby." + }, + { + "id": "language:ruby:relations", + "profileType": "language", + "profileId": "ruby", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for ruby." + }, + { + "id": "language:ruby:riskInterprocedural", + "profileType": "language", + "profileId": "ruby", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for ruby." + }, + { + "id": "language:ruby:riskLocal", + "profileType": "language", + "profileId": "ruby", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for ruby." + }, + { + "id": "language:ruby:symbolGraph", + "profileType": "language", + "profileId": "ruby", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for ruby." + }, + { + "id": "language:rust:ast", + "profileType": "language", + "profileId": "rust", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for rust." + }, + { + "id": "language:rust:controlFlow", + "profileType": "language", + "profileId": "rust", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for rust." + }, + { + "id": "language:rust:dataFlow", + "profileType": "language", + "profileId": "rust", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for rust." + }, + { + "id": "language:rust:docmeta", + "profileType": "language", + "profileId": "rust", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for rust." + }, + { + "id": "language:rust:graphRelations", + "profileType": "language", + "profileId": "rust", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for rust." + }, + { + "id": "language:rust:imports", + "profileType": "language", + "profileId": "rust", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for rust." + }, + { + "id": "language:rust:relations", + "profileType": "language", + "profileId": "rust", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for rust." + }, + { + "id": "language:rust:riskInterprocedural", + "profileType": "language", + "profileId": "rust", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for rust." + }, + { + "id": "language:rust:riskLocal", + "profileType": "language", + "profileId": "rust", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for rust." + }, + { + "id": "language:rust:symbolGraph", + "profileType": "language", + "profileId": "rust", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for rust." + }, + { + "id": "language:scala:ast", + "profileType": "language", + "profileId": "scala", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for scala." + }, + { + "id": "language:scala:controlFlow", + "profileType": "language", + "profileId": "scala", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for scala." + }, + { + "id": "language:scala:dataFlow", + "profileType": "language", + "profileId": "scala", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for scala." + }, + { + "id": "language:scala:docmeta", + "profileType": "language", + "profileId": "scala", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for scala." + }, + { + "id": "language:scala:graphRelations", + "profileType": "language", + "profileId": "scala", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for scala." + }, + { + "id": "language:scala:imports", + "profileType": "language", + "profileId": "scala", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for scala." + }, + { + "id": "language:scala:relations", + "profileType": "language", + "profileId": "scala", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for scala." + }, + { + "id": "language:scala:riskInterprocedural", + "profileType": "language", + "profileId": "scala", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for scala." + }, + { + "id": "language:scala:riskLocal", + "profileType": "language", + "profileId": "scala", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for scala." + }, + { + "id": "language:scala:symbolGraph", + "profileType": "language", + "profileId": "scala", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for scala." + }, + { + "id": "language:shell:ast", + "profileType": "language", + "profileId": "shell", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for shell." + }, + { + "id": "language:shell:controlFlow", + "profileType": "language", + "profileId": "shell", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for shell." + }, + { + "id": "language:shell:dataFlow", + "profileType": "language", + "profileId": "shell", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial dataFlow coverage for shell." + }, + { + "id": "language:shell:docmeta", + "profileType": "language", + "profileId": "shell", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for shell." + }, + { + "id": "language:shell:graphRelations", + "profileType": "language", + "profileId": "shell", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for shell." + }, + { + "id": "language:shell:imports", + "profileType": "language", + "profileId": "shell", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for shell." + }, + { + "id": "language:shell:relations", + "profileType": "language", + "profileId": "shell", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for shell." + }, + { + "id": "language:shell:riskInterprocedural", + "profileType": "language", + "profileId": "shell", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for shell." + }, + { + "id": "language:shell:riskLocal", + "profileType": "language", + "profileId": "shell", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for shell." + }, + { + "id": "language:shell:symbolGraph", + "profileType": "language", + "profileId": "shell", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for shell." + }, + { + "id": "language:sql:ast", + "profileType": "language", + "profileId": "sql", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for sql." + }, + { + "id": "language:sql:controlFlow", + "profileType": "language", + "profileId": "sql", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for sql." + }, + { + "id": "language:sql:dataFlow", + "profileType": "language", + "profileId": "sql", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for sql." + }, + { + "id": "language:sql:docmeta", + "profileType": "language", + "profileId": "sql", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for sql." + }, + { + "id": "language:sql:graphRelations", + "profileType": "language", + "profileId": "sql", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for sql." + }, + { + "id": "language:sql:imports", + "profileType": "language", + "profileId": "sql", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial imports coverage for sql." + }, + { + "id": "language:sql:relations", + "profileType": "language", + "profileId": "sql", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for sql." + }, + { + "id": "language:sql:riskInterprocedural", + "profileType": "language", + "profileId": "sql", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for sql." + }, + { + "id": "language:sql:riskLocal", + "profileType": "language", + "profileId": "sql", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskLocal coverage for sql." + }, + { + "id": "language:sql:symbolGraph", + "profileType": "language", + "profileId": "sql", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for sql." + }, + { + "id": "language:starlark:ast", + "profileType": "language", + "profileId": "starlark", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for starlark." + }, + { + "id": "language:starlark:controlFlow", + "profileType": "language", + "profileId": "starlark", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial controlFlow coverage for starlark." + }, + { + "id": "language:starlark:dataFlow", + "profileType": "language", + "profileId": "starlark", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial dataFlow coverage for starlark." + }, + { + "id": "language:starlark:docmeta", + "profileType": "language", + "profileId": "starlark", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for starlark." + }, + { + "id": "language:starlark:graphRelations", + "profileType": "language", + "profileId": "starlark", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for starlark." + }, + { + "id": "language:starlark:imports", + "profileType": "language", + "profileId": "starlark", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported imports coverage for starlark." + }, + { + "id": "language:starlark:relations", + "profileType": "language", + "profileId": "starlark", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for starlark." + }, + { + "id": "language:starlark:riskInterprocedural", + "profileType": "language", + "profileId": "starlark", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for starlark." + }, + { + "id": "language:starlark:riskLocal", + "profileType": "language", + "profileId": "starlark", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for starlark." + }, + { + "id": "language:starlark:symbolGraph", + "profileType": "language", + "profileId": "starlark", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for starlark." + }, + { + "id": "language:swift:ast", + "profileType": "language", + "profileId": "swift", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported ast coverage for swift." + }, + { + "id": "language:swift:controlFlow", + "profileType": "language", + "profileId": "swift", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported controlFlow coverage for swift." + }, + { + "id": "language:swift:dataFlow", + "profileType": "language", + "profileId": "swift", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported dataFlow coverage for swift." + }, + { + "id": "language:swift:docmeta", + "profileType": "language", + "profileId": "swift", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported docmeta coverage for swift." + }, + { + "id": "language:swift:graphRelations", + "profileType": "language", + "profileId": "swift", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported graphRelations coverage for swift." + }, + { + "id": "language:swift:imports", + "profileType": "language", + "profileId": "swift", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported imports coverage for swift." + }, + { + "id": "language:swift:relations", + "profileType": "language", + "profileId": "swift", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported relations coverage for swift." + }, + { + "id": "language:swift:riskInterprocedural", + "profileType": "language", + "profileId": "swift", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for swift." + }, + { + "id": "language:swift:riskLocal", + "profileType": "language", + "profileId": "swift", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": false, + "notes": "supported riskLocal coverage for swift." + }, + { + "id": "language:swift:symbolGraph", + "profileType": "language", + "profileId": "swift", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for swift." + }, + { + "id": "language:toml:ast", + "profileType": "language", + "profileId": "toml", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for toml." + }, + { + "id": "language:toml:controlFlow", + "profileType": "language", + "profileId": "toml", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for toml." + }, + { + "id": "language:toml:dataFlow", + "profileType": "language", + "profileId": "toml", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for toml." + }, + { + "id": "language:toml:docmeta", + "profileType": "language", + "profileId": "toml", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for toml." + }, + { + "id": "language:toml:graphRelations", + "profileType": "language", + "profileId": "toml", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for toml." + }, + { + "id": "language:toml:imports", + "profileType": "language", + "profileId": "toml", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for toml." + }, + { + "id": "language:toml:relations", + "profileType": "language", + "profileId": "toml", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for toml." + }, + { + "id": "language:toml:riskInterprocedural", + "profileType": "language", + "profileId": "toml", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for toml." + }, + { + "id": "language:toml:riskLocal", + "profileType": "language", + "profileId": "toml", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for toml." + }, + { + "id": "language:toml:symbolGraph", + "profileType": "language", + "profileId": "toml", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for toml." + }, + { + "id": "language:typescript:ast", + "profileType": "language", + "profileId": "typescript", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported ast coverage for typescript." + }, + { + "id": "language:typescript:controlFlow", + "profileType": "language", + "profileId": "typescript", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported controlFlow coverage for typescript." + }, + { + "id": "language:typescript:dataFlow", + "profileType": "language", + "profileId": "typescript", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported dataFlow coverage for typescript." + }, + { + "id": "language:typescript:docmeta", + "profileType": "language", + "profileId": "typescript", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported docmeta coverage for typescript." + }, + { + "id": "language:typescript:graphRelations", + "profileType": "language", + "profileId": "typescript", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported graphRelations coverage for typescript." + }, + { + "id": "language:typescript:imports", + "profileType": "language", + "profileId": "typescript", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported imports coverage for typescript." + }, + { + "id": "language:typescript:relations", + "profileType": "language", + "profileId": "typescript", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported relations coverage for typescript." + }, + { + "id": "language:typescript:riskInterprocedural", + "profileType": "language", + "profileId": "typescript", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "partial riskInterprocedural coverage for typescript." + }, + { + "id": "language:typescript:riskLocal", + "profileType": "language", + "profileId": "typescript", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": false, + "notes": "supported riskLocal coverage for typescript." + }, + { + "id": "language:typescript:symbolGraph", + "profileType": "language", + "profileId": "typescript", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ], + "blocking": true, + "notes": "supported symbolGraph coverage for typescript." + }, + { + "id": "language:xml:ast", + "profileType": "language", + "profileId": "xml", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for xml." + }, + { + "id": "language:xml:controlFlow", + "profileType": "language", + "profileId": "xml", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for xml." + }, + { + "id": "language:xml:dataFlow", + "profileType": "language", + "profileId": "xml", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for xml." + }, + { + "id": "language:xml:docmeta", + "profileType": "language", + "profileId": "xml", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for xml." + }, + { + "id": "language:xml:graphRelations", + "profileType": "language", + "profileId": "xml", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for xml." + }, + { + "id": "language:xml:imports", + "profileType": "language", + "profileId": "xml", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for xml." + }, + { + "id": "language:xml:relations", + "profileType": "language", + "profileId": "xml", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for xml." + }, + { + "id": "language:xml:riskInterprocedural", + "profileType": "language", + "profileId": "xml", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for xml." + }, + { + "id": "language:xml:riskLocal", + "profileType": "language", + "profileId": "xml", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for xml." + }, + { + "id": "language:xml:symbolGraph", + "profileType": "language", + "profileId": "xml", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for xml." + }, + { + "id": "language:yaml:ast", + "profileType": "language", + "profileId": "yaml", + "capability": "ast", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial ast coverage for yaml." + }, + { + "id": "language:yaml:controlFlow", + "profileType": "language", + "profileId": "yaml", + "capability": "controlFlow", + "artifactId": "graph_relations", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported controlFlow coverage for yaml." + }, + { + "id": "language:yaml:dataFlow", + "profileType": "language", + "profileId": "yaml", + "capability": "dataFlow", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported dataFlow coverage for yaml." + }, + { + "id": "language:yaml:docmeta", + "profileType": "language", + "profileId": "yaml", + "capability": "docmeta", + "artifactId": "chunk_meta", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "supported docmeta coverage for yaml." + }, + { + "id": "language:yaml:graphRelations", + "profileType": "language", + "profileId": "yaml", + "capability": "graphRelations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial graphRelations coverage for yaml." + }, + { + "id": "language:yaml:imports", + "profileType": "language", + "profileId": "yaml", + "capability": "imports", + "artifactId": "file_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial imports coverage for yaml." + }, + { + "id": "language:yaml:relations", + "profileType": "language", + "profileId": "yaml", + "capability": "relations", + "artifactId": "graph_relations", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial relations coverage for yaml." + }, + { + "id": "language:yaml:riskInterprocedural", + "profileType": "language", + "profileId": "yaml", + "capability": "riskInterprocedural", + "artifactId": "risk_flows", + "expectation": "deterministic-empty", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "unsupported riskInterprocedural coverage for yaml." + }, + { + "id": "language:yaml:riskLocal", + "profileType": "language", + "profileId": "yaml", + "capability": "riskLocal", + "artifactId": "risk_summaries", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": false, + "notes": "partial riskLocal coverage for yaml." + }, + { + "id": "language:yaml:symbolGraph", + "profileType": "language", + "profileId": "yaml", + "capability": "symbolGraph", + "artifactId": "symbols", + "expectation": "required", + "requiredConformance": [ + "C0", + "C1", + "C2" + ], + "blocking": true, + "notes": "partial symbolGraph coverage for yaml." + } + ] +} diff --git a/tests/lang/matrix/usr-fixture-governance.json b/tests/lang/matrix/usr-fixture-governance.json index 6bcecf63e..61c2af9f6 100644 --- a/tests/lang/matrix/usr-fixture-governance.json +++ b/tests/lang/matrix/usr-fixture-governance.json @@ -10,8 +10,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -65,8 +67,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -125,6 +129,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -156,6 +162,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-cmake", @@ -187,6 +196,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -218,7 +229,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-css", "reviewers": [ @@ -233,6 +247,7 @@ "appendix-c:css", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -249,6 +264,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -280,6 +297,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-dockerfile", @@ -311,6 +331,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -342,6 +364,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-graphql", @@ -373,6 +398,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -404,7 +431,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-handlebars", "reviewers": [ @@ -419,6 +449,7 @@ "appendix-c:handlebars", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -434,7 +465,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-html", "reviewers": [ @@ -449,6 +483,7 @@ "appendix-c:html", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -464,6 +499,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-ini", @@ -495,6 +533,9 @@ "config", "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-ini", @@ -526,6 +567,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -560,6 +603,8 @@ "framework-overlay", "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -619,7 +664,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-jinja", "reviewers": [ @@ -634,6 +682,7 @@ "appendix-c:jinja", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -649,6 +698,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-json", @@ -680,6 +732,9 @@ "config", "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-json", @@ -711,6 +766,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -743,6 +800,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -775,6 +834,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -806,6 +867,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-makefile", @@ -836,7 +900,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-mustache", "reviewers": [ @@ -851,6 +918,7 @@ "appendix-c:mustache", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -862,8 +930,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -895,6 +965,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-nix", @@ -921,8 +994,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -955,6 +1030,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -987,6 +1064,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1018,6 +1097,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-proto", @@ -1049,6 +1131,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1081,6 +1165,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1112,7 +1198,10 @@ "families": [ "framework-overlay", "golden", - "language-baseline" + "language-baseline", + "normalization", + "resolution", + "risk" ], "owner": "language-razor", "reviewers": [ @@ -1127,6 +1216,7 @@ "appendix-c:razor", "phase-4", "phase-5", + "phase-6", "phase-7" ] }, @@ -1138,8 +1228,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -1172,6 +1264,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1204,6 +1298,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1236,6 +1332,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1268,6 +1366,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1300,6 +1400,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1331,6 +1433,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-starlark", @@ -1357,6 +1462,7 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", "style-scope", @@ -1385,8 +1491,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -1419,6 +1527,8 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1450,6 +1560,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-toml", @@ -1481,6 +1594,9 @@ "config", "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-toml", @@ -1514,6 +1630,8 @@ "framework-overlay", "golden", "language-baseline", + "normalization", + "resolution", "risk", "semantic-flow" ], @@ -1863,8 +1981,10 @@ "C4" ], "families": [ + "embedded-bridge", "framework-overlay", "hydration", + "route-canonicalization", "route-semantics", "style-scope", "template-binding" @@ -1950,6 +2070,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-xml", @@ -1981,6 +2104,9 @@ "config", "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-xml", @@ -2011,6 +2137,9 @@ "families": [ "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-yaml", @@ -2042,6 +2171,9 @@ "config", "golden", "language-baseline", + "normalization", + "resolution", + "risk", "semantic-flow" ], "owner": "language-yaml", diff --git a/tests/lang/perl/perl-package-chunks.test.js b/tests/lang/perl/package-chunks.test.js similarity index 100% rename from tests/lang/perl/perl-package-chunks.test.js rename to tests/lang/perl/package-chunks.test.js diff --git a/tests/lang/perl/perl-scheduler-crash-recovery.test.js b/tests/lang/perl/perl-scheduler-crash-recovery.test.js deleted file mode 100644 index 1089224c3..000000000 --- a/tests/lang/perl/perl-scheduler-crash-recovery.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { createCrashLogger } from '../../../src/index/build/crash-log.js'; -import { runTreeSitterScheduler } from '../../../src/index/build/tree-sitter-scheduler/runner.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'perl-scheduler-crash-recovery'); -const outDir = path.join(tempRoot, 'index-code'); -const repoCacheRoot = path.join(tempRoot, 'repo-cache'); -const perlAbs = path.join(root, 'tests', 'fixtures', 'languages', 'src', 'perl_advanced.pl'); -const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); -await fs.mkdir(repoCacheRoot, { recursive: true }); - -const runtime = { - root, - repoCacheRoot, - buildRoot: tempRoot, - buildId: 'ub001-perl-crash-recovery', - segmentsConfig: null, - languageOptions: { - treeSitter: { - enabled: true, - strict: true - } - } -}; -const crashLogger = await createCrashLogger({ - repoCacheRoot, - enabled: true -}); - -const previousCrashInjection = process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH; -process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH = 'perl'; -let scheduler = null; -try { - scheduler = await runTreeSitterScheduler({ - mode: 'code', - runtime, - entries: [perlAbs, jsAbs], - outDir, - abortSignal: null, - log: () => {}, - crashLogger - }); -} finally { - if (previousCrashInjection === undefined) { - delete process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH; - } else { - process.env.PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH = previousCrashInjection; - } -} - -assert.ok(scheduler, 'expected scheduler object'); -assert.ok(scheduler.index instanceof Map, 'expected scheduler index map'); -assert.ok( - scheduler.index.size > 0, - 'expected repo to continue indexing unaffected files after injected perl parser crash' -); -const stats = scheduler.stats(); -assert.ok( - Number(stats?.parserCrashSignatures) >= 1, - 'expected parser crash signature marker in scheduler stats' -); -assert.ok( - Number(stats?.degradedVirtualPaths) >= 1, - 'expected degraded path count in scheduler stats' -); -const crashSummary = scheduler.getCrashSummary(); -assert.ok(Array.isArray(crashSummary?.parserCrashEvents), 'expected parser crash events array'); -assert.ok(crashSummary.parserCrashEvents.length >= 1, 'expected at least one parser crash event'); - -await fs.access(scheduler.crashForensicsBundlePath); -await fs.access(path.join(repoCacheRoot, 'logs', 'index-crash-forensics-index.json')); -const durableCrashPath = scheduler.durableCrashForensicsBundlePath; -if (durableCrashPath) { - await fs.access(durableCrashPath); -} - -console.log('perl scheduler crash recovery ok'); diff --git a/tests/lang/perl/scheduler-crash-recovery.test.js b/tests/lang/perl/scheduler-crash-recovery.test.js new file mode 100644 index 000000000..fd4b0e0ce --- /dev/null +++ b/tests/lang/perl/scheduler-crash-recovery.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createCrashLogger } from '../../../src/index/build/crash-log.js'; +import { runTreeSitterScheduler } from '../../../src/index/build/tree-sitter-scheduler/runner.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'perl-scheduler-crash-recovery'); +const outDir = path.join(tempRoot, 'index-code'); +const repoCacheRoot = path.join(tempRoot, 'repo-cache'); +const perlAbs = path.join(root, 'tests', 'fixtures', 'languages', 'src', 'perl_advanced.pl'); +const jsAbs = path.join(root, 'tests', 'fixtures', 'tree-sitter', 'javascript.js'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); +await fs.mkdir(repoCacheRoot, { recursive: true }); + +const runtime = { + root, + repoCacheRoot, + buildRoot: tempRoot, + buildId: 'ub001-perl-crash-recovery', + segmentsConfig: null, + languageOptions: { + treeSitter: { + enabled: true, + strict: true + } + } +}; +const crashLogger = await createCrashLogger({ + repoCacheRoot, + enabled: true +}); + +try { + let scheduler = null; + await withTemporaryEnv({ PAIROFCLEATS_TEST_TREE_SITTER_SCHEDULER_CRASH: 'perl' }, async () => { + scheduler = await runTreeSitterScheduler({ + mode: 'code', + runtime, + entries: [perlAbs, jsAbs], + outDir, + abortSignal: null, + log: () => {}, + crashLogger + }); + }); + + assert.ok(scheduler, 'expected scheduler object'); + assert.ok(scheduler.index instanceof Map, 'expected scheduler index map'); + assert.ok( + scheduler.index.size > 0, + 'expected repo to continue indexing unaffected files after injected perl parser crash' + ); + const stats = scheduler.stats(); + assert.ok( + Number(stats?.parserCrashSignatures) >= 1, + 'expected parser crash signature marker in scheduler stats' + ); + assert.ok( + Number(stats?.degradedVirtualPaths) >= 1, + 'expected degraded path count in scheduler stats' + ); + const crashSummary = scheduler.getCrashSummary(); + assert.ok(Array.isArray(crashSummary?.parserCrashEvents), 'expected parser crash events array'); + assert.ok(crashSummary.parserCrashEvents.length >= 1, 'expected at least one parser crash event'); + + await fs.access(scheduler.crashForensicsBundlePath); + await fs.access(path.join(repoCacheRoot, 'logs', 'index-crash-forensics-index.json')); + const durableCrashPath = scheduler.durableCrashForensicsBundlePath; + if (durableCrashPath) { + await fs.access(durableCrashPath); + } +} finally { + await crashLogger.close?.(); +} + +console.log('perl scheduler crash recovery ok'); diff --git a/tests/lang/php/php-methods-unique.test.js b/tests/lang/php/methods-unique.test.js similarity index 100% rename from tests/lang/php/php-methods-unique.test.js rename to tests/lang/php/methods-unique.test.js diff --git a/tests/lang/php/php-namespace.test.js b/tests/lang/php/namespace.test.js similarity index 100% rename from tests/lang/php/php-namespace.test.js rename to tests/lang/php/namespace.test.js diff --git a/tests/lang/python/python-ast-chunks.test.js b/tests/lang/python/ast-chunks.test.js similarity index 100% rename from tests/lang/python/python-ast-chunks.test.js rename to tests/lang/python/ast-chunks.test.js diff --git a/tests/lang/python/ast-worker.test.js b/tests/lang/python/ast-worker.test.js new file mode 100644 index 000000000..d693f7331 --- /dev/null +++ b/tests/lang/python/ast-worker.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { getPythonAst, shutdownPythonAstPool } from '../../../src/lang/python.js'; + +function hasPython() { + const candidates = ['python', 'python3']; + for (const cmd of candidates) { + const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); + if (result.status === 0 && result.stdout.trim() === 'ok') return true; + } + return false; +} + +if (!hasPython()) { + console.log('Python AST worker test skipped (python not available).'); + process.exit(0); +} + +const sample = ` +def add(a: int, b: int) -> int: + return a + b +`; +const parserLogs = []; +const captureLog = (line) => { + if (!line) return; + parserLogs.push(String(line)); +}; + +const result = await getPythonAst(sample, captureLog, { + dataflow: true, + controlFlow: true, + pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } +}); +const ast = result?.ast ?? result; + +if (!ast || !Array.isArray(ast.defs)) { + console.error('Python AST worker returned no defs.'); + process.exit(1); +} +const hasAdd = ast.defs.some((entry) => entry?.name === 'add'); +if (!hasAdd) { + console.error('Python AST worker missing add() definition.'); + process.exit(1); +} + +const warningSample = 'regex = "\\S+"\n'; +const warningResult = await getPythonAst(warningSample, captureLog, { + dataflow: true, + controlFlow: true, + pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } +}); +if (!warningResult?.ast || !Array.isArray(warningResult.ast.defs)) { + console.error('Python AST worker warning sample parse failed.'); + process.exit(1); +} +if (parserLogs.some((line) => /\binvalid escape sequence\b/i.test(line) || /\bSyntaxWarning\b/i.test(line))) { + console.error('Python AST worker should suppress invalid escape SyntaxWarning logs.'); + process.exit(1); +} + +console.log('Python AST worker test passed'); +await shutdownPythonAstPool(); diff --git a/tests/lang/python/python-ast-worker.test.js b/tests/lang/python/python-ast-worker.test.js deleted file mode 100644 index 0c52a0382..000000000 --- a/tests/lang/python/python-ast-worker.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { getPythonAst, shutdownPythonAstPool } from '../../../src/lang/python.js'; - -function hasPython() { - const candidates = ['python', 'python3']; - for (const cmd of candidates) { - const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); - if (result.status === 0 && result.stdout.trim() === 'ok') return true; - } - return false; -} - -if (!hasPython()) { - console.log('Python AST worker test skipped (python not available).'); - process.exit(0); -} - -const sample = ` -def add(a: int, b: int) -> int: - return a + b -`; -const parserLogs = []; -const captureLog = (line) => { - if (!line) return; - parserLogs.push(String(line)); -}; - -const result = await getPythonAst(sample, captureLog, { - dataflow: true, - controlFlow: true, - pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } -}); -const ast = result?.ast ?? result; - -if (!ast || !Array.isArray(ast.defs)) { - console.error('Python AST worker returned no defs.'); - process.exit(1); -} -const hasAdd = ast.defs.some((entry) => entry?.name === 'add'); -if (!hasAdd) { - console.error('Python AST worker missing add() definition.'); - process.exit(1); -} - -const warningSample = 'regex = "\\S+"\n'; -const warningResult = await getPythonAst(warningSample, captureLog, { - dataflow: true, - controlFlow: true, - pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } -}); -if (!warningResult?.ast || !Array.isArray(warningResult.ast.defs)) { - console.error('Python AST worker warning sample parse failed.'); - process.exit(1); -} -if (parserLogs.some((line) => /\binvalid escape sequence\b/i.test(line) || /\bSyntaxWarning\b/i.test(line))) { - console.error('Python AST worker should suppress invalid escape SyntaxWarning logs.'); - process.exit(1); -} - -console.log('Python AST worker test passed'); -shutdownPythonAstPool(); diff --git a/tests/lang/python/python-contract-matrix.test.js b/tests/lang/python/python-contract-matrix.test.js new file mode 100644 index 000000000..e4f818b33 --- /dev/null +++ b/tests/lang/python/python-contract-matrix.test.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; + +import { + buildPythonHeuristicChunks, + collectPythonImports, + getPythonAst, + shutdownPythonAstPool +} from '../../../src/lang/python.js'; +const hasPython = () => { + for (const cmd of ['python', 'python3']) { + const result = spawnSync(cmd, ['-c', 'import sys; sys.stdout.write("ok")'], { encoding: 'utf8' }); + if (result.status === 0 && result.stdout.trim() === 'ok') return true; + } + return false; +}; + +const sorted = (items) => items.slice().sort(); +const expectSet = (label, actual, expected) => { + assert.deepEqual(sorted(actual), sorted(expected), `${label} mismatch`); +}; + +const cases = [ + { + name: 'python import collection is deterministic and ignores docstrings', + async run() { + const source = [ + 'import Foo, foo', + 'import os, sys as system', + 'import json', + 'from collections import defaultdict, namedtuple as nt', + 'from . import sibling', + 'from ..pkg.sub import Util as UtilAlias', + 'from foo.bar import Baz as Qux, Quux', + '# from ignored import nope' + ].join('\n'); + + const { imports, usages } = collectPythonImports(source); + expectSet('imports+relative', imports, ['Foo', 'foo', 'os', 'sys', 'json', 'collections', '.', '..pkg.sub', 'foo.bar']); + expectSet('usages', usages, ['sibling', 'Util', 'UtilAlias', 'system', 'defaultdict', 'namedtuple', 'nt', 'Baz', 'Qux', 'Quux']); + assert.deepEqual(imports, imports.slice().sort((a, b) => String(a).toLowerCase().localeCompare(String(b).toLowerCase()) || String(a).localeCompare(String(b)))); + + const docstringSample = collectPythonImports([ + '"""', + 'Example:', + ' from fake.docs import ExampleThing', + ' import pretend_module', + '"""', + 'from real.pkg import ActualThing as AliasThing', + 'import json', + 'guide = """', + 'import another_fake', + '"""', + 'from feature.flags import (', + ' EnabledFeature,', + ' DisabledFeature as DF,', + ')' + ].join('\n')); + + expectSet('docstring imports filtered', docstringSample.imports, ['feature.flags', 'json', 'real.pkg']); + expectSet('docstring usages filtered', docstringSample.usages, ['ActualThing', 'AliasThing', 'DF', 'DisabledFeature', 'EnabledFeature']); + } + }, + { + name: 'python heuristic chunking covers representative fixtures and local samples', + async run() { + const sample = [ + 'class Foo:', + ' def method(self):', + ' pass', + '', + 'def top():', + ' pass', + '', + 'async def later():', + ' pass' + ].join('\n'); + + const chunks = buildPythonHeuristicChunks(sample) || []; + const byName = Object.fromEntries(chunks.map((chunk) => [chunk.name, chunk])); + assert.ok(byName.Foo); + assert.ok(byName['Foo.method']); + assert.ok(byName.top); + assert.ok(byName.later); + assert.equal(byName.Foo.meta.startLine, 1); + assert.equal(byName.Foo.meta.endLine, 4); + assert.equal(byName['Foo.method'].meta.startLine, 2); + assert.equal(byName['Foo.method'].meta.endLine, 4); + + const fixturePath = path.join(process.cwd(), 'tests', 'fixtures', 'languages', 'src', 'python_advanced.py'); + const text = fs.readFileSync(fixturePath, 'utf8'); + const fixtureChunks = buildPythonHeuristicChunks(text) || []; + const names = new Set(fixtureChunks.map((chunk) => chunk.name)); + assert.ok(names.has('Point')); + assert.ok(names.has('Point.distance')); + assert.ok(names.has('outer')); + assert.ok(names.has('fetch_data')); + } + }, + { + name: 'python ast pool fails open when python is unavailable', + async run() { + const failOpenScript = [ + "import assert from 'node:assert/strict';", + "import { getPythonAst, shutdownPythonAstPool } from './src/lang/python.js';", + "const ast = await getPythonAst('def add(a, b):\\n return a + b\\n', null, { pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } });", + 'assert.equal(ast, null);', + 'await shutdownPythonAstPool();' + ].join('\n'); + const failOpen = runNode( + ['--input-type=module', '--eval', failOpenScript], + 'python AST fail-open without python', + process.cwd(), + { + ...process.env, + PATH: '' + }, + { stdio: 'pipe', allowFailure: true } + ); + assert.equal(failOpen.status, 0, failOpen.stderr || failOpen.stdout || 'python fail-open subprocess failed'); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('python contract matrix test passed'); diff --git a/tests/lang/python/python-fallback.test.js b/tests/lang/python/python-fallback.test.js deleted file mode 100644 index 66dd8bcef..000000000 --- a/tests/lang/python/python-fallback.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildPythonHeuristicChunks } from '../../../src/lang/python.js'; - -const root = process.cwd(); -const fixturePath = path.join(root, 'tests', 'fixtures', 'languages', 'src', 'python_advanced.py'); - -if (!fs.existsSync(fixturePath)) { - console.error(`Missing python fixture at ${fixturePath}`); - process.exit(1); -} - -const text = fs.readFileSync(fixturePath, 'utf8'); -const chunks = buildPythonHeuristicChunks(text) || []; - -const hasPoint = chunks.some((chunk) => chunk.name === 'Point'); -const hasDistance = chunks.some((chunk) => chunk.name === 'Point.distance'); -const hasOuter = chunks.some((chunk) => chunk.name === 'outer'); -const hasFetch = chunks.some((chunk) => chunk.name === 'fetch_data'); - -if (!hasPoint || !hasDistance || !hasOuter || !hasFetch) { - console.error('Python heuristic fallback missing expected chunks (Point, Point.distance, outer, fetch_data).'); - process.exit(1); -} - -console.log('Python heuristic fallback test passed'); diff --git a/tests/lang/python/python-heuristic-chunking.test.js b/tests/lang/python/python-heuristic-chunking.test.js deleted file mode 100644 index d63fbe0aa..000000000 --- a/tests/lang/python/python-heuristic-chunking.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import { buildPythonHeuristicChunks } from '../../../src/lang/python.js'; - -const sample = [ - 'class Foo:', - ' def method(self):', - ' pass', - '', - 'def top():', - ' pass', - '', - 'async def later():', - ' pass' -].join('\n'); - -const chunks = buildPythonHeuristicChunks(sample) || []; -const byName = Object.fromEntries(chunks.map((chunk) => [chunk.name, chunk])); - -const expect = (condition, message) => { - if (!condition) { - console.error(message); - process.exit(1); - } -}; - -expect(byName.Foo, 'Missing class chunk for Foo.'); -expect(byName['Foo.method'], 'Missing method chunk for Foo.method.'); -expect(byName.top, 'Missing function chunk for top.'); -expect(byName.later, 'Missing function chunk for later.'); - -expect(byName.Foo.meta.startLine === 1, 'Foo startLine mismatch.'); -expect(byName.Foo.meta.endLine === 4, 'Foo endLine mismatch.'); -expect(byName['Foo.method'].meta.startLine === 2, 'Foo.method startLine mismatch.'); -expect(byName['Foo.method'].meta.endLine === 4, 'Foo.method endLine mismatch.'); -expect(byName.top.meta.startLine === 5, 'top startLine mismatch.'); -expect(byName.top.meta.endLine === 7, 'top endLine mismatch.'); -expect(byName.later.meta.startLine === 8, 'later startLine mismatch.'); -expect(byName.later.meta.endLine === 9, 'later endLine mismatch.'); - -console.log('Python heuristic chunking test passed.'); diff --git a/tests/lang/python/python-imports.test.js b/tests/lang/python/python-imports.test.js deleted file mode 100644 index 3cfff7ac3..000000000 --- a/tests/lang/python/python-imports.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import { collectPythonImports } from '../../../src/lang/python.js'; - -const source = [ - 'import Foo, foo', - 'import os, sys as system', - 'import json', - 'from collections import defaultdict, namedtuple as nt', - 'from . import sibling', - 'from ..pkg.sub import Util as UtilAlias', - 'from foo.bar import Baz as Qux, Quux', - '# from ignored import nope' -].join('\n'); - -const { imports, usages } = collectPythonImports(source); -const sorted = (items) => items.slice().sort(); - -const expectSet = (label, actual, expected) => { - const actualSorted = sorted(actual); - const expectedSorted = sorted(expected); - const actualText = JSON.stringify(actualSorted); - const expectedText = JSON.stringify(expectedSorted); - if (actualText !== expectedText) { - console.error(`${label} mismatch: ${actualText} !== ${expectedText}`); - process.exit(1); - } -}; - -expectSet('imports+relative', imports, ['Foo', 'foo', 'os', 'sys', 'json', 'collections', '.', '..pkg.sub', 'foo.bar']); -expectSet('usages', usages, [ - 'sibling', - 'Util', - 'UtilAlias', - 'system', - 'defaultdict', - 'namedtuple', - 'nt', - 'Baz', - 'Qux', - 'Quux' -]); - -const expectedOrder = imports.slice().sort((a, b) => ( - String(a).toLowerCase().localeCompare(String(b).toLowerCase()) || String(a).localeCompare(String(b)) -)); -if (JSON.stringify(imports) !== JSON.stringify(expectedOrder)) { - console.error('imports order should be deterministic and case-aware sorted'); - process.exit(1); -} - -console.log('Python imports test passed.'); diff --git a/tests/lang/python/python-pool.test.js b/tests/lang/python/python-pool.test.js deleted file mode 100644 index e183b0dba..000000000 --- a/tests/lang/python/python-pool.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import { getPythonAst, shutdownPythonAstPool } from '../../../src/lang/python.js'; -import { findPythonExecutable } from '../../../src/lang/python/executable.js'; - -const sample = 'def add(a: int, b: int) -> int:\\n return a + b\\n'; -const originalPath = process.env.PATH; -process.env.PATH = ''; - -const pythonBin = await findPythonExecutable(); -if (pythonBin) { - const ast = await getPythonAst(sample, null, { - pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } - }); - if (!ast || !Array.isArray(ast.defs)) { - console.error('Expected AST payload when python is available.'); - process.exit(1); - } -} else { - const ast = await getPythonAst(sample, null, { - pythonAst: { workerCount: 1, maxWorkers: 1, taskTimeoutMs: 5000 } - }); - if (ast !== null) { - console.error('Expected null AST when python is not available.'); - process.exit(1); - } -} - -shutdownPythonAstPool(); -shutdownPythonAstPool(); -process.env.PATH = originalPath; - -console.log('Python pool test passed.'); diff --git a/tests/lang/registry/collectors.test.js b/tests/lang/registry/collectors.test.js index d046b5798..a89ff8750 100644 --- a/tests/lang/registry/collectors.test.js +++ b/tests/lang/registry/collectors.test.js @@ -164,6 +164,12 @@ const cases = [ ].join('\n'), expected: ['foo.proto', 'bar.proto', 'baz.proto'] }, + { + label: 'proto-inline-block-comment-import', + fn: collectProtoImports, + text: '/* c */ import "real.proto";', + expected: ['real.proto'] + }, { label: 'graphql', fn: collectGraphqlImports, @@ -174,6 +180,17 @@ const cases = [ ].join('\n'), expected: ['common.graphql', 'shared.graphql', 'https://specs.apollo.dev/federation/v2.6'] }, + { + label: 'graphql-multiline-link', + fn: collectGraphqlImports, + text: [ + 'extend schema @link(', + ' url: "https://specs.apollo.dev/federation/v2.7",', + ' import: ["@key"]', + ')' + ].join('\n'), + expected: ['https://specs.apollo.dev/federation/v2.7'] + }, { label: 'cmake', fn: collectCmakeImports, @@ -197,6 +214,42 @@ const cases = [ ].join('\n'), expected: ['//path:target', '@rules_cc', '//tools:deps.bzl', '../third_party/custom'] }, + { + label: 'starlark-ignores-inline-comment-noise', + fn: collectStarlarkImports, + text: [ + '# bazel_dep(name = "rules_cc")', + 'load("//path:target", "x") # bazel_dep(name = "rules_java")' + ].join('\n'), + expected: ['//path:target'] + }, + { + label: 'starlark-multiline-calls', + fn: collectStarlarkImports, + text: [ + 'bazel_dep(', + ' name = "rules_go",', + ' version = "0.48.0",', + ')', + 'use_extension(', + ' "//tools:deps.bzl",', + ' "deps"', + ')' + ].join('\n'), + expected: ['@rules_go', '//tools:deps.bzl'] + }, + { + label: 'starlark-ignores-string-noise', + fn: collectStarlarkImports, + text: [ + 'DOC = """', + 'load("//ignored:target", "x")', + 'bazel_dep(name = "ignored")', + '"""', + 'load("//real:target", "x")' + ].join('\n'), + expected: ['//real:target'] + }, { label: 'nix', fn: collectNixImports, @@ -218,6 +271,66 @@ const cases = [ '../local-override' ] }, + { + label: 'nix-multiline-imports-array', + fn: collectNixImports, + text: [ + 'imports = [', + ' ./hosts/default.nix', + ' ../shared/infra.nix', + '];' + ].join('\n'), + expected: ['./hosts/default.nix', '../shared/infra.nix'] + }, + { + label: 'nix-ignores-commented-imports', + fn: collectNixImports, + text: [ + '# import ./ignored.nix', + '# callPackage ../nope.nix {}', + 'import ./real.nix' + ].join('\n'), + expected: ['./real.nix'] + }, + { + label: 'nix-ignores-quoted-doc-noise', + fn: collectNixImports, + text: [ + 'let', + " doc = ''", + ' import ./ignored-in-doc.nix', + " builtins.getFlake \"github:ignored/example\"", + " '';", + 'in {', + ' imports = [ ./real.nix ];', + '}' + ].join('\n'), + expected: ['./real.nix'] + }, + { + label: 'starlark-budget-cap', + fn: collectStarlarkImports, + text: Array.from( + { length: 700 }, + (_, index) => `bazel_dep(name = "rules_${String(index).padStart(4, '0')}")` + ).join('\n'), + expected: Array.from( + { length: 512 }, + (_, index) => `@rules_${String(index).padStart(4, '0')}` + ) + }, + { + label: 'nix-budget-cap', + fn: collectNixImports, + text: `imports = [ ${Array.from( + { length: 1400 }, + (_, index) => `./modules/m${String(index).padStart(4, '0')}.nix` + ).join(' ')} ];`, + expected: Array.from( + { length: 1024 }, + (_, index) => `./modules/m${String(index).padStart(4, '0')}.nix` + ) + }, { label: 'dart', fn: collectDartImports, @@ -279,23 +392,89 @@ const cases = [ text: '{{> partial-name}} {{> "partials/nav"}}', expected: ['partial-name', 'partials/nav'] }, + { + label: 'handlebars-comment-suppression', + fn: collectHandlebarsImports, + text: '{{!-- {{> ignored/partial}} --}}{{> "partials/nav"}}', + expected: ['partials/nav'] + }, { label: 'mustache', fn: collectMustacheImports, text: '{{> other}}{{> partials/footer}}', expected: ['other', 'partials/footer'] }, + { + label: 'mustache-comment-suppression', + fn: collectMustacheImports, + text: '{{! {{> ignored}} }}{{> partials/footer}}', + expected: ['partials/footer'] + }, { label: 'jinja', fn: collectJinjaImports, text: '{% extends \"base.html\" %}', expected: ['base.html'] }, + { + label: 'jinja-multiline-include', + fn: collectJinjaImports, + text: '{% include\n \"partials/footer.html\"\n%}', + expected: ['partials/footer.html'] + }, + { + label: 'jinja-comment-suppression', + fn: collectJinjaImports, + text: '{# {% include "ignored.html" %} #}\n{% include "partials/footer.html" %}', + expected: ['partials/footer.html'] + }, { label: 'razor', fn: collectRazorImports, - text: '@using System.Text', + text: '@using System.Text // note', expected: ['System.Text'] + }, + { + label: 'razor-static-using', + fn: collectRazorImports, + text: '@using static System.Math', + expected: ['System.Math'] + }, + { + label: 'razor-alias-using', + fn: collectRazorImports, + text: '@using Json = System.Text.Json;', + expected: ['System.Text.Json'] + }, + { + label: 'razor-inline-block-comment-tail', + fn: collectRazorImports, + text: '@using System.Text @* trailing note *@', + expected: ['System.Text'] + }, + { + label: 'razor-inline-comment-tail-no-whitespace', + fn: collectRazorImports, + text: '@using System.Text//note', + expected: ['System.Text'] + }, + { + label: 'razor-comment-only-using', + fn: collectRazorImports, + text: '@using //comment', + expected: [] + }, + { + label: 'razor-using-expression-not-import', + fn: collectRazorImports, + text: '@using (Html.BeginForm()) { }', + expected: [] + }, + { + label: 'collector-long-line-budget', + fn: collectGraphqlImports, + text: `#import "${'a'.repeat(8193)}.graphql"`, + expected: [] } ]; diff --git a/tests/lang/registry/registry-contract-matrix.test.js b/tests/lang/registry/registry-contract-matrix.test.js new file mode 100644 index 000000000..fe8733d0c --- /dev/null +++ b/tests/lang/registry/registry-contract-matrix.test.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + collectLanguageImportEntries, + collectLanguageImports, + getLanguageForFile +} from '../../../src/index/language-registry.js'; +import { collectNixImports } from '../../../src/index/language-registry/import-collectors/nix.js'; +import { collectProtoImports } from '../../../src/index/language-registry/import-collectors/proto.js'; +import { collectStarlarkImports } from '../../../src/index/language-registry/import-collectors/starlark.js'; +import { collectGraphqlImports } from '../../../src/index/language-registry/import-collectors/graphql.js'; +import { collectJinjaImports } from '../../../src/index/language-registry/import-collectors/jinja.js'; +import { LANGUAGE_REGISTRY } from '../../../src/index/language-registry/registry-data.js'; +import { + createCommentAwareLineStripper, + stripInlineCommentAware +} from '../../../src/index/language-registry/import-collectors/comment-aware.js'; +import { addCollectorImport } from '../../../src/index/language-registry/import-collectors/utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const sortUnique = (values) => Array.from(new Set(values || [])).sort(); +const expectEquivalent = (label, collector, textA, textB) => { + const a = sortUnique(collector(textA)); + const b = sortUnique(collector(textB)); + assert.deepEqual(b, a, `${label}: comment/doc placement should not alter detected imports`); +}; + +const cases = [ + { + name: 'comment-aware stripping preserves literals while removing comments', + run() { + const hashStripper = createCommentAwareLineStripper({ + markers: ['#'], + requireWhitespaceBefore: true + }); + assert.equal(hashStripper('include = "value#fragment" # trailing comment'), 'include = "value#fragment"'); + assert.equal(hashStripper('include = value#fragment'), 'include = value#fragment'); + + const slashStripper = createCommentAwareLineStripper({ + markers: ['//'], + requireWhitespaceBefore: true + }); + assert.equal(slashStripper('@using System.Text // trailing'), '@using System.Text'); + assert.equal(slashStripper('url = "https://example.com/path"'), 'url = "https://example.com/path"'); + + const blockStripper = createCommentAwareLineStripper({ + markers: ['//'], + blockCommentPairs: [['/*', '*/']], + requireWhitespaceBefore: true + }); + assert.equal(blockStripper('/* start comment'), ''); + assert.equal(blockStripper('still comment */ import "real.proto";'), ' import "real.proto";'); + assert.equal(blockStripper('import "next.proto"; // trailing'), 'import "next.proto";'); + + assert.equal( + stripInlineCommentAware('name = "pkg#name" # trailing', { markers: ['#'], requireWhitespaceBefore: true }), + 'name = "pkg#name"' + ); + + const imports = new Set(); + assert.equal(addCollectorImport(imports, 'anchor:token'), false); + assert.equal(addCollectorImport(imports, ' ./real/path '), true); + assert.deepEqual(Array.from(imports), ['./real/path']); + } + }, + { + name: 'comment placement metamorphism holds across nix starlark and proto collectors', + run() { + expectEquivalent( + 'nix', + collectNixImports, + [ + 'import ./module.nix', + 'callPackage ../pkg/default.nix {}', + 'inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";' + ].join('\n'), + [ + '# import ./ignored.nix', + 'import ./module.nix # trailing comment', + '# callPackage ../ignored/default.nix {}', + 'callPackage ../pkg/default.nix {} # keep', + '', + 'inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; # pinned' + ].join('\n') + ); + + expectEquivalent( + 'starlark', + collectStarlarkImports, + [ + 'load("//tools:deps.bzl", "deps")', + 'bazel_dep(name = "rules_cc", version = "0.0.1")' + ].join('\n'), + [ + '# load("//ignored:deps.bzl", "deps")', + 'load("//tools:deps.bzl", "deps") # keep', + '# bazel_dep(name = "rules_java", version = "0.1.0")', + 'bazel_dep(name = "rules_cc", version = "0.0.1")' + ].join('\n') + ); + + expectEquivalent( + 'proto', + collectProtoImports, + [ + 'import "foo.proto";', + 'import public "bar.proto";' + ].join('\n'), + [ + '// import "ignored.proto";', + 'import "foo.proto"; // trailing note', + '/* block */ import public "bar.proto";' + ].join('\n') + ); + } + }, + { + name: 'registry selection resolves representative language/file pairs', + run() { + const expectId = (ext, relPath, expected) => { + const lang = getLanguageForFile(ext, relPath); + const actual = lang ? lang.id : null; + assert.equal(actual, expected, `Language mismatch for ${relPath || ext}`); + }; + + expectId('.js', 'src/app.js', 'javascript'); + expectId('.mjs', 'src/app.mjs', 'javascript'); + expectId('.tsx', 'src/App.tsx', 'typescript'); + expectId('.py', 'src/app.py', 'python'); + expectId('.rs', 'src/lib.rs', 'rust'); + expectId('.go', 'src/main.go', 'go'); + expectId('.jsonc', 'config/deno.jsonc', 'json'); + expectId('', 'python/Pipfile', 'toml'); + expectId('.csproj', 'src/app/app.csproj', 'xml'); + expectId('', 'go.mod', 'go'); + expectId('', 'proto/buf.yaml', 'proto'); + expectId('.hbs', 'templates/view.hbs', 'handlebars'); + expectId('.dockerfile', 'Dockerfile.dockerfile', 'dockerfile'); + } + }, + { + name: 'import entries preserve specifiers and collector hints', + run() { + const starlarkText = [ + 'load("//tools:deps.bzl", "deps")', + 'bazel_dep(name = "rules_cc", version = "0.0.1")', + 'local_path_override(module_name = "custom", path = "../third_party/custom")' + ].join('\n'); + const starlarkEntries = collectLanguageImportEntries({ + ext: '.bzl', + relPath: 'WORKSPACE.bzl', + text: starlarkText, + mode: 'code', + options: {} + }); + assert.deepEqual( + starlarkEntries.map((entry) => entry.specifier), + collectLanguageImports({ + ext: '.bzl', + relPath: 'WORKSPACE.bzl', + text: starlarkText, + mode: 'code', + options: {} + }) + ); + assert.equal( + starlarkEntries.find((entry) => entry.specifier === '//tools:deps.bzl')?.collectorHint?.reasonCode, + 'IMP_U_RESOLVER_GAP' + ); + assert.equal( + starlarkEntries.find((entry) => entry.specifier === '../third_party/custom')?.collectorHint || null, + null + ); + + const pythonEntries = collectLanguageImportEntries({ + ext: '.py', + relPath: 'app/main.py', + text: [ + '"""', + 'from docs.fake import Demo', + 'import docs_only', + '"""', + 'from pkg.runtime import loader as load', + 'import os', + 'config = """import hidden_runtime"""' + ].join('\n'), + mode: 'code', + options: {} + }); + assert.deepEqual( + pythonEntries.map((entry) => entry.specifier), + ['os', 'pkg.runtime'] + ); + } + }, + { + name: 'collector budget diagnostics are emitted for direct and heuristic collectors', + run() { + const graphqlDiagnostics = []; + const graphqlSource = Array.from({ length: 16 }, (_, index) => `#import "mod${index}.graphql"`).join('\n'); + const graphqlImports = collectGraphqlImports(graphqlSource, { + collectorDiagnostics: graphqlDiagnostics, + collectorScanBudgets: { + graphql: { + maxChars: 16384, + maxMatches: 64, + maxTokens: 3, + maxMs: 200 + } + } + }); + assert.equal(graphqlImports.length, 3); + const graphqlBudgetDiagnostic = graphqlDiagnostics.find((entry) => entry?.collectorId === 'graphql'); + assert.ok(graphqlBudgetDiagnostic); + assert.ok(Array.isArray(graphqlBudgetDiagnostic.reasons) && graphqlBudgetDiagnostic.reasons.includes('scan_tokens')); + + const diagnostics = []; + collectLanguageImportEntries({ + ext: '.bzl', + relPath: 'tools/deps.bzl', + text: [ + 'load("//tools:deps.bzl", "deps")', + 'bazel_dep(name = "rules_proto")', + 'bazel_dep(name = "rules_cc")' + ].join('\n'), + mode: 'code', + options: { + collectorDiagnostics: diagnostics, + collectorScanBudgets: { + starlark: { + maxMatches: 1, + maxTokens: 1 + } + } + } + }); + collectLanguageImportEntries({ + ext: '.nix', + relPath: 'flake.nix', + text: `imports = [ ${Array.from({ length: 8 }, (_, idx) => `./m${idx}.nix`).join(' ')} ];`, + mode: 'code', + options: { + collectorDiagnostics: diagnostics, + collectorScanBudgets: { + nix: { maxTokens: 2 } + } + } + }); + assert.ok(diagnostics.find((entry) => entry?.type === 'collector-scan-budget' && entry?.collectorId === 'starlark')); + assert.ok(diagnostics.find((entry) => entry?.type === 'collector-scan-budget' && entry?.collectorId === 'nix')); + + const heuristicDiagnostics = []; + const makefileEntry = LANGUAGE_REGISTRY.find((entry) => entry.id === 'makefile'); + assert.ok(makefileEntry); + makefileEntry.buildRelations({ + text: Array.from({ length: 48 }, (_, index) => `target${index}: dep${index} dep${index + 1}`).join('\n'), + relPath: 'Makefile', + options: { + collectorDiagnostics: heuristicDiagnostics, + collectorScanBudgets: { + 'heuristic-adapter': { + maxChars: 65536, + maxLines: 128, + maxMatches: 1, + maxTokens: 64, + maxMs: 200 + } + } + } + }); + const heuristicNamespaceBudgetDiagnostic = heuristicDiagnostics.find( + (entry) => entry?.collectorId === 'heuristic-adapter:makefile' + ); + assert.ok(heuristicNamespaceBudgetDiagnostic); + assert.ok( + Array.isArray(heuristicNamespaceBudgetDiagnostic.reasons) + && heuristicNamespaceBudgetDiagnostic.reasons.includes('scan_matches') + ); + + const jinjaDiagnostics = []; + collectJinjaImports(Array.from({ length: 24 }, () => '{% include "partials/item.html" %}').join('\n'), { + collectorDiagnostics: jinjaDiagnostics, + collectorScanBudgets: { + jinja: { + maxChars: 40, + maxMatches: 32, + maxTokens: 32, + maxMs: 200 + } + } + }); + const jinjaBudgetDiagnostic = jinjaDiagnostics.find((entry) => entry?.collectorId === 'jinja'); + assert.ok(jinjaBudgetDiagnostic); + assert.ok(Array.isArray(jinjaBudgetDiagnostic.reasons) && jinjaBudgetDiagnostic.reasons.includes('source_bytes')); + } + } +]; + +for (const entry of cases) { + entry.run(); +} + +console.log('language registry contract matrix test passed'); diff --git a/tests/lang/registry/selection.test.js b/tests/lang/registry/selection.test.js deleted file mode 100644 index ed97470f5..000000000 --- a/tests/lang/registry/selection.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import { getLanguageForFile } from '../../../src/index/language-registry.js'; - -const expectId = (ext, relPath, expected) => { - const lang = getLanguageForFile(ext, relPath); - const actual = lang ? lang.id : null; - if (actual !== expected) { - console.error(`Language mismatch for ${relPath || ext}: ${actual} !== ${expected}`); - process.exit(1); - } -}; - -expectId('.js', 'src/app.js', 'javascript'); -expectId('.mjs', 'src/app.mjs', 'javascript'); -expectId('.cjs', 'src/app.cjs', 'javascript'); -expectId('.jsx', 'src/App.jsx', 'javascript'); -expectId('.ts', 'src/app.ts', 'typescript'); -expectId('.mts', 'src/app.mts', 'typescript'); -expectId('.cts', 'src/app.cts', 'typescript'); -expectId('.tsx', 'src/App.tsx', 'typescript'); -expectId('.py', 'src/app.py', 'python'); -expectId('.rs', 'src/lib.rs', 'rust'); -expectId('.go', 'src/main.go', 'go'); -expectId('.jsonc', 'config/deno.jsonc', 'json'); -expectId('.resolved', 'swift/Package.resolved', 'json'); -expectId('', 'python/Pipfile', 'toml'); -expectId('.csproj', 'src/app/app.csproj', 'xml'); -expectId('', 'go.mod', 'go'); -expectId('', 'proto/buf.yaml', 'proto'); -expectId('', 'proto/buf.gen.yaml', 'proto'); -expectId('.hbs', 'templates/view.hbs', 'handlebars'); -expectId('.dockerfile', 'Dockerfile.dockerfile', 'dockerfile'); - -console.log('Language registry selection test passed.'); diff --git a/tests/lang/ruby/ruby-end-comment.test.js b/tests/lang/ruby/end-comment.test.js similarity index 100% rename from tests/lang/ruby/ruby-end-comment.test.js rename to tests/lang/ruby/end-comment.test.js diff --git a/tests/lang/ruby/ruby-modules.test.js b/tests/lang/ruby/modules.test.js similarity index 100% rename from tests/lang/ruby/ruby-modules.test.js rename to tests/lang/ruby/modules.test.js diff --git a/tests/lang/rust/prose-exclusion.test.js b/tests/lang/rust/prose-exclusion.test.js new file mode 100644 index 000000000..e79f3c489 --- /dev/null +++ b/tests/lang/rust/prose-exclusion.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; +import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'prose-rust-exclusion'); +const repoRootRaw = path.join(tempRoot, 'repo'); +const repoRoot = toRealPathSync(repoRootRaw); +const srcDir = path.join(repoRoot, 'src'); +const docsDir = path.join(repoRoot, 'docs'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(srcDir, { recursive: true }); +await fsPromises.mkdir(docsDir, { recursive: true }); + +await fsPromises.writeFile(path.join(srcDir, 'lib.rs'), 'fn main() {}\n'); +await fsPromises.writeFile(path.join(docsDir, 'readme.md'), '# Readme\n'); + +const env = applyTestEnv({ + cacheRoot: path.join(tempRoot, 'cache'), + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +runNode( + [ + path.join(root, 'build_index.js'), + '--repo', + repoRoot, + '--stage', + 'stage1', + '--mode', + 'prose', + '--no-sqlite', + '--scm-provider', + 'none', + '--stub-embeddings' + ], + 'prose rust exclusion build index', + root, + env, + { stdio: 'pipe' } +); + +const userConfig = loadUserConfig(repoRoot); +const proseDir = getIndexDir(repoRoot, 'prose', userConfig); + +const proseMeta = await loadChunkMeta(proseDir); +const fileMeta = await readJsonFile(path.join(proseDir, 'file_meta.json')); +const fileById = new Map(fileMeta.map((entry) => [entry.id, entry.file])); +if (proseMeta.some((chunk) => fileById.get(chunk.fileId) === 'src/lib.rs')) { + console.error('prose rust exclusion test failed: rust file leaked into prose index.'); + process.exit(1); +} +if (!proseMeta.some((chunk) => fileById.get(chunk.fileId) === 'docs/readme.md')) { + console.error('prose rust exclusion test failed: readme missing from prose index.'); + process.exit(1); +} + +console.log('prose rust exclusion test passed.'); + diff --git a/tests/lang/rust/prose-rust-exclusion.test.js b/tests/lang/rust/prose-rust-exclusion.test.js deleted file mode 100644 index 4fe7b2a99..000000000 --- a/tests/lang/rust/prose-rust-exclusion.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; -import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'prose-rust-exclusion'); -const repoRootRaw = path.join(tempRoot, 'repo'); -const repoRoot = toRealPathSync(repoRootRaw); -const srcDir = path.join(repoRoot, 'src'); -const docsDir = path.join(repoRoot, 'docs'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(srcDir, { recursive: true }); -await fsPromises.mkdir(docsDir, { recursive: true }); - -await fsPromises.writeFile(path.join(srcDir, 'lib.rs'), 'fn main() {}\n'); -await fsPromises.writeFile(path.join(docsDir, 'readme.md'), '# Readme\n'); - -const env = applyTestEnv({ - cacheRoot: path.join(tempRoot, 'cache'), - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--repo', repoRoot, '--mode', 'prose', '--stub-embeddings'], - { env, encoding: 'utf8' } -); -if (buildResult.status !== 0) { - console.error('prose rust exclusion test failed: build_index error.'); - if (buildResult.stderr) console.error(buildResult.stderr.trim()); - process.exit(buildResult.status ?? 1); -} - -const userConfig = loadUserConfig(repoRoot); -const proseDir = getIndexDir(repoRoot, 'prose', userConfig); - -const proseMeta = await loadChunkMeta(proseDir); -const fileMeta = await readJsonFile(path.join(proseDir, 'file_meta.json')); -const fileById = new Map(fileMeta.map((entry) => [entry.id, entry.file])); -if (proseMeta.some((chunk) => fileById.get(chunk.fileId) === 'src/lib.rs')) { - console.error('prose rust exclusion test failed: rust file leaked into prose index.'); - process.exit(1); -} -if (!proseMeta.some((chunk) => fileById.get(chunk.fileId) === 'docs/readme.md')) { - console.error('prose rust exclusion test failed: readme missing from prose index.'); - process.exit(1); -} - -console.log('prose rust exclusion test passed.'); - diff --git a/tests/lang/shell/shell-shebang.test.js b/tests/lang/shell/shebang.test.js similarity index 100% rename from tests/lang/shell/shell-shebang.test.js rename to tests/lang/shell/shebang.test.js diff --git a/tests/lang/typescript/typescript-chunk-boundaries.test.js b/tests/lang/typescript/chunk-boundaries.test.js similarity index 100% rename from tests/lang/typescript/typescript-chunk-boundaries.test.js rename to tests/lang/typescript/chunk-boundaries.test.js diff --git a/tests/lang/typescript/ts-jsx-fixtures.test.js b/tests/lang/typescript/ts-jsx-fixtures.test.js deleted file mode 100644 index ecaea8619..000000000 --- a/tests/lang/typescript/ts-jsx-fixtures.test.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildJsChunks, collectImports } from '../../../src/lang/javascript.js'; -import { buildTypeScriptChunks, collectTypeScriptImports } from '../../../src/lang/typescript.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages', 'src'); - -function readFixture(name) { - const filePath = path.join(fixtureRoot, name); - if (!fs.existsSync(filePath)) { - console.error(`Missing fixture file: ${filePath}`); - process.exit(1); - } - return fs.readFileSync(filePath, 'utf8'); -} - -const tsxText = readFixture('typescript_component.tsx'); -const tsxChunks = buildTypeScriptChunks(tsxText) || []; -const tsxHasWidget = tsxChunks.some((chunk) => chunk.name === 'FancyWidget'); -if (!tsxHasWidget) { - console.error('Expected TypeScript TSX chunk for FancyWidget.'); - process.exit(1); -} - -const mtsText = readFixture('typescript_imports.mts'); -const mtsImports = collectTypeScriptImports(mtsText); -const expectedMts = ['lib-alpha', 'lib-beta', 'lib-gamma', 'lib-delta']; -for (const mod of expectedMts) { - if (!mtsImports.includes(mod)) { - console.error(`Missing TypeScript import (${mod}) in .mts parsing.`); - process.exit(1); - } -} - -const ctsText = readFixture('typescript_commonjs.cts'); -const ctsImports = collectTypeScriptImports(ctsText); -if (!ctsImports.includes('legacy-lib')) { - console.error('Missing TypeScript import (legacy-lib) in .cts parsing.'); - process.exit(1); -} - -const jsxText = readFixture('javascript_component.jsx'); -const jsxChunks = buildJsChunks(jsxText) || []; -const jsxHasApp = jsxChunks.some((chunk) => chunk.name === 'App'); -const jsxHasButton = jsxChunks.some((chunk) => chunk.name === 'Button'); -if (!jsxHasApp || !jsxHasButton) { - console.error('Expected JSX chunks for App and Button.'); - process.exit(1); -} - -const flowText = readFixture('javascript_flow.js'); -const flowChunks = buildJsChunks(flowText, { - ext: '.js', - javascript: { parser: 'babel', flow: 'auto' }, - flowMode: 'auto' -}) || []; -const flowHasGreet = flowChunks.some((chunk) => chunk.name === 'greet'); -if (!flowHasGreet) { - console.error('Expected Flow chunks for greet.'); - process.exit(1); -} -const flowImports = collectImports(flowText, { - ext: '.js', - javascript: { parser: 'babel', flow: 'auto' }, - flowMode: 'auto' -}); -if (!flowImports.includes('flow-parser') || !flowImports.includes('./types')) { - console.error('Missing Flow imports in JS parsing.'); - process.exit(1); -} - -console.log('TS/JSX/Flow fixture parsing tests passed'); diff --git a/tests/lang/typescript/typescript-contract-matrix.test.js b/tests/lang/typescript/typescript-contract-matrix.test.js new file mode 100644 index 000000000..c90f9a60f --- /dev/null +++ b/tests/lang/typescript/typescript-contract-matrix.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { smartChunk } from '../../../src/index/chunking.js'; +import { buildJsChunks, collectImports } from '../../../src/lang/javascript.js'; +import { buildTypeScriptChunks, collectTypeScriptImports } from '../../../src/lang/typescript.js'; + +const fixtureRoot = path.join(process.cwd(), 'tests', 'fixtures', 'languages', 'src'); + +const readFixture = (name) => { + const filePath = path.join(fixtureRoot, name); + assert.ok(fs.existsSync(filePath), `missing fixture file: ${filePath}`); + return fs.readFileSync(filePath, 'utf8'); +}; + +const cases = [ + { + name: 'importsOnly still extracts imports and produces chunks', + run() { + const text = "import type { Foo } from 'foo';\nexport = ???"; + const imports = collectTypeScriptImports(text, { + parser: 'babel', + typescript: { importsOnly: true } + }); + assert.ok(imports.includes('foo')); + + const chunks = smartChunk({ + text: 'export interface Foo { bar: string }', + ext: '.ts', + relPath: 'foo.ts', + mode: 'code', + context: { typescript: { importsOnly: true } } + }); + assert.ok(Array.isArray(chunks) && chunks.length > 0); + } + }, + { + name: 'parser selection supports heuristic, babel, and typescript backends', + run() { + const sample = 'export function foo(a: number): string { return String(a); }'; + const heuristicChunks = buildTypeScriptChunks(sample, { parser: 'heuristic' }); + const babelChunks = buildTypeScriptChunks(sample, { parser: 'babel' }); + const tsChunks = buildTypeScriptChunks(sample, { parser: 'typescript', rootDir: process.cwd() }); + + assert.ok(Array.isArray(heuristicChunks) && heuristicChunks.length > 0); + assert.ok(Array.isArray(babelChunks) && babelChunks.length > 0); + assert.ok(Array.isArray(tsChunks) && tsChunks.length > 0); + } + }, + { + name: 'tsx, mts, cts, jsx, and flow fixtures remain parseable', + run() { + const tsxText = readFixture('typescript_component.tsx'); + const tsxChunks = buildTypeScriptChunks(tsxText) || []; + assert.ok(tsxChunks.some((chunk) => chunk.name === 'FancyWidget')); + + const mtsImports = collectTypeScriptImports(readFixture('typescript_imports.mts')); + for (const mod of ['lib-alpha', 'lib-beta', 'lib-gamma', 'lib-delta']) { + assert.ok(mtsImports.includes(mod), `missing mts import ${mod}`); + } + + const ctsImports = collectTypeScriptImports(readFixture('typescript_commonjs.cts')); + assert.ok(ctsImports.includes('legacy-lib')); + + const jsxText = readFixture('javascript_component.jsx'); + const jsxChunks = buildJsChunks(jsxText) || []; + assert.ok(jsxChunks.some((chunk) => chunk.name === 'App')); + assert.ok(jsxChunks.some((chunk) => chunk.name === 'Button')); + + const flowText = readFixture('javascript_flow.js'); + const flowChunks = buildJsChunks(flowText, { + ext: '.js', + javascript: { parser: 'babel', flow: 'auto' }, + flowMode: 'auto' + }) || []; + assert.ok(flowChunks.some((chunk) => chunk.name === 'greet')); + + const flowImports = collectImports(flowText, { + ext: '.js', + javascript: { parser: 'babel', flow: 'auto' }, + flowMode: 'auto' + }); + assert.ok(flowImports.includes('flow-parser')); + assert.ok(flowImports.includes('./types')); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('TypeScript contract matrix test passed'); diff --git a/tests/lang/typescript/typescript-imports-only.test.js b/tests/lang/typescript/typescript-imports-only.test.js deleted file mode 100644 index 67e61783f..000000000 --- a/tests/lang/typescript/typescript-imports-only.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import { collectTypeScriptImports } from '../../../src/lang/typescript.js'; -import { smartChunk } from '../../../src/index/chunking.js'; - -const text = "import type { Foo } from 'foo';\nexport = ???"; -let imports = []; -try { - imports = collectTypeScriptImports(text, { - parser: 'babel', - typescript: { importsOnly: true } - }); -} catch (err) { - console.error(`typescript imports-only test failed: ${err?.message || err}`); - process.exit(1); -} - -if (!imports.includes('foo')) { - console.error('typescript imports-only test failed: missing import'); - process.exit(1); -} - -const chunks = smartChunk({ - text: 'export interface Foo { bar: string }', - ext: '.ts', - relPath: 'foo.ts', - mode: 'code', - context: { typescript: { importsOnly: true } } -}); - -if (!Array.isArray(chunks) || chunks.length === 0) { - console.error('typescript imports-only test failed: chunker returned empty.'); - process.exit(1); -} - -console.log('typescript imports-only test passed'); diff --git a/tests/lang/typescript/typescript-parser-selection.test.js b/tests/lang/typescript/typescript-parser-selection.test.js deleted file mode 100644 index 611a08624..000000000 --- a/tests/lang/typescript/typescript-parser-selection.test.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildTypeScriptChunks } from '../../../src/lang/typescript.js'; - -const sample = 'export function foo(a: number): string { return String(a); }'; - -const heuristicChunks = buildTypeScriptChunks(sample, { parser: 'heuristic' }); -assert.ok(Array.isArray(heuristicChunks) && heuristicChunks.length > 0); - -const babelChunks = buildTypeScriptChunks(sample, { parser: 'babel' }); -assert.ok(Array.isArray(babelChunks) && babelChunks.length > 0); - -const tsChunks = buildTypeScriptChunks(sample, { parser: 'typescript', rootDir: process.cwd() }); -assert.ok(Array.isArray(tsChunks) && tsChunks.length > 0); - -console.log('typescript parser selection test passed'); diff --git a/tests/lexicon/lexicon-ascii-only.test.js b/tests/lexicon/ascii-only.test.js similarity index 100% rename from tests/lexicon/lexicon-ascii-only.test.js rename to tests/lexicon/ascii-only.test.js diff --git a/tests/lexicon/lexicon-fallback.test.js b/tests/lexicon/fallback.test.js similarity index 100% rename from tests/lexicon/lexicon-fallback.test.js rename to tests/lexicon/fallback.test.js diff --git a/tests/lexicon/lexicon-report.test.js b/tests/lexicon/lexicon-report.test.js deleted file mode 100644 index 8d1e23d81..000000000 --- a/tests/lexicon/lexicon-report.test.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'lexicon', 'report.js'); - -const result = spawnSync(process.execPath, [scriptPath, '--json'], { - cwd: root, - encoding: 'utf8' -}); - -assert.equal(result.status, 0, `expected report script success: ${result.stderr || result.stdout}`); -const payload = JSON.parse(result.stdout || '{}'); -assert.ok(Array.isArray(payload.wordlists), 'expected wordlists array'); -assert.ok(payload.wordlists.length >= 1, 'expected at least one wordlist'); -assert.equal(payload.versioning?.wordlistFormatVersion, 1, 'expected report wordlist format version 1'); -assert.equal(payload.versioning?.explainPayloadVersion, 1, 'expected explain payload version 1'); -assert.equal(payload.versioning?.nonAsciiSupport, 'deferred-v2', 'expected non-ascii v2 deferral marker'); -const hasGeneric = payload.wordlists.some((entry) => entry.languageId === '_generic'); -assert.equal(hasGeneric, true, 'expected generic fallback lexicon in report'); - -console.log('lexicon report test passed'); diff --git a/tests/lexicon/lexicon-tool-validate.test.js b/tests/lexicon/lexicon-tool-validate.test.js deleted file mode 100644 index c3f687bf6..000000000 --- a/tests/lexicon/lexicon-tool-validate.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'lexicon', 'validate.js'); -const schemaPath = path.join(root, 'src', 'lang', 'lexicon', 'language-lexicon-wordlist.schema.json'); - -const ok = spawnSync(process.execPath, [scriptPath, '--json'], { - cwd: root, - encoding: 'utf8' -}); -assert.equal(ok.status, 0, `expected validate script success: ${ok.stderr || ok.stdout}`); -const okPayload = JSON.parse(ok.stdout || '{}'); -assert.equal(okPayload.ok, true, 'expected validate payload ok=true'); -assert.ok(okPayload.counts?.filesScanned >= 1, 'expected at least one lexicon file'); - -const tempRoot = resolveTestCachePath(root, 'lexicon-tool-validate'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); -await fs.writeFile( - path.join(tempRoot, 'badlang.json'), - JSON.stringify({ - formatVersion: 1, - languageId: 'badlang', - keywords: ['If'], - literals: ['null'] - }, null, 2) -); - -const bad = spawnSync(process.execPath, [scriptPath, '--json', '--dir', tempRoot, '--schema', schemaPath], { - cwd: root, - encoding: 'utf8' -}); -assert.notEqual(bad.status, 0, 'expected invalid lexicon validation to fail'); -const badPayload = JSON.parse(bad.stdout || '{}'); -assert.equal(badPayload.ok, false, 'expected bad validation payload ok=false'); -assert.ok(Array.isArray(badPayload.errors) && badPayload.errors.length > 0, 'expected validation errors'); - -console.log('lexicon tool validate test passed'); diff --git a/tests/lexicon/lexicon-loads-all-languages.test.js b/tests/lexicon/loads-all-languages.test.js similarity index 100% rename from tests/lexicon/lexicon-loads-all-languages.test.js rename to tests/lexicon/loads-all-languages.test.js diff --git a/tests/lexicon/lexicon-lua-wordlist.test.js b/tests/lexicon/lua-wordlist.test.js similarity index 100% rename from tests/lexicon/lexicon-lua-wordlist.test.js rename to tests/lexicon/lua-wordlist.test.js diff --git a/tests/lexicon/lexicon-per-language-overrides.test.js b/tests/lexicon/per-language-overrides.test.js similarity index 100% rename from tests/lexicon/lexicon-per-language-overrides.test.js rename to tests/lexicon/per-language-overrides.test.js diff --git a/tests/lexicon/report.test.js b/tests/lexicon/report.test.js new file mode 100644 index 000000000..7e91b26a8 --- /dev/null +++ b/tests/lexicon/report.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { runNode } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'lexicon', 'report.js'); +const env = applyTestEnv({ syncProcess: false }); + +const result = runNode([scriptPath, '--json'], 'lexicon report json', root, env, { + stdio: 'pipe' +}); + +assert.equal(result.status, 0, `expected report script success: ${result.stderr || result.stdout}`); +const payload = JSON.parse(result.stdout || '{}'); +assert.ok(Array.isArray(payload.wordlists), 'expected wordlists array'); +assert.ok(payload.wordlists.length >= 1, 'expected at least one wordlist'); +assert.equal(payload.versioning?.wordlistFormatVersion, 1, 'expected report wordlist format version 1'); +assert.equal(payload.versioning?.explainPayloadVersion, 1, 'expected explain payload version 1'); +assert.equal(payload.versioning?.nonAsciiSupport, 'deferred-v2', 'expected non-ascii v2 deferral marker'); +const hasGeneric = payload.wordlists.some((entry) => entry.languageId === '_generic'); +assert.equal(hasGeneric, true, 'expected generic fallback lexicon in report'); + +console.log('lexicon report test passed'); diff --git a/tests/lexicon/lexicon-schema.test.js b/tests/lexicon/schema.test.js similarity index 100% rename from tests/lexicon/lexicon-schema.test.js rename to tests/lexicon/schema.test.js diff --git a/tests/lexicon/lexicon-stopwords.test.js b/tests/lexicon/stopwords.test.js similarity index 100% rename from tests/lexicon/lexicon-stopwords.test.js rename to tests/lexicon/stopwords.test.js diff --git a/tests/lexicon/tool-validate.test.js b/tests/lexicon/tool-validate.test.js new file mode 100644 index 000000000..8b67c4048 --- /dev/null +++ b/tests/lexicon/tool-validate.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { runNode } from '../helpers/run-node.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'lexicon', 'validate.js'); +const schemaPath = path.join(root, 'src', 'lang', 'lexicon', 'language-lexicon-wordlist.schema.json'); +const env = applyTestEnv({ syncProcess: false }); + +const ok = runNode([scriptPath, '--json'], 'lexicon validate json', root, env, { + stdio: 'pipe' +}); +assert.equal(ok.status, 0, `expected validate script success: ${ok.stderr || ok.stdout}`); +const okPayload = JSON.parse(ok.stdout || '{}'); +assert.equal(okPayload.ok, true, 'expected validate payload ok=true'); +assert.ok(okPayload.counts?.filesScanned >= 1, 'expected at least one lexicon file'); + +const tempRoot = resolveTestCachePath(root, 'lexicon-tool-validate'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'badlang.json'), + JSON.stringify({ + formatVersion: 1, + languageId: 'badlang', + keywords: ['If'], + literals: ['null'] + }, null, 2) +); + +const bad = runNode( + [scriptPath, '--json', '--dir', tempRoot, '--schema', schemaPath], + 'lexicon validate invalid wordlist', + root, + env, + { + stdio: 'pipe', + allowFailure: true + } +); +assert.notEqual(bad.status, 0, 'expected invalid lexicon validation to fail'); +const badPayload = JSON.parse(bad.stdout || '{}'); +assert.equal(badPayload.ok, false, 'expected bad validation payload ok=false'); +assert.ok(Array.isArray(badPayload.errors) && badPayload.errors.length > 0, 'expected validation errors'); + +console.log('lexicon tool validate test passed'); diff --git a/tests/map/build-contract-matrix.test.js b/tests/map/build-contract-matrix.test.js new file mode 100644 index 000000000..74a76adae --- /dev/null +++ b/tests/map/build-contract-matrix.test.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { buildCodeMap } from '../../src/map/build-map.js'; +import { writeMapJsonStream } from '../../src/map/build-map/io.js'; +import { DEFAULT_EDGE_WEIGHTS } from '../../src/map/constants.js'; +import { prepareMapBuildFixture } from './map-build-fixture.js'; + +const stripGeneratedFields = (payload) => { + const clone = JSON.parse(JSON.stringify(payload)); + clone.generatedAt = null; + if (clone.buildMetrics) clone.buildMetrics = null; + return clone; +}; + +const sharedFixture = await prepareMapBuildFixture({ + tempName: 'map-build-contract-matrix', + files: [ + ['src/one.js', 'export function one() { return 1; }\n'], + ['src/two.js', 'import { one } from "./one.js";\nexport function two() { return one(); }\n'], + [ + 'src/three.js', + 'import { one } from "./one.js";\nimport { two } from "./two.js";\nexport function three() { return one() + two(); }\n' + ] + ], + buildIndexArgs: ['--stage', 'stage2', '--mode', 'code'] +}); + +const cases = [ + { + name: 'builds are deterministic across repeated runs', + async run() { + const { repoRoot, indexDir } = sharedFixture; + + const first = stripGeneratedFields(await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } })); + const second = stripGeneratedFields(await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } })); + assert.equal(JSON.stringify(first), JSON.stringify(second)); + } + }, + { + name: 'streamed map output matches in-memory map output', + async run() { + const { repoRoot, indexDir, tempRoot } = sharedFixture; + + const mapModel = await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } }); + const outPath = path.join(tempRoot, 'map-stream.json'); + await writeMapJsonStream({ + filePath: outPath, + mapBase: (() => { + const base = { ...mapModel }; + delete base.nodes; + delete base.edges; + return base; + })(), + nodes: mapModel.nodes || [], + edges: mapModel.edges || [] + }); + + const streamed = JSON.parse(await fsPromises.readFile(outPath, 'utf8')); + assert.deepEqual(streamed, mapModel); + } + }, + { + name: 'edge aggregates stay consistent with emitted edge weights', + async run() { + const { repoRoot, indexDir } = sharedFixture; + + const mapModel = await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } }); + const aggregateMap = new Map(); + for (const edge of mapModel.edges || []) { + const fromFile = edge?.from?.file || null; + const toFile = edge?.to?.file || null; + if (!fromFile || !toFile) continue; + const type = edge.type || 'other'; + const key = `${type}:${fromFile}->${toFile}`; + const weight = DEFAULT_EDGE_WEIGHTS[type] || 1; + let bucket = aggregateMap.get(key); + if (!bucket) { + bucket = { + type, + fromFile, + toFile, + count: 0, + weight: 0, + minWeight: Infinity, + maxWeight: -Infinity + }; + aggregateMap.set(key, bucket); + } + bucket.count += 1; + bucket.weight += weight; + bucket.minWeight = Math.min(bucket.minWeight, weight); + bucket.maxWeight = Math.max(bucket.maxWeight, weight); + } + + const expected = Array.from(aggregateMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([, entry]) => ({ + type: entry.type, + fromFile: entry.fromFile, + toFile: entry.toFile, + count: entry.count, + weight: entry.weight, + minWeight: Number.isFinite(entry.minWeight) ? entry.minWeight : null, + maxWeight: Number.isFinite(entry.maxWeight) ? entry.maxWeight : null + })); + + assert.deepEqual(mapModel.edgeAggregates || [], expected); + } + }, + { + name: 'edge guardrails fail loudly when the byte budget is exceeded', + async run() { + const { repoRoot, indexDir } = sharedFixture; + + await assert.rejects( + () => buildCodeMap({ + repoRoot, + indexDir, + options: { + mode: 'code', + maxEdgeBytes: 1 + } + }), + (error) => { + const message = error?.message || String(error); + return message.includes('max-edge-bytes') || message.includes('edges'); + } + ); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('map build contract matrix test passed'); diff --git a/tests/map/build-map/cleanup-contract-matrix.test.js b/tests/map/build-map/cleanup-contract-matrix.test.js new file mode 100644 index 000000000..cb24a4a9a --- /dev/null +++ b/tests/map/build-map/cleanup-contract-matrix.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { buildCodeMap } from '../../../src/map/build-map.js'; +import { createSpillSorter } from '../../../src/map/build-map/io.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const cases = [ + { + name: 'spill cleanup retries transient rm failures', + async run() { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-map-spill-cleanup-')); + const sorter = createSpillSorter({ + label: 'spill', + compare: (left, right) => left.id - right.id, + maxInMemory: 1, + tempDir: tempRoot + }); + + await sorter.push({ id: 2 }); + await sorter.push({ id: 1 }); + const finalized = await sorter.finalize(); + assert.equal(finalized.spilled, true); + assert.ok(Array.isArray(finalized.runs) && finalized.runs.length >= 2); + + const originalRm = fsPromises.rm; + const attemptsByPath = new Map(); + fsPromises.rm = async (targetPath, options) => { + const key = String(targetPath); + const attempts = (attemptsByPath.get(key) || 0) + 1; + attemptsByPath.set(key, attempts); + if (attempts === 1) { + const error = new Error('transient descriptor pressure'); + error.code = 'EMFILE'; + throw error; + } + return originalRm(targetPath, options); + }; + + try { + await sorter.cleanup(); + } finally { + fsPromises.rm = originalRm; + } + + for (const runPath of finalized.runs) { + assert.equal(fs.existsSync(runPath), false, `expected spill run cleanup: ${runPath}`); + assert.ok((attemptsByPath.get(String(runPath)) || 0) >= 2); + } + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }, + { + name: 'forced temp directories are cleaned up after build failures', + async run() { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-map-temp-cleanup-')); + applyTestEnv({ cacheRoot: tempRoot }); + + const repoRoot = path.join(tempRoot, 'repo'); + const indexDir = path.join(tempRoot, 'index'); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fsPromises.writeFile(path.join(repoRoot, 'src', 'a.js'), 'export function alpha() { return 1; }\n'); + await fsPromises.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ + pieces: [ + { + name: 'repo_map', + path: 'repo_map.json', + format: 'json' + } + ] + }, null, 2)); + await fsPromises.writeFile(path.join(indexDir, 'repo_map.json'), JSON.stringify([ + { + file: 'src/a.js', + name: 'alpha', + kind: 'function', + signature: 'alpha()', + startLine: 1, + endLine: 1, + exported: true + } + ], null, 2)); + + const forcedTempDir = path.join(tempRoot, 'forced-map-temp-dir'); + const originalMkdtemp = fsPromises.mkdtemp; + fsPromises.mkdtemp = async () => { + await fsPromises.mkdir(forcedTempDir, { recursive: true }); + return forcedTempDir; + }; + + try { + await assert.rejects( + () => buildCodeMap({ + repoRoot, + indexDir, + options: { + maxNodeBytes: 1 + } + }), + /Map build guardrail hit for nodes/i + ); + } finally { + fsPromises.mkdtemp = originalMkdtemp; + } + + assert.equal(fs.existsSync(forcedTempDir), false); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('build-map cleanup contract matrix test passed'); diff --git a/tests/map/build-map/spill-cleanup-retries.test.js b/tests/map/build-map/spill-cleanup-retries.test.js deleted file mode 100644 index 2a8ed3fed..000000000 --- a/tests/map/build-map/spill-cleanup-retries.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createSpillSorter } from '../../../src/map/build-map/io.js'; - -const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-map-spill-cleanup-')); -const sorter = createSpillSorter({ - label: 'spill', - compare: (left, right) => left.id - right.id, - maxInMemory: 1, - tempDir: tempRoot -}); - -await sorter.push({ id: 2 }); -await sorter.push({ id: 1 }); -const finalized = await sorter.finalize(); -assert.equal(finalized.spilled, true, 'expected sorter to spill run files'); -assert.ok(Array.isArray(finalized.runs) && finalized.runs.length >= 2, 'expected spill run files to exist'); - -const originalRm = fsPromises.rm; -const attemptsByPath = new Map(); -fsPromises.rm = async (targetPath, options) => { - const key = String(targetPath); - const attempts = (attemptsByPath.get(key) || 0) + 1; - attemptsByPath.set(key, attempts); - if (attempts === 1) { - const error = new Error('transient descriptor pressure'); - error.code = 'EMFILE'; - throw error; - } - return originalRm(targetPath, options); -}; - -try { - await sorter.cleanup(); -} finally { - fsPromises.rm = originalRm; -} - -for (const runPath of finalized.runs) { - assert.equal(fs.existsSync(runPath), false, `expected spill run cleanup: ${runPath}`); - assert.ok((attemptsByPath.get(String(runPath)) || 0) >= 2, 'expected retry path to be exercised'); -} - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('map spill cleanup retries test passed'); diff --git a/tests/map/build-map/temp-cleanup-on-failure.test.js b/tests/map/build-map/temp-cleanup-on-failure.test.js deleted file mode 100644 index eae498316..000000000 --- a/tests/map/build-map/temp-cleanup-on-failure.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { buildCodeMap } from '../../../src/map/build-map.js'; - -const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-map-temp-cleanup-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const repoRoot = path.join(tempRoot, 'repo'); -const indexDir = path.join(tempRoot, 'index'); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.mkdir(indexDir, { recursive: true }); -await fsPromises.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fsPromises.writeFile(path.join(repoRoot, 'src', 'a.js'), 'export function alpha() { return 1; }\n'); -await fsPromises.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - pieces: [ - { - name: 'repo_map', - path: 'repo_map.json', - format: 'json' - } - ] -}, null, 2)); -await fsPromises.writeFile(path.join(indexDir, 'repo_map.json'), JSON.stringify([ - { - file: 'src/a.js', - name: 'alpha', - kind: 'function', - signature: 'alpha()', - startLine: 1, - endLine: 1, - exported: true - } -], null, 2)); - -const forcedTempDir = path.join(tempRoot, 'forced-map-temp-dir'); -const originalMkdtemp = fsPromises.mkdtemp; -fsPromises.mkdtemp = async () => { - await fsPromises.mkdir(forcedTempDir, { recursive: true }); - return forcedTempDir; -}; - -try { - await assert.rejects( - () => buildCodeMap({ - repoRoot, - indexDir, - options: { - maxNodeBytes: 1 - } - }), - /Map build guardrail hit for nodes/i - ); -} finally { - fsPromises.mkdtemp = originalMkdtemp; -} - -assert.equal(fs.existsSync(forcedTempDir), false, 'expected temp directory cleanup after failure'); -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - -console.log('map build temp cleanup on failure test passed'); diff --git a/tests/map/map-config-merge-behavior.test.js b/tests/map/config-merge-behavior.test.js similarity index 100% rename from tests/map/map-config-merge-behavior.test.js rename to tests/map/config-merge-behavior.test.js diff --git a/tests/map/map-filter-api-contract.test.js b/tests/map/filter-api-contract.test.js similarity index 100% rename from tests/map/map-filter-api-contract.test.js rename to tests/map/filter-api-contract.test.js diff --git a/tests/map/map-build-determinism.test.js b/tests/map/map-build-determinism.test.js deleted file mode 100644 index 0e3ce078f..000000000 --- a/tests/map/map-build-determinism.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildCodeMap } from '../../src/map/build-map.js'; -import { prepareMapBuildFixture } from './map-build-fixture.js'; - -const { repoRoot, indexDir } = await prepareMapBuildFixture({ - tempName: 'map-build-determinism', - files: [ - ['src/one.js', 'export function one() { return 1; }\n'], - ['src/two.js', 'import { one } from "./one.js";\nexport function two() { return one(); }\n'] - ], - buildIndexArgs: ['--stage', 'stage2', '--mode', 'code'] -}); - -const strip = (payload) => { - const clone = JSON.parse(JSON.stringify(payload)); - clone.generatedAt = null; - if (clone.buildMetrics) clone.buildMetrics = null; - return clone; -}; - -const first = strip(await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } })); -const second = strip(await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } })); - -assert.equal(JSON.stringify(first), JSON.stringify(second)); - -console.log('map build determinism test passed'); diff --git a/tests/map/map-build-fixture.js b/tests/map/map-build-fixture.js index db2cadfd8..ff2901613 100644 --- a/tests/map/map-build-fixture.js +++ b/tests/map/map-build-fixture.js @@ -1,7 +1,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../helpers/test-env.js'; +import { runNode } from '../helpers/run-node.js'; import { getIndexDir, resolveRepoConfig } from '../../tools/shared/dict-utils.js'; import { prepareTestCacheDir } from '../helpers/test-cache.js'; @@ -34,24 +34,34 @@ export async function prepareMapBuildFixture({ embeddings: 'stub', testConfig: { indexing: { - scm: { provider: 'none' } + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + workerPool: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } } }, ...envOverrides }); + const effectiveBuildArgs = Array.isArray(buildIndexArgs) && buildIndexArgs.length + ? buildIndexArgs + : ['--stage', 'stage1', '--mode', 'code']; + const buildArgs = [ path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot, - ...buildIndexArgs + ...effectiveBuildArgs ]; - const buildResult = spawnSync(process.execPath, buildArgs, { - cwd: repoRoot, - env, - stdio: 'inherit' + const buildResult = runNode(buildArgs, `build index for ${tempName}`, repoRoot, env, { + stdio: 'inherit', + allowFailure: true }); if (buildResult.status !== 0) { diff --git a/tests/map/map-build-heap-guard.test.js b/tests/map/map-build-heap-guard.test.js deleted file mode 100644 index e743d5211..000000000 --- a/tests/map/map-build-heap-guard.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import { buildCodeMap } from '../../src/map/build-map.js'; -import { prepareMapBuildFixture } from './map-build-fixture.js'; - -const { repoRoot, indexDir } = await prepareMapBuildFixture({ - tempName: 'map-build-heap-guard', - files: [ - ['src/alpha.js', 'export function alpha() { return 1; }\n'], - ['src/beta.js', 'import { alpha } from "./alpha.js";\nexport function beta() { return alpha(); }\n'] - ], - buildIndexArgs: ['--stage', 'stage2', '--mode', 'code'] -}); - -let threw = false; -try { - await buildCodeMap({ - repoRoot, - indexDir, - options: { - mode: 'code', - maxEdgeBytes: 10 - } - }); -} catch (err) { - threw = true; - const message = err?.message || String(err); - if (!message.includes('max-edge-bytes') && !message.includes('edges')) { - console.error(`Failed: unexpected guardrail message: ${message}`); - process.exit(1); - } -} - -if (!threw) { - console.error('Failed: expected guardrail to throw for edges'); - process.exit(1); -} - -console.log('map build heap guard test passed'); diff --git a/tests/map/map-build-streaming.test.js b/tests/map/map-build-streaming.test.js deleted file mode 100644 index 5e1550136..000000000 --- a/tests/map/map-build-streaming.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { buildCodeMap } from '../../src/map/build-map.js'; -import { writeMapJsonStream } from '../../src/map/build-map/io.js'; -import { prepareMapBuildFixture } from './map-build-fixture.js'; - -const { repoRoot, indexDir, tempRoot } = await prepareMapBuildFixture({ - tempName: 'map-build-streaming', - files: [ - ['src/alpha.js', 'export function alpha() { return 1; }\n'], - ['src/beta.js', 'import { alpha } from "./alpha.js";\nexport function beta() { return alpha(); }\n'] - ], - buildIndexArgs: ['--stage', 'stage2', '--mode', 'code'] -}); - -const mapModel = await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } }); - -const outPath = path.join(tempRoot, 'map-stream.json'); -await writeMapJsonStream({ - filePath: outPath, - mapBase: (() => { - const base = { ...mapModel }; - delete base.nodes; - delete base.edges; - return base; - })(), - nodes: mapModel.nodes || [], - edges: mapModel.edges || [] -}); - -const streamed = JSON.parse(await fsPromises.readFile(outPath, 'utf8')); -assert.deepEqual(streamed, mapModel, 'streamed map should match in-memory model'); - -console.log('map build streaming test passed'); diff --git a/tests/map/map-edge-aggregate-stability.test.js b/tests/map/map-edge-aggregate-stability.test.js deleted file mode 100644 index 3c9bab692..000000000 --- a/tests/map/map-edge-aggregate-stability.test.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildCodeMap } from '../../src/map/build-map.js'; -import { DEFAULT_EDGE_WEIGHTS } from '../../src/map/constants.js'; -import { prepareMapBuildFixture } from './map-build-fixture.js'; - -const { repoRoot, indexDir } = await prepareMapBuildFixture({ - tempName: 'map-edge-aggregate-stability', - files: [ - ['src/one.js', 'export function one() { return 1; }\n'], - ['src/two.js', 'import { one } from "./one.js";\nexport function two() { return one(); }\n'], - [ - 'src/three.js', - 'import { one } from "./one.js";\nimport { two } from "./two.js";\nexport function three() { return one() + two(); }\n' - ] - ] -}); - -const mapModel = await buildCodeMap({ repoRoot, indexDir, options: { mode: 'code' } }); -const edgeAggregates = Array.isArray(mapModel.edgeAggregates) ? mapModel.edgeAggregates : []; - -const aggregateMap = new Map(); -for (const edge of mapModel.edges || []) { - const fromFile = edge?.from?.file || null; - const toFile = edge?.to?.file || null; - if (!fromFile || !toFile) continue; - const type = edge.type || 'other'; - const key = `${type}:${fromFile}->${toFile}`; - const weight = DEFAULT_EDGE_WEIGHTS[type] || 1; - let bucket = aggregateMap.get(key); - if (!bucket) { - bucket = { - type, - fromFile, - toFile, - count: 0, - weight: 0, - minWeight: Infinity, - maxWeight: -Infinity - }; - aggregateMap.set(key, bucket); - } - bucket.count += 1; - bucket.weight += weight; - bucket.minWeight = Math.min(bucket.minWeight, weight); - bucket.maxWeight = Math.max(bucket.maxWeight, weight); -} - -const expected = Array.from(aggregateMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([, entry]) => ({ - type: entry.type, - fromFile: entry.fromFile, - toFile: entry.toFile, - count: entry.count, - weight: entry.weight, - minWeight: Number.isFinite(entry.minWeight) ? entry.minWeight : null, - maxWeight: Number.isFinite(entry.maxWeight) ? entry.maxWeight : null - })); - -assert.deepEqual(edgeAggregates, expected); - -console.log('map edge aggregate stability test passed'); diff --git a/tests/map/map-viewer-culling-correctness.test.js b/tests/map/map-viewer-culling-correctness.test.js deleted file mode 100644 index f24449f60..000000000 --- a/tests/map/map-viewer-culling-correctness.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import * as THREE from 'three'; -import { applyBucketCulling } from '../../src/map/isometric/client/culling.js'; - -const geometry = new THREE.BoxGeometry(1, 1, 1); -const material = new THREE.MeshBasicMaterial({ color: 0xffffff }); -const mesh = new THREE.InstancedMesh(geometry, material, 1); -const baseMatrix = new THREE.Matrix4().makeTranslation(0, 0, 0); -mesh.setMatrixAt(0, baseMatrix); -mesh.instanceMatrix.needsUpdate = true; - -const bucket = { - mesh, - instances: [{ index: 0, baseMatrix }], - sphere: new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1), - visible: true -}; - -const hiddenMatrix = new THREE.Matrix4().makeScale(0, 0, 0); - -const frustumHide = { intersectsSphere: () => false }; -applyBucketCulling({ frustum: frustumHide, buckets: [bucket], hiddenMatrix }); -const hiddenCheck = new THREE.Matrix4(); -mesh.getMatrixAt(0, hiddenCheck); -assert.ok(hiddenCheck.equals(hiddenMatrix), 'expected instance to be hidden'); - -const frustumShow = { intersectsSphere: () => true }; -applyBucketCulling({ frustum: frustumShow, buckets: [bucket], hiddenMatrix }); -const visibleCheck = new THREE.Matrix4(); -mesh.getMatrixAt(0, visibleCheck); -assert.ok(visibleCheck.equals(baseMatrix), 'expected instance to be restored'); - -const malformedBucket = { - mesh, - instances: { broken: true }, - sphere: new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1), - visible: true -}; -assert.doesNotThrow( - () => applyBucketCulling({ frustum: frustumHide, buckets: [malformedBucket], hiddenMatrix }), - 'expected culling to fail closed when bucket instances are malformed' -); - -console.log('map viewer culling correctness test passed'); diff --git a/tests/map/map-viewer-display-limits.test.js b/tests/map/map-viewer-display-limits.test.js deleted file mode 100644 index bb25dda89..000000000 --- a/tests/map/map-viewer-display-limits.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { applyDisplayLimits } from '../../src/map/isometric/client/display-limits.js'; - -const map = { - nodes: [ - { - path: 'src/a.js', - name: 'a.js', - members: [{ id: 'a1' }, { id: 'a2' }] - }, - { - path: 'src/b.js', - name: 'b.js', - members: [{ id: 'b1' }] - } - ], - edges: [ - { type: 'call', from: { file: 'src/a.js', member: 'a1' }, to: { file: 'src/b.js', member: 'b1' } }, - { type: 'call', from: { file: 'src/a.js', member: 'a2' }, to: { file: 'src/b.js', member: 'b1' } } - ] -}; - -const { map: limited, limits } = applyDisplayLimits(map, { maxFiles: 2, maxMembersPerFile: 1, maxEdges: 1 }); - -assert.equal(limits.maxFiles, 2); -assert.equal(limited.nodes.length, 2); -assert.equal(limited.nodes[0].members.length, 1); -assert.equal(limited.edges.length, 1); -assert.ok(limited.summary.truncated, 'expected truncated summary'); - -console.log('map viewer display limits test passed'); diff --git a/tests/map/map-viewer-instancing-count.test.js b/tests/map/map-viewer-instancing-count.test.js deleted file mode 100644 index 4a3c4590d..000000000 --- a/tests/map/map-viewer-instancing-count.test.js +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import * as THREE from 'three'; -import { state } from '../../src/map/isometric/client/state.js'; -import { buildMeshes } from '../../src/map/isometric/client/meshes.js'; -import { performanceDefaults, visualDefaults } from '../../src/map/isometric/client/defaults.js'; - -const resetState = () => { - Object.assign(state, { - THREE, - visuals: { ...visualDefaults, glass: { ...visualDefaults.glass } }, - visualDefaults, - performance: { ...performanceDefaults, drawCaps: { ...performanceDefaults.drawCaps } }, - allFiles: [], - layoutMetrics: { - labelOffset: 0.1, - memberCell: 1, - memberGap: 0.1, - baseSize: 3, - memberInset: 0.3, - routingPadding: 1, - routingStep: 1 - }, - fileGroup: new THREE.Group(), - memberGroup: new THREE.Group(), - labelGroup: new THREE.Group(), - wireGroup: new THREE.Group(), - fileMeshes: [], - fileInstancedMeshes: [], - fileInstancedInnerMeshes: [], - chunkMeshes: [], - memberInstancedMeshes: [], - memberClusters: [], - memberInstanceById: new Map(), - memberClusterByMemberId: new Map(), - fileInstanceByKey: new Map(), - fileBuckets: [], - fileBucketByKey: new Map(), - highlightedMemberIds: new Set(), - highlightedFileKeys: new Set(), - fileAnchors: new Map(), - memberAnchors: new Map(), - fileColorByPath: new Map(), - memberColorById: new Map(), - fileWireByKey: new Map(), - wireByMesh: new Map(), - normalMapState: { texture: null }, - glowMaterials: [], - glassMaterials: [], - wireMaterials: [], - scoreToColor: () => new THREE.Color(0xffffff) - }); - state.labelGroup.visible = false; -}; - -resetState(); - -state.allFiles = [ - { - node: { path: 'src/a.js', name: 'a.js', category: 'source', id: 'a' }, - shape: 'box', - x: 0, - z: 0, - width: 2, - depth: 2, - height: 1, - topY: 1, - memberSlots: [{ x: 0, z: 0 }], - members: [ - { - height: 0.6, - footprint: 0.5, - score: 0.5, - shape: 'square', - member: { id: 'm1', name: 'alpha', file: 'src/a.js' } - } - ] - }, - { - node: { path: 'src/b.js', name: 'b.js', category: 'source', id: 'b' }, - shape: 'box', - x: 4, - z: 0, - width: 2, - depth: 2, - height: 1, - topY: 1, - memberSlots: [{ x: 0, z: 0 }], - members: [ - { - height: 0.6, - footprint: 0.5, - score: 0.6, - shape: 'square', - member: { id: 'm2', name: 'beta', file: 'src/b.js' } - } - ] - } -]; - -buildMeshes(); - -assert.ok(state.fileInstancedMeshes.length > 0, 'expected instanced file meshes'); -assert.equal(state.fileInstanceByKey.size, 2, 'expected file instance count to match file count'); -assert.ok(state.memberInstancedMeshes.length > 0, 'expected instanced member meshes'); - -console.log('map viewer instancing count test passed'); diff --git a/tests/map/map-viewer-lod-switch.test.js b/tests/map/map-viewer-lod-switch.test.js deleted file mode 100644 index 78c655be3..000000000 --- a/tests/map/map-viewer-lod-switch.test.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveLodTier } from '../../src/map/isometric/client/lod.js'; -import { performanceDefaults } from '../../src/map/isometric/client/defaults.js'; - -const perf = { ...performanceDefaults, lod: { ...performanceDefaults.lod } }; - -const full = resolveLodTier({ zoom: 30, edgeCount: 1000, frameMs: 8, performance: perf }); -assert.equal(full, 'full'); - -const simplified = resolveLodTier({ zoom: 10, edgeCount: 4000, frameMs: 20, performance: perf }); -assert.equal(simplified, 'simplified'); - -const hidden = resolveLodTier({ zoom: 4, edgeCount: 15000, frameMs: 40, performance: perf }); -assert.equal(hidden, 'hidden'); - -console.log('map viewer LOD switch test passed'); diff --git a/tests/map/map-viewer-selection-stability.test.js b/tests/map/map-viewer-selection-stability.test.js deleted file mode 100644 index 04db42421..000000000 --- a/tests/map/map-viewer-selection-stability.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import * as THREE from 'three'; -import { applyBucketCulling, forceBucketVisible } from '../../src/map/isometric/client/culling.js'; - -const geometry = new THREE.BoxGeometry(1, 1, 1); -const material = new THREE.MeshBasicMaterial({ color: 0xffffff }); -const mesh = new THREE.InstancedMesh(geometry, material, 1); -const baseMatrix = new THREE.Matrix4().makeTranslation(0, 0, 0); -mesh.setMatrixAt(0, baseMatrix); -mesh.instanceMatrix.needsUpdate = true; - -const bucket = { - mesh, - instances: [{ index: 0, baseMatrix }], - sphere: new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1), - visible: true -}; - -const hiddenMatrix = new THREE.Matrix4().makeScale(0, 0, 0); -applyBucketCulling({ frustum: { intersectsSphere: () => false }, buckets: [bucket], hiddenMatrix }); - -forceBucketVisible(bucket); -const visibleCheck = new THREE.Matrix4(); -mesh.getMatrixAt(0, visibleCheck); -assert.ok(visibleCheck.equals(baseMatrix), 'expected selected bucket to restore instance'); - -console.log('map viewer selection stability test passed'); diff --git a/tests/map/map-viewer-telemetry.test.js b/tests/map/map-viewer-telemetry.test.js deleted file mode 100644 index 02eaa940b..000000000 --- a/tests/map/map-viewer-telemetry.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { updatePerfStats } from '../../src/map/isometric/client/telemetry.js'; - -const perfStats = { droppedFrames: 0 }; -let fpsState = { start: 0, frames: 0 }; - -const step = (now, frameMs) => { - const result = updatePerfStats({ - perfStats, - now, - frameMs, - budgetMs: 18, - fpsState, - heapUsed: 1024 * 1024 - }); - Object.assign(perfStats, result.stats); - fpsState = result.fpsState; -}; - -step(0, 16); -step(16, 22); -step(32, 40); - -assert.ok(perfStats.droppedFrames >= 1, 'expected dropped frame count'); -assert.ok(perfStats.frameMs > 0, 'expected frameMs'); -assert.ok(perfStats.heapUsed, 'expected heapUsed'); - -console.log('map viewer telemetry test passed'); diff --git a/tests/map/viewer-contract-matrix.test.js b/tests/map/viewer-contract-matrix.test.js new file mode 100644 index 000000000..bf04153b5 --- /dev/null +++ b/tests/map/viewer-contract-matrix.test.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import * as THREE from 'three'; + +import { DEFAULT_EDGE_WEIGHTS, VIEWER_DEFAULTS } from '../../src/map/constants.js'; +import { applyBucketCulling, forceBucketVisible } from '../../src/map/isometric/client/culling.js'; +import { applyDisplayLimits } from '../../src/map/isometric/client/display-limits.js'; +import { + controlDefaults, + defaultEdgeWeights, + layoutDefaults, + performanceDefaults, + visualDefaults +} from '../../src/map/isometric/client/defaults.js'; +import { resolveLodTier } from '../../src/map/isometric/client/lod.js'; +import { buildMeshes } from '../../src/map/isometric/client/meshes.js'; +import { state } from '../../src/map/isometric/client/state.js'; +import { updatePerfStats } from '../../src/map/isometric/client/telemetry.js'; + +const resetState = () => { + Object.assign(state, { + THREE, + visuals: { ...visualDefaults, glass: { ...visualDefaults.glass } }, + visualDefaults, + performance: { ...performanceDefaults, drawCaps: { ...performanceDefaults.drawCaps } }, + allFiles: [], + layoutMetrics: { + labelOffset: 0.1, + memberCell: 1, + memberGap: 0.1, + baseSize: 3, + memberInset: 0.3, + routingPadding: 1, + routingStep: 1 + }, + fileGroup: new THREE.Group(), + memberGroup: new THREE.Group(), + labelGroup: new THREE.Group(), + wireGroup: new THREE.Group(), + fileMeshes: [], + fileInstancedMeshes: [], + fileInstancedInnerMeshes: [], + chunkMeshes: [], + memberInstancedMeshes: [], + memberClusters: [], + memberInstanceById: new Map(), + memberClusterByMemberId: new Map(), + fileInstanceByKey: new Map(), + fileBuckets: [], + fileBucketByKey: new Map(), + highlightedMemberIds: new Set(), + highlightedFileKeys: new Set(), + fileAnchors: new Map(), + memberAnchors: new Map(), + fileColorByPath: new Map(), + memberColorById: new Map(), + fileWireByKey: new Map(), + wireByMesh: new Map(), + normalMapState: { texture: null }, + glowMaterials: [], + glassMaterials: [], + wireMaterials: [], + scoreToColor: () => new THREE.Color(0xffffff) + }); + state.labelGroup.visible = false; +}; + +const cases = [ + { + name: 'shared isometric defaults preserve aligned values and intentional client overrides', + run() { + assert.deepEqual(layoutDefaults, VIEWER_DEFAULTS.layout); + assert.deepEqual(defaultEdgeWeights, DEFAULT_EDGE_WEIGHTS); + assert.deepEqual(visualDefaults.glass, VIEWER_DEFAULTS.visuals.glass); + assert.deepEqual(controlDefaults.wasd, VIEWER_DEFAULTS.controls.wasd); + + assert.equal(visualDefaults.enableFlowLights, false); + assert.equal(VIEWER_DEFAULTS.visuals.enableFlowLights, true); + assert.equal(visualDefaults.enableExtraLights, false); + assert.equal(VIEWER_DEFAULTS.visuals.enableExtraLights, true); + assert.equal(controlDefaults.zoomSensitivity, 18); + assert.equal(VIEWER_DEFAULTS.controls.zoomSensitivity, 6); + assert.equal(controlDefaults.zoomMin, 0.05); + assert.equal(VIEWER_DEFAULTS.controls.zoomMin, 1); + } + }, + { + name: 'bucket culling hides and restores instances and force-visible overrides culling', + run() { + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = new THREE.MeshBasicMaterial({ color: 0xffffff }); + const mesh = new THREE.InstancedMesh(geometry, material, 1); + const baseMatrix = new THREE.Matrix4().makeTranslation(0, 0, 0); + mesh.setMatrixAt(0, baseMatrix); + mesh.instanceMatrix.needsUpdate = true; + + const bucket = { + mesh, + instances: [{ index: 0, baseMatrix }], + sphere: new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1), + visible: true + }; + const hiddenMatrix = new THREE.Matrix4().makeScale(0, 0, 0); + + applyBucketCulling({ frustum: { intersectsSphere: () => false }, buckets: [bucket], hiddenMatrix }); + const hiddenCheck = new THREE.Matrix4(); + mesh.getMatrixAt(0, hiddenCheck); + assert.ok(hiddenCheck.equals(hiddenMatrix)); + + applyBucketCulling({ frustum: { intersectsSphere: () => true }, buckets: [bucket], hiddenMatrix }); + const visibleCheck = new THREE.Matrix4(); + mesh.getMatrixAt(0, visibleCheck); + assert.ok(visibleCheck.equals(baseMatrix)); + + applyBucketCulling({ frustum: { intersectsSphere: () => false }, buckets: [bucket], hiddenMatrix }); + forceBucketVisible(bucket); + const forcedCheck = new THREE.Matrix4(); + mesh.getMatrixAt(0, forcedCheck); + assert.ok(forcedCheck.equals(baseMatrix)); + + const malformedBucket = { + mesh, + instances: { broken: true }, + sphere: new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1), + visible: true + }; + assert.doesNotThrow(() => applyBucketCulling({ frustum: { intersectsSphere: () => false }, buckets: [malformedBucket], hiddenMatrix })); + } + }, + { + name: 'display limits truncate files members and edges consistently', + run() { + const map = { + nodes: [ + { path: 'src/a.js', name: 'a.js', members: [{ id: 'a1' }, { id: 'a2' }] }, + { path: 'src/b.js', name: 'b.js', members: [{ id: 'b1' }] } + ], + edges: [ + { type: 'call', from: { file: 'src/a.js', member: 'a1' }, to: { file: 'src/b.js', member: 'b1' } }, + { type: 'call', from: { file: 'src/a.js', member: 'a2' }, to: { file: 'src/b.js', member: 'b1' } } + ] + }; + + const { map: limited, limits } = applyDisplayLimits(map, { + maxFiles: 2, + maxMembersPerFile: 1, + maxEdges: 1 + }); + + assert.equal(limits.maxFiles, 2); + assert.equal(limited.nodes.length, 2); + assert.equal(limited.nodes[0].members.length, 1); + assert.equal(limited.edges.length, 1); + assert.ok(limited.summary.truncated); + } + }, + { + name: 'instanced mesh building tracks file and member instance counts', + run() { + resetState(); + state.allFiles = [ + { + node: { path: 'src/a.js', name: 'a.js', category: 'source', id: 'a' }, + shape: 'box', + x: 0, + z: 0, + width: 2, + depth: 2, + height: 1, + topY: 1, + memberSlots: [{ x: 0, z: 0 }], + members: [{ height: 0.6, footprint: 0.5, score: 0.5, shape: 'square', member: { id: 'm1', name: 'alpha', file: 'src/a.js' } }] + }, + { + node: { path: 'src/b.js', name: 'b.js', category: 'source', id: 'b' }, + shape: 'box', + x: 4, + z: 0, + width: 2, + depth: 2, + height: 1, + topY: 1, + memberSlots: [{ x: 0, z: 0 }], + members: [{ height: 0.6, footprint: 0.5, score: 0.6, shape: 'square', member: { id: 'm2', name: 'beta', file: 'src/b.js' } }] + } + ]; + + buildMeshes(); + + assert.ok(state.fileInstancedMeshes.length > 0); + assert.equal(state.fileInstanceByKey.size, 2); + assert.ok(state.memberInstancedMeshes.length > 0); + } + }, + { + name: 'lod switching and telemetry respond to load and dropped frames', + run() { + const perf = { ...performanceDefaults, lod: { ...performanceDefaults.lod } }; + assert.equal(resolveLodTier({ zoom: 30, edgeCount: 1000, frameMs: 8, performance: perf }), 'full'); + assert.equal(resolveLodTier({ zoom: 10, edgeCount: 4000, frameMs: 20, performance: perf }), 'simplified'); + assert.equal(resolveLodTier({ zoom: 4, edgeCount: 15000, frameMs: 40, performance: perf }), 'hidden'); + + const perfStats = { droppedFrames: 0 }; + let fpsState = { start: 0, frames: 0 }; + const step = (now, frameMs) => { + const result = updatePerfStats({ + perfStats, + now, + frameMs, + budgetMs: 18, + fpsState, + heapUsed: 1024 * 1024 + }); + Object.assign(perfStats, result.stats); + fpsState = result.fpsState; + }; + + step(0, 16); + step(16, 22); + step(32, 40); + + assert.ok(perfStats.droppedFrames >= 1); + assert.ok(perfStats.frameMs > 0); + assert.ok(perfStats.heapUsed); + } + } +]; + +for (const entry of cases) { + entry.run(); +} + +console.log('map viewer contract matrix test passed'); diff --git a/tests/ops/failure-injection/retrieval-hotpath.test.js b/tests/ops/failure-injection/retrieval-hotpath.test.js index 697035ed9..d776bafc8 100644 --- a/tests/ops/failure-injection/retrieval-hotpath.test.js +++ b/tests/ops/failure-injection/retrieval-hotpath.test.js @@ -7,7 +7,7 @@ import { classifyOperationalFailure, resetOperationalFailureInjectionState, runWithOperationalFailurePolicy -} from '../../../src/shared/ops-failure-injection.js'; +} from '../../../src/shared/ops/failure-injection.js'; const prevEnv = { PAIROFCLEATS_TESTING: process.env.PAIROFCLEATS_TESTING, diff --git a/tests/ops/health-check-contract.test.js b/tests/ops/health-check-contract.test.js index 094176662..caefec478 100644 --- a/tests/ops/health-check-contract.test.js +++ b/tests/ops/health-check-contract.test.js @@ -8,7 +8,7 @@ import { formatHealthFailure, runIndexingHealthChecks, runRetrievalHealthChecks -} from '../../src/shared/ops-health.js'; +} from '../../src/shared/ops/health.js'; import { createRunnerHelpers } from '../../src/retrieval/cli/runner.js'; const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-op-health-')); diff --git a/tests/ops/release-gates/blocker-flags-unsupported.test.js b/tests/ops/release-gates/blocker-flags-unsupported.test.js index 6a610226f..23025fada 100644 --- a/tests/ops/release-gates/blocker-flags-unsupported.test.js +++ b/tests/ops/release-gates/blocker-flags-unsupported.test.js @@ -4,23 +4,20 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; applyTestEnv(); const repoRoot = process.cwd(); const releaseCheckScript = path.join(repoRoot, 'tools', 'release', 'check.js'); +const env = applyTestEnv({ syncProcess: false }); -const runReleaseCheck = ({ cwd, args = [] }) => spawnSync( - process.execPath, +const runReleaseCheck = ({ cwd, args = [] }) => runNode( [releaseCheckScript, ...args], - { - cwd, - encoding: 'utf8', - env: { - ...process.env - } - } + 'release check unsupported blocker flags', + cwd, + env, + { stdio: 'pipe', allowFailure: true } ); const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-release-gates-')); diff --git a/tests/ops/resources/basic-growth-warning.test.js b/tests/ops/resources/basic-growth-warning.test.js index 6a23491ab..eeb2ca315 100644 --- a/tests/ops/resources/basic-growth-warning.test.js +++ b/tests/ops/resources/basic-growth-warning.test.js @@ -10,7 +10,7 @@ import { evaluateResourceGrowth, formatResourceGrowthWarning, readIndexArtifactBytes -} from '../../../src/shared/ops-resource-visibility.js'; +} from '../../../src/shared/ops/resource-visibility.js'; const MiB = 1024 * 1024; diff --git a/tests/perf/artifact-io/streaming-determinism.test.js b/tests/perf/artifact-io/streaming-determinism.test.js index 0c035cc69..1d6d67dd9 100644 --- a/tests/perf/artifact-io/streaming-determinism.test.js +++ b/tests/perf/artifact-io/streaming-determinism.test.js @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { loadJsonArrayArtifactRows } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { prepareArtifactIoTestDir, writePiecesManifest diff --git a/tests/perf/artifact-io/streaming-memory-cap.test.js b/tests/perf/artifact-io/streaming-memory-cap.test.js index d546f2eb0..6974230a3 100644 --- a/tests/perf/artifact-io/streaming-memory-cap.test.js +++ b/tests/perf/artifact-io/streaming-memory-cap.test.js @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { loadJsonArrayArtifactRows } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { prepareArtifactIoTestDir, writePiecesManifest diff --git a/tests/perf/artifact-io/streaming-vs-full.test.js b/tests/perf/artifact-io/streaming-vs-full.test.js index fbfcd5111..34925a25f 100644 --- a/tests/perf/artifact-io/streaming-vs-full.test.js +++ b/tests/perf/artifact-io/streaming-vs-full.test.js @@ -7,7 +7,7 @@ import { resolveJsonlWriteShapeHints, writeBinaryRowFrames } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { prepareArtifactIoTestDir, writePiecesManifest diff --git a/tests/perf/baseline-artifacts.test.js b/tests/perf/baseline-artifacts.test.js index 2d38bf94f..5472dffb2 100644 --- a/tests/perf/baseline-artifacts.test.js +++ b/tests/perf/baseline-artifacts.test.js @@ -3,10 +3,11 @@ import { applyTestEnv } from '../helpers/test-env.js'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig } from '../../tools/shared/dict-utils.js'; import { repoRoot } from '../helpers/root.js'; +import { runNode } from '../helpers/run-node.js'; import { makeTempDir, rmDirRecursive } from '../helpers/temp.js'; +import { createFastIndexingTestConfig } from '../helpers/fast-indexing-config.js'; const root = repoRoot(); const fixtureRoot = path.join(root, 'tests', 'fixtures', 'baseline'); @@ -17,16 +18,19 @@ const prevCacheRoot = process.env.PAIROFCLEATS_CACHE_ROOT; const env = applyTestEnv({ cacheRoot, embeddings: 'stub', + testConfig: createFastIndexingTestConfig(), extraEnv: { PAIROFCLEATS_THREADS: '1', PAIROFCLEATS_BUNDLE_THREADS: '1' } }); -const runBuild = () => spawnSync( - process.execPath, - [buildPath, '--stub-embeddings', '--repo', fixtureRoot, '--mode', 'both', '--quiet'], - { env, encoding: 'utf8' } +const runBuild = () => runNode( + [buildPath, '--stub-embeddings', '--repo', fixtureRoot, '--mode', 'code', '--quiet', '--scm-provider', 'none'], + 'baseline artifact deterministic build', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); const normalizeManifest = (raw) => { @@ -105,8 +109,7 @@ if (buildResult1.status !== 0) { process.exit(buildResult1.status ?? 1); } const firstBuildRoots = { - code: getIndexDir(fixtureRoot, 'code', userConfig), - prose: getIndexDir(fixtureRoot, 'prose', userConfig) + code: getIndexDir(fixtureRoot, 'code', userConfig) }; const buildResult2 = runBuild(); @@ -116,8 +119,7 @@ if (buildResult2.status !== 0) { process.exit(buildResult2.status ?? 1); } const secondBuildRoots = { - code: getIndexDir(fixtureRoot, 'code', userConfig), - prose: getIndexDir(fixtureRoot, 'prose', userConfig) + code: getIndexDir(fixtureRoot, 'code', userConfig) }; if (prevCacheRoot === undefined) { @@ -127,12 +129,10 @@ if (prevCacheRoot === undefined) { } const firstArtifacts = { - code: readArtifacts(firstBuildRoots.code), - prose: readArtifacts(firstBuildRoots.prose) + code: readArtifacts(firstBuildRoots.code) }; const secondArtifacts = { - code: readArtifacts(secondBuildRoots.code), - prose: readArtifacts(secondBuildRoots.prose) + code: readArtifacts(secondBuildRoots.code) }; const compareArtifacts = (label, first, second) => { @@ -152,7 +152,6 @@ const compareArtifacts = (label, first, second) => { }; compareArtifacts('code', firstArtifacts.code, secondArtifacts.code); -compareArtifacts('prose', firstArtifacts.prose, secondArtifacts.prose); await rmDirRecursive(cacheRoot); console.log('baseline determinism test passed'); diff --git a/tests/perf/bench/bench-language-default-cache-root.test.js b/tests/perf/bench/bench-language-default-cache-root.test.js deleted file mode 100644 index af283ffa6..000000000 --- a/tests/perf/bench/bench-language-default-cache-root.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { BENCH_REPO_TIMEOUT_DEFAULT_MS, parseBenchLanguageArgs } from '../../../tools/bench/language/cli.js'; -import { getCacheRoot } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const expectedDefault = path.resolve(path.join(getCacheRoot(), 'bench-language')); -const parsedDefault = parseBenchLanguageArgs([]); -assert.equal( - parsedDefault.cacheRoot, - expectedDefault, - `expected bench-language default cache root to use shared cache helper (${expectedDefault})` -); -assert.equal( - parsedDefault.benchTimeoutMs, - BENCH_REPO_TIMEOUT_DEFAULT_MS, - 'expected bench-language default timeout to use bounded repo runtime cap' -); - -const explicitRoot = resolveTestCachePath(process.cwd(), 'bench-language-explicit-cache-root'); -const parsedExplicit = parseBenchLanguageArgs([ - '--cache-root', - explicitRoot, - '--timeout-ms', - '42000' -]); -assert.equal( - parsedExplicit.cacheRoot, - path.resolve(explicitRoot), - 'expected explicit --cache-root to override shared default' -); -assert.equal( - parsedExplicit.benchTimeoutMs, - 42000, - 'expected explicit --timeout-ms to override default bench repo timeout' -); - -console.log('bench-language default cache root test passed'); diff --git a/tests/perf/bench/bench-language-duplicate-tier-warning.test.js b/tests/perf/bench/bench-language-duplicate-tier-warning.test.js deleted file mode 100644 index 4f372a19e..000000000 --- a/tests/perf/bench/bench-language-duplicate-tier-warning.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import assert from 'node:assert/strict'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-language-duplicate-tier-warning'); -const reposRoot = path.join(tempRoot, 'repos'); -const cacheRoot = path.join(tempRoot, 'cache'); -const resultsRoot = path.join(tempRoot, 'results'); -const configPath = path.join(tempRoot, 'repos.json'); -const logPath = path.join(tempRoot, 'bench.log'); -const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); -const repoId = 'test/duplicate-tier-repo'; -const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoPath, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(resultsRoot, { recursive: true }); -await fsPromises.writeFile(path.join(repoPath, 'README.md'), 'duplicate-tier warning test'); - -const config = { - javascript: { - label: 'JavaScript', - queries: queriesPath, - repos: { - small: [repoId], - huge: [repoId] - } - } -}; -await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); - -const scriptPath = path.join(root, 'tools', 'bench', 'language-repos.js'); -const result = spawnSync( - process.execPath, - [ - scriptPath, - '--config', - configPath, - '--root', - reposRoot, - '--cache-root', - cacheRoot, - '--results', - resultsRoot, - '--log', - logPath, - '--no-clone', - '--dry-run', - '--json' - ], - { encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'bench-language duplicate-tier warning test failed'); - process.exit(result.status ?? 1); -} - -const payload = JSON.parse(result.stdout || '{}'); -assert.ok(Array.isArray(payload.tasks), 'expected tasks array in bench payload'); -assert.ok(payload.tasks.length >= 1, 'expected at least one scheduled bench task'); - -const combinedLogs = await fsPromises.readFile(logPath, 'utf8'); -assert.ok( - combinedLogs.includes('appears in multiple tiers'), - 'expected duplicate-tier warning in bench config logs' -); - -console.log('bench-language duplicate-tier warning test passed'); diff --git a/tests/perf/bench/bench-language-lock.test.js b/tests/perf/bench/bench-language-lock.test.js deleted file mode 100644 index 6499fd9ac..000000000 --- a/tests/perf/bench/bench-language-lock.test.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-language-lock'); -const reposRoot = path.join(tempRoot, 'repos'); -const cacheRoot = path.join(tempRoot, 'cache'); -const resultsRoot = path.join(tempRoot, 'results'); -const configPath = path.join(tempRoot, 'repos.json'); -const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); -const repoId = 'test/lock-repo'; -const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoPath, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(resultsRoot, { recursive: true }); - -await fsPromises.writeFile(path.join(repoPath, 'README.md'), 'bench lock test'); - -const config = { - javascript: { - label: 'JavaScript', - queries: queriesPath, - repos: { - small: [repoId] - } - } -}; -await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); - -const repoCacheRoot = getRepoCacheRoot(repoPath, { cache: { root: cacheRoot } }); -const lockDir = path.join(repoCacheRoot, 'locks'); -await fsPromises.mkdir(lockDir, { recursive: true }); -await fsPromises.writeFile( - path.join(lockDir, 'index.lock'), - JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }) -); - -const scriptPath = path.join(root, 'tools', 'bench/language-repos.js'); -const result = spawnSync( - process.execPath, - [ - scriptPath, - '--config', - configPath, - '--root', - reposRoot, - '--cache-root', - cacheRoot, - '--results', - resultsRoot, - '--no-clone', - '--dry-run', - '--lock-mode', - 'fail-fast', - '--json' - ], - { encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error(result.stderr || 'bench-language-lock test failed'); - process.exit(result.status ?? 1); -} - -const payload = JSON.parse(result.stdout || '{}'); -const task = Array.isArray(payload.tasks) ? payload.tasks[0] : null; -if (!task || !task.skipped) { - console.error('Expected bench task to be skipped due to lock.'); - process.exit(1); -} -if (task.skipReason !== 'lock') { - console.error(`Expected skipReason=lock, got ${task.skipReason}`); - process.exit(1); -} - -console.log('bench-language lock test passed'); - diff --git a/tests/perf/bench/bench-language-mirror-cache.test.js b/tests/perf/bench/bench-language-mirror-cache.test.js deleted file mode 100644 index 9bd82585a..000000000 --- a/tests/perf/bench/bench-language-mirror-cache.test.js +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { - __setGitCommandRunnerForTests, - DEFAULT_MIRROR_REFRESH_MS, - resolveMirrorCacheRoot, - resolveMirrorRefreshMs, - resolveMirrorRepoPath, - shouldRefreshMirror, - tryMirrorClone -} from '../../../tools/bench/language/repos.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bench-language-mirror-cache-')); -const reposRoot = path.join(tempRoot, 'repos'); -const mirrorRoot = resolveMirrorCacheRoot({ reposRoot }); -const mirrorRepoPath = resolveMirrorRepoPath({ - mirrorCacheRoot: mirrorRoot, - repo: 'example-org/example-repo' -}); - -assert.equal( - mirrorRoot, - path.join(path.resolve(reposRoot), '.mirror-cache'), - 'expected mirror cache root to live under repos root' -); -assert.equal( - mirrorRepoPath.endsWith(`example-org__example-repo.git`), - true, - 'expected mirror path to include normalized repo slug' -); - -assert.equal( - shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: DEFAULT_MIRROR_REFRESH_MS }), - true, - 'missing mirror should be refreshable' -); - -await fs.mkdir(mirrorRepoPath, { recursive: true }); -const now = new Date(); -await fs.utimes(mirrorRepoPath, now, now); -assert.equal( - shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: 10 * 60 * 1000 }), - false, - 'recent mirror should not refresh before interval' -); - -const staleDate = new Date(Date.now() - (2 * 60 * 60 * 1000)); -await fs.utimes(mirrorRepoPath, staleDate, staleDate); -assert.equal( - shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: 60 * 60 * 1000 }), - true, - 'stale mirror should refresh after interval' -); - -assert.equal(resolveMirrorRefreshMs('60000'), 60000, 'expected numeric mirror refresh override'); -assert.equal(resolveMirrorRefreshMs('0'), 0, 'expected explicit zero mirror refresh override'); -assert.equal( - resolveMirrorRefreshMs(null, 1234), - 1234, - 'expected null mirror refresh override to use fallback' -); -assert.equal( - resolveMirrorRefreshMs(undefined, 1234), - 1234, - 'expected undefined mirror refresh override to use fallback' -); -assert.equal( - resolveMirrorRefreshMs('invalid', 1234), - 1234, - 'expected fallback mirror refresh value for invalid input' -); - -const shouldFallbackToDirectClone = (mirrorCloneResult) => ( - mirrorCloneResult?.attempted === true && mirrorCloneResult?.ok !== true -); - -try { - __setGitCommandRunnerForTests((cmd, args) => { - if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); - if (Array.isArray(args) && args[0] === '--version') { - return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; - } - if (Array.isArray(args) && args[0] === 'clone' && args[1] === '--mirror') { - const error = new Error('mirror clone timeout'); - error.code = 'ETIMEDOUT'; - error.shortMessage = 'mirror clone timed out'; - throw error; - } - throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); - }); - const timeoutResult = tryMirrorClone({ - repo: 'example-org/timeout-repo', - repoPath: path.join(tempRoot, 'timeout-repo'), - mirrorCacheRoot: mirrorRoot, - timeoutMs: 17 - }); - assert.equal(timeoutResult.ok, false, 'expected mirror timeout to fail mirror clone'); - assert.equal(timeoutResult.attempted, true, 'expected mirror timeout to report attempted mirror clone'); - assert.equal(timeoutResult.mirrorAction, 'clone-timeout', 'expected timeout action from mirror clone'); - assert.match(timeoutResult.reason, /timed out after 17ms/i, 'expected timeout reason to include timeout duration'); - assert.equal(shouldFallbackToDirectClone(timeoutResult), true, 'expected mirror timeout to trigger direct clone fallback'); - - __setGitCommandRunnerForTests((cmd, args) => { - if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); - if (Array.isArray(args) && args[0] === '--version') { - return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; - } - if (Array.isArray(args) && args[0] === 'clone' && args[1] === '--mirror') { - return { ok: false, status: 128, stdout: '', stderr: 'fatal: mirror fetch failed' }; - } - throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); - }); - const cloneErrorResult = tryMirrorClone({ - repo: 'example-org/error-repo', - repoPath: path.join(tempRoot, 'error-repo'), - mirrorCacheRoot: mirrorRoot, - timeoutMs: 25 - }); - assert.equal(cloneErrorResult.ok, false, 'expected mirror command failure to fail mirror clone'); - assert.equal(cloneErrorResult.attempted, true, 'expected mirror command failure to report attempted mirror clone'); - assert.equal(cloneErrorResult.mirrorAction, 'clone-failed', 'expected non-timeout mirror clone failure action'); - assert.match(cloneErrorResult.reason, /mirror clone failed/i, 'expected command failure reason prefix'); - assert.match(cloneErrorResult.reason, /fatal: mirror fetch failed/i, 'expected command failure details in reason'); - assert.equal(shouldFallbackToDirectClone(cloneErrorResult), true, 'expected mirror command failure to trigger direct clone fallback'); - - const refreshRepo = 'example-org/refresh-timeout-repo'; - const refreshMirrorPath = resolveMirrorRepoPath({ - mirrorCacheRoot: mirrorRoot, - repo: refreshRepo - }); - await fs.mkdir(refreshMirrorPath, { recursive: true }); - __setGitCommandRunnerForTests((cmd, args) => { - if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); - if (Array.isArray(args) && args[0] === '--version') { - return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; - } - if (Array.isArray(args) && args[0] === '-C' && args[2] === 'remote' && args[3] === 'update') { - const error = new Error('mirror refresh timeout'); - error.code = 'ETIMEDOUT'; - throw error; - } - throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); - }); - const refreshTimeoutResult = tryMirrorClone({ - repo: refreshRepo, - repoPath: path.join(tempRoot, 'refresh-timeout-repo'), - mirrorCacheRoot: mirrorRoot, - mirrorRefreshMs: 0, - timeoutMs: 23 - }); - assert.equal(refreshTimeoutResult.ok, false, 'expected mirror refresh timeout to fail mirror clone'); - assert.equal(refreshTimeoutResult.attempted, true, 'expected mirror refresh timeout to report attempted mirror clone'); - assert.equal(refreshTimeoutResult.mirrorAction, 'refresh-timeout', 'expected timeout action from mirror refresh'); - assert.match( - refreshTimeoutResult.reason, - /timed out after 23ms/i, - 'expected mirror refresh timeout reason to include timeout duration' - ); - assert.equal( - shouldFallbackToDirectClone(refreshTimeoutResult), - true, - 'expected mirror refresh timeout to trigger direct clone fallback' - ); -} finally { - __setGitCommandRunnerForTests(null); -} - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('bench-language mirror cache test passed'); diff --git a/tests/perf/bench/bench-language-process-diagnostics-stream.test.js b/tests/perf/bench/bench-language-process-diagnostics-stream.test.js deleted file mode 100644 index 3137eb918..000000000 --- a/tests/perf/bench/bench-language-process-diagnostics-stream.test.js +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; - -import { ensureTestingEnv } from '../../helpers/test-env.js'; -import { createProcessRunner } from '../../../tools/bench/language/process.js'; -import { - BENCH_DIAGNOSTIC_EVENT_TYPES, - BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION -} from '../../../tools/bench/language/logging.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); - -const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-process-diagnostics-stream'); -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const masterLogPath = path.join(tempRoot, 'run-all.log'); -const captured = []; -const logHistory = []; -const runner = createProcessRunner({ - appendLog: (line) => { - if (line) captured.push(String(line)); - }, - writeLog: () => {}, - writeLogSync: () => {}, - logHistory, - logPath: masterLogPath, - getLogPaths: () => [masterLogPath], - onProgressEvent: () => {} -}); - -const script = [ - "const progress = (payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), ...payload }));", - "progress({ level: 'error', stage: 'parse', taskId: 'stage:parse', message: 'tree-sitter parser crash while parsing src/main.c' });", - "console.error('[scm] timeout while collecting git metadata');", - "progress({ level: 'warn', stage: 'watchdog', taskId: 'stage:watchdog', message: '[tree-sitter:schedule] queue delay hotspot 1450ms' });", - "console.log('artifact tail stalled for 32000ms while writing shard');", - "const fallback = JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), level: 'warn', stage: 'parse', taskId: 'stage:parse', message: 'using fallback parser for unsupported grammar' });", - 'console.log(fallback);', - 'console.log(fallback);', - 'process.exit(0);' -].join(''); - -const result = await runner.runProcess( - 'ub050-diagnostics', - process.execPath, - ['-e', script], - { continueOnError: true } -); - -assert.equal(result.ok, true, 'expected subprocess success'); -assert.ok(result.diagnostics && typeof result.diagnostics === 'object', 'expected diagnostics summary on result'); -assert.equal( - result.diagnostics.schemaVersion, - BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, - 'expected diagnostics schema version' -); -assert.equal(result.diagnostics.eventCount, 6, 'expected six diagnostic events including fallback duplicate'); -assert.equal(result.diagnostics.countsByType.fallback_used, 2, 'expected fallback duplicate count in full stream'); - -for (const type of BENCH_DIAGNOSTIC_EVENT_TYPES) { - assert.equal( - Number(result.diagnostics.countsByType[type] || 0) > 0, - true, - `expected required diagnostic type ${type}` - ); -} - -const diagnosticsPath = path.join(tempRoot, 'run-all.diagnostics.jsonl'); -assert.equal(fs.existsSync(diagnosticsPath), true, 'expected diagnostics stream file to exist'); -assert.equal( - result.diagnostics.streamPaths.includes(diagnosticsPath), - true, - 'expected diagnostics path in process result summary' -); - -const streamLines = (await fsPromises.readFile(diagnosticsPath, 'utf8')) - .split(/\r?\n/) - .filter((line) => line.trim()); -assert.equal(streamLines.length, 6, 'expected full JSON event stream with all occurrences'); - -const streamEvents = streamLines.map((line) => JSON.parse(line)); -const fallbackEvents = streamEvents.filter((entry) => entry.eventType === 'fallback_used'); -assert.equal(fallbackEvents.length, 2, 'expected two fallback events in persisted stream'); -assert.equal( - new Set(fallbackEvents.map((entry) => entry.eventId)).size, - 1, - 'expected stable fallback event ID for dedupe/rate-limiting' -); -assert.deepEqual( - fallbackEvents.map((entry) => entry.occurrence), - [1, 2], - 'expected fallback occurrence counter to increment' -); -for (const entry of streamEvents) { - assert.match(entry.eventId, /^ub050:v1:[a-z_]+:[a-f0-9]{12}$/); - assert.equal(entry.schemaVersion, BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, 'expected schema version on stream entry'); -} - -const interactiveDiagnostics = captured - .filter((line) => line.startsWith('[diagnostics]')) - .map((line) => line.replace(/ub050:v1:[a-z_]+:[a-f0-9]{12}/g, '')) - .sort(); -assert.deepEqual( - interactiveDiagnostics, - [ - '[diagnostics] artifact_tail_stall artifact tail stalled for 32000ms while writing shard', - '[diagnostics] fallback_used using fallback parser for unsupported grammar', - '[diagnostics] parser_crash tree-sitter parser crash while parsing src/main.c', - '[diagnostics] queue_delay_hotspot [tree-sitter:schedule] queue delay hotspot 1450ms', - '[diagnostics] scm_timeout [scm] timeout while collecting git metadata' - ], - 'expected concise interactive diagnostics snapshot (deduped)' -); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - -console.log('bench language process diagnostics stream test passed'); diff --git a/tests/perf/bench/bench-language-regression-gate.test.js b/tests/perf/bench/bench-language-regression-gate.test.js deleted file mode 100644 index 6151acff1..000000000 --- a/tests/perf/bench/bench-language-regression-gate.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const root = process.cwd(); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-perf-budget-')); -const budgetPath = path.join(tempRoot, 'budget.json'); - -await fs.writeFile(budgetPath, JSON.stringify({ - schemaVersion: 1, - toleranceFraction: 0, - tests: { - 'runner/harness/copy-fixture': 5 - } -}, null, 2)); - -const runPath = path.join(root, 'tests', 'run.js'); -const result = spawnSync( - process.execPath, - [runPath, '--lane', 'all', '--match', 'runner/harness/copy-fixture', '--json', '--perf-budget-file', budgetPath], - { - cwd: root, - env: { ...process.env, PAIROFCLEATS_TESTING: '1' }, - encoding: 'utf8' - } -); - -assert.equal(result.status, 1, 'expected perf budget regression to fail run.js with exit code 1'); -assert.equal( - (result.stderr || '').includes('[perf] regression budget violations='), - true, - 'expected perf budget regression diagnostics in stderr' -); - -console.log('perf regression gate test passed'); diff --git a/tests/perf/bench/bench-language-repo-preflight.test.js b/tests/perf/bench/bench-language-repo-preflight.test.js deleted file mode 100644 index 3740a2c00..000000000 --- a/tests/perf/bench/bench-language-repo-preflight.test.js +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - __setGitCommandRunnerForTests, - buildNonInteractiveGitEnv, - ensureRepoBenchmarkReady, - parseSubmoduleStatusLines -} from '../../../tools/bench/language/repos.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const parsed = parseSubmoduleStatusLines([ - '-a1b2c3d extern/doctest (heads/main)', - ' f0f0f0f include/fmt', - '+1234567 third_party/json (v3.11.0)', - 'U89abcde bad/submodule (merge conflict)' -].join('\n')); - -assert.equal(parsed.length, 4, 'expected four parsed submodule status entries'); -assert.deepEqual( - parsed.map((entry) => ({ - marker: entry.marker, - path: entry.path, - missing: entry.missing, - dirty: entry.dirty - })), - [ - { marker: '-', path: 'extern/doctest', missing: true, dirty: false }, - { marker: ' ', path: 'include/fmt', missing: false, dirty: false }, - { marker: '+', path: 'third_party/json', missing: false, dirty: true }, - { marker: 'U', path: 'bad/submodule', missing: false, dirty: true } - ], - 'expected parser to retain marker semantics used for preflight decisions' -); - -const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-repo-preflight'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); -const missingRepo = path.join(tempRoot, 'missing-repo'); - -const summary = ensureRepoBenchmarkReady({ repoPath: missingRepo }); -assert.equal(summary.gitRepo, false, 'expected non-git dirs to skip preflight without throwing'); -assert.equal(summary.submodules.detected, 0, 'unexpected submodule detection for non-git dir'); -assert.equal(summary.lfs.pulled, false, 'unexpected lfs pull for non-git dir'); - -const env = buildNonInteractiveGitEnv({ HOME: '/tmp/home' }); -assert.equal(env.GIT_TERMINAL_PROMPT, '0', 'expected bench preflight git commands to disable prompts'); -assert.equal(env.GCM_INTERACTIVE, 'Never', 'expected bench preflight to disable interactive credential manager'); -assert.equal(env.HOME, '/tmp/home', 'expected caller env vars to remain intact'); - -const setupMockRepo = async (name, gitmodulesContent) => { - const repoPath = path.join(tempRoot, name); - await fs.mkdir(repoPath, { recursive: true }); - if (typeof gitmodulesContent === 'string') { - await fs.writeFile(path.join(repoPath, '.gitmodules'), gitmodulesContent, 'utf8'); - } - return repoPath; -}; - -const withMockGitRunner = (runner, action) => { - __setGitCommandRunnerForTests(runner); - try { - return action(); - } finally { - __setGitCommandRunnerForTests(null); - } -}; - -const sshRepoPath = await setupMockRepo( - 'ssh-rewrite', - [ - '[submodule "vendor/nvmrc"]', - ' path = vendor/nvmrc', - ' url = git@github.com:nvm-sh/nvmrc.git' - ].join('\n') -); -const sshCalls = []; -const sshLogs = []; -let sshStatusChecks = 0; -const sshSummary = withMockGitRunner((cmd, args) => { - sshCalls.push([cmd, args]); - assert.equal(cmd, 'git', 'expected git command for preflight'); - if (args[0] === '--version') { - return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'rev-parse') { - return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'submodule' && args[3] === 'status') { - sshStatusChecks += 1; - if (sshStatusChecks === 1) { - return { ok: true, status: 0, stdout: '-1234567 vendor/nvmrc\n', stderr: '' }; - } - return { ok: true, status: 0, stdout: '-1234567 vendor/nvmrc\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'submodule' && args[3] === 'sync') { - return { ok: true, status: 0, stdout: '', stderr: '' }; - } - if ( - args[0] === '-C' - && args[1] === sshRepoPath - && args[2] === '-c' - && args[3] === 'url.https://github.com/.insteadOf=git@github.com:' - && args[4] === 'submodule' - && args[5] === 'update' - ) { - return { - ok: false, - status: 128, - stdout: 'Cloning into \'vendor/nvmrc\'...\n', - stderr: [ - 'Host key verification failed.', - 'fatal: Could not read from remote repository.' - ].join('\n') - }; - } - throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); -}, () => ensureRepoBenchmarkReady({ - repoPath: sshRepoPath, - onLog: (message) => sshLogs.push(String(message || '')) -})); - -assert.equal(sshSummary.ok, false, 'expected failing submodule init to fail preflight'); -assert.equal(sshSummary.failureReason, 'preflight-submodule-init', 'expected init failure reason'); -assert.equal(sshSummary.submodules.rewriteGithubSshToHttps, true, 'expected SSH rewrite marker'); -assert.match(sshSummary.failureDetail || '', /Host key verification failed\./, 'expected stderr detail tail'); -assert.match( - sshSummary.failureDetail || '', - /Could not read from remote repository\./, - 'expected tail detail to include meaningful fatal line' -); -assert.ok( - sshCalls.some(([, args]) => args.includes('url.https://github.com/.insteadOf=git@github.com:')), - 'expected submodule update to inject HTTPS rewrite config' -); -assert.ok( - sshLogs.some((line) => line.includes('submodule init failed')), - 'expected submodule init failure to be logged' -); - -const verifyRepoPath = await setupMockRepo( - 'verify-missing', - [ - '[submodule "deps/example"]', - ' path = deps/example', - ' url = https://github.com/example/example.git' - ].join('\n') -); -let verifyStatusChecks = 0; -const verifySummary = withMockGitRunner((cmd, args) => { - assert.equal(cmd, 'git', 'expected git command for preflight'); - if (args[0] === '--version') { - return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'rev-parse') { - return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'status') { - verifyStatusChecks += 1; - if (verifyStatusChecks === 1) { - return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; - } - return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; - } - if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'sync') { - return { ok: true, status: 0, stdout: '', stderr: '' }; - } - if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'update') { - return { ok: true, status: 0, stdout: '', stderr: '' }; - } - throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); -}, () => ensureRepoBenchmarkReady({ repoPath: verifyRepoPath })); - -assert.equal(verifySummary.ok, false, 'expected unresolved submodules to fail preflight'); -assert.equal( - verifySummary.failureReason, - 'preflight-submodule-incomplete', - 'expected post-update missing submodules to fail verification' -); -assert.equal(verifySummary.submodules.initialMissing, 1, 'expected one missing submodule before update'); -assert.equal(verifySummary.submodules.missing, 1, 'expected one missing submodule after update'); -assert.equal(verifySummary.submodules.updated, false, 'expected update flag to remain false on incomplete state'); - -console.log('bench-language repo preflight parser test passed.'); diff --git a/tests/perf/bench/bench-language-repos.test.js b/tests/perf/bench/bench-language-repos.test.js deleted file mode 100644 index 208da51fd..000000000 --- a/tests/perf/bench/bench-language-repos.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-language-repos'); -const reposRoot = path.join(tempRoot, 'repos'); -const cacheRoot = path.join(tempRoot, 'cache'); -const resultsRoot = path.join(tempRoot, 'results'); -const configPath = path.join(tempRoot, 'repos.json'); -const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); -const repoId = 'test/repos-smoke'; -const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoPath, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(resultsRoot, { recursive: true }); -await fsPromises.writeFile(path.join(repoPath, 'README.md'), 'bench repos smoke'); - -const config = { - javascript: { - label: 'JavaScript', - queries: queriesPath, - repos: { - small: [repoId] - } - } -}; -await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); - -const scriptPath = path.join(root, 'tools', 'bench', 'language-repos.js'); -const result = spawnSync( - process.execPath, - [ - scriptPath, - '--config', - configPath, - '--root', - reposRoot, - '--cache-root', - cacheRoot, - '--results', - resultsRoot, - '--no-clone', - '--dry-run', - '--json' - ], - { encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error(result.stderr || 'bench-language-repos test failed'); - process.exit(result.status ?? 1); -} - -const payload = JSON.parse(result.stdout || '{}'); -assert.ok(Array.isArray(payload.tasks), 'expected tasks array in bench payload'); -assert.equal(payload.tasks.length, 1, 'expected exactly one scheduled bench task'); -assert.equal(payload.tasks[0]?.repo, repoId, 'expected synthetic repo task in bench payload'); - -console.log('bench-language repos test passed'); diff --git a/tests/perf/bench/bench-progress-format.test.js b/tests/perf/bench/bench-progress-format.test.js deleted file mode 100644 index b41d284e6..000000000 --- a/tests/perf/bench/bench-progress-format.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import { formatShardFileProgress } from '../../../src/shared/bench-progress.js'; - -const shardByLabel = new Map([['alpha', { index: 2, total: 10 }]]); -const output = formatShardFileProgress({ - shardLabel: 'alpha', - fileIndex: 5, - fileTotal: 20, - pct: 25.0, - file: 'src/app.js' -}, { shardByLabel, lineTotal: 100 }); - -if (!output.includes('[shard 2/10]')) { - console.error('bench progress format test failed: missing shard index'); - process.exit(1); -} -if (!output.includes('5/20')) { - console.error('bench progress format test failed: missing file counts'); - process.exit(1); -} -if (!output.includes('lines 100')) { - console.error('bench progress format test failed: missing line count'); - process.exit(1); -} -if (!output.includes('src/app.js')) { - console.error('bench progress format test failed: missing file path'); - process.exit(1); -} - -console.log('bench progress format test passed'); diff --git a/tests/perf/bench/embedding-model-bakeoff-resume.test.js b/tests/perf/bench/embedding-model-bakeoff-resume.test.js index b4166861c..0e1d23100 100644 --- a/tests/perf/bench/embedding-model-bakeoff-resume.test.js +++ b/tests/perf/bench/embedding-model-bakeoff-resume.test.js @@ -4,15 +4,14 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'embedding-bakeoff-resume-')); const checkpointPath = path.join(tempRoot, 'bakeoff.json'); const cacheRoot = path.join(tempRoot, 'cache'); -const runBakeoff = () => spawnSync( - process.execPath, +const runBakeoff = () => runNode( [ path.join(root, 'tools', 'bench', 'embeddings', 'model-bakeoff.js'), '--repo', @@ -28,7 +27,10 @@ const runBakeoff = () => spawnSync( checkpointPath, '--json' ], - { cwd: root, encoding: 'utf8' } + 'embedding model bakeoff resume', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); const firstRun = runBakeoff(); diff --git a/tests/perf/bench/language-closeout-exit.test.js b/tests/perf/bench/language-closeout-exit.test.js new file mode 100644 index 000000000..a2de4f713 --- /dev/null +++ b/tests/perf/bench/language-closeout-exit.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + createBenchLanguageRepoFixture, + runBenchLanguageRepos +} from './language-repos-fixture.js'; + +const repoId = 'test/closeout-repo'; +const fixture = await createBenchLanguageRepoFixture({ + name: 'bench-language-closeout-exit', + repoId, + readme: 'bench closeout exit test' +}); + +const logPath = path.join(fixture.resultsRoot, 'bench-run.log'); +const result = runBenchLanguageRepos({ + fixture, + args: ['--log', logPath, '--quiet'], + timeout: 15000 +}); + +if (result.error?.code === 'ETIMEDOUT') { + console.error('bench-language closeout exit test timed out waiting for process to exit'); + process.exit(1); +} +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'bench-language closeout exit test failed'); + process.exit(result.status ?? 1); +} +if (!fs.existsSync(logPath)) { + console.error('expected bench log path to exist'); + process.exit(1); +} +const logText = await fsPromises.readFile(logPath, 'utf8'); +if (!logText.includes('Completed 1 benchmark runs.')) { + console.error('expected completion marker in bench log'); + process.exit(1); +} +const logsRoot = path.dirname(logPath); +const logEntries = await fsPromises.readdir(logsRoot); +const summaryName = logEntries.find((entry) => entry.endsWith('-run-summary.json')); +const ledgerName = logEntries.find((entry) => entry.endsWith('-run-ledger.jsonl')); +const footerName = logEntries.find((entry) => entry.endsWith('-footer.log')); +if (!summaryName || !ledgerName || !footerName) { + console.error(`expected bench closeout artifacts in ${logsRoot}; found ${logEntries.join(', ')}`); + process.exit(1); +} +const summary = JSON.parse(await fsPromises.readFile(path.join(logsRoot, summaryName), 'utf8')); +if (summary?.run?.state !== 'completed') { + console.error(`expected completed run summary, got ${summary?.run?.state}`); + process.exit(1); +} +const footerText = await fsPromises.readFile(path.join(logsRoot, footerName), 'utf8'); +if (!footerText.includes('State: completed')) { + console.error('expected completed state in footer artifact'); + process.exit(1); +} + +console.log('bench-language closeout exit test passed'); diff --git a/tests/perf/bench/language-default-cache-root.test.js b/tests/perf/bench/language-default-cache-root.test.js new file mode 100644 index 000000000..e1a0195ac --- /dev/null +++ b/tests/perf/bench/language-default-cache-root.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { BENCH_REPO_TIMEOUT_DEFAULT_MS, parseBenchLanguageArgs } from '../../../tools/bench/language/cli.js'; +import { getCacheRoot } from '../../../tools/shared/dict-utils.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const expectedDefault = path.resolve(path.join(getCacheRoot(), 'bench-language')); +const parsedDefault = parseBenchLanguageArgs([]); +assert.equal( + parsedDefault.cacheRoot, + expectedDefault, + `expected bench-language default cache root to use shared cache helper (${expectedDefault})` +); +assert.equal( + parsedDefault.benchTimeoutMs, + BENCH_REPO_TIMEOUT_DEFAULT_MS, + 'expected bench-language default timeout to use bounded repo runtime cap' +); + +const explicitRoot = resolveTestCachePath(process.cwd(), 'bench-language-explicit-cache-root'); +const parsedExplicit = parseBenchLanguageArgs([ + '--cache-root', + explicitRoot, + '--timeout-ms', + '42000' +]); +assert.equal( + parsedExplicit.cacheRoot, + path.resolve(explicitRoot), + 'expected explicit --cache-root to override shared default' +); +assert.equal( + parsedExplicit.benchTimeoutMs, + 42000, + 'expected explicit --timeout-ms to override default bench repo timeout' +); + +const parsedCold = parseBenchLanguageArgs([ + '--cache-root', + explicitRoot, + '--mode', + 'cold' +]); +assert.match( + parsedCold.cacheRoot, + /bench-language-explicit-cache-root[\\/]cold[\\/]/, + 'expected cold mode to use an isolated cold-cache namespace' +); + +const parsedTooling = parseBenchLanguageArgs([ + '--cache-root', + explicitRoot, + '--mode', + 'tooling' +]); +assert.equal( + parsedTooling.cacheRoot, + path.resolve(explicitRoot, 'tooling'), + 'expected tooling mode to use a dedicated tooling cache namespace' +); + +console.log('bench-language default cache root test passed'); diff --git a/tests/perf/bench/language-duplicate-tier-warning.test.js b/tests/perf/bench/language-duplicate-tier-warning.test.js new file mode 100644 index 000000000..009acc717 --- /dev/null +++ b/tests/perf/bench/language-duplicate-tier-warning.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-language-duplicate-tier-warning'); +const reposRoot = path.join(tempRoot, 'repos'); +const cacheRoot = path.join(tempRoot, 'cache'); +const resultsRoot = path.join(tempRoot, 'results'); +const configPath = path.join(tempRoot, 'repos.json'); +const logPath = path.join(tempRoot, 'bench.log'); +const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); +const repoId = 'test/duplicate-tier-repo'; +const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoPath, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.mkdir(resultsRoot, { recursive: true }); +await fsPromises.writeFile(path.join(repoPath, 'README.md'), 'duplicate-tier warning test'); + +const config = { + javascript: { + label: 'JavaScript', + queries: queriesPath, + repos: { + small: [repoId], + huge: [repoId] + } + } +}; +await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); + +const scriptPath = path.join(root, 'tools', 'bench', 'language-repos.js'); +const result = runNode( + [ + scriptPath, + '--config', + configPath, + '--root', + reposRoot, + '--cache-root', + cacheRoot, + '--results', + resultsRoot, + '--log', + logPath, + '--no-clone', + '--dry-run', + '--json' + ], + 'bench language duplicate-tier warning', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'bench-language duplicate-tier warning test failed'); + process.exit(result.status ?? 1); +} + +const payload = JSON.parse(result.stdout || '{}'); +assert.ok(Array.isArray(payload.tasks), 'expected tasks array in bench payload'); +assert.ok(payload.tasks.length >= 1, 'expected at least one scheduled bench task'); + +const combinedLogs = await fsPromises.readFile(logPath, 'utf8'); +assert.ok( + combinedLogs.includes('appears in multiple tiers'), + 'expected duplicate-tier warning in bench config logs' +); + +console.log('bench-language duplicate-tier warning test passed'); diff --git a/tests/perf/bench/language-interrupted-closeout.test.js b/tests/perf/bench/language-interrupted-closeout.test.js new file mode 100644 index 000000000..ce2f43f63 --- /dev/null +++ b/tests/perf/bench/language-interrupted-closeout.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildBenchRunSummaryFromLedgerEvents, formatBenchRunFooter } from '../../../tools/bench/language-repos/run-ledger.js'; + +const events = [ + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:00.000Z', + eventType: 'run.started', + payload: { + runSuffix: 'run-interrupted-fixture', + plannedRepoCount: 2, + taskCount: 2 + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:01.000Z', + eventType: 'repo.started', + payload: { + language: 'javascript', + tier: 'small', + repo: 'owner/repo-one', + repoPath: 'C:\\repo-one' + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:02.000Z', + eventType: 'repo.completed', + payload: { + result: { + language: 'javascript', + tier: 'small', + repo: 'owner/repo-one', + repoPath: 'C:\\repo-one', + outFile: 'C:\\repo-one.json', + failed: false, + skipped: false, + diagnostics: {} + } + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:03.000Z', + eventType: 'repo.started', + payload: { + language: 'go', + tier: 'large', + repo: 'owner/repo-two', + repoPath: 'C:\\repo-two' + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:04.000Z', + eventType: 'closeout.started', + payload: { + state: 'interrupted', + reason: 'SIGINT' + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:10:05.000Z', + eventType: 'run.ended', + payload: { + state: 'interrupted', + reason: 'SIGINT', + signal: 'SIGINT', + exitCode: 130 + } + } +]; + +const summary = await buildBenchRunSummaryFromLedgerEvents({ + events, + diagnosticsRoot: null, + runSuffix: 'run-interrupted-fixture' +}); +const footerLines = formatBenchRunFooter(summary); + +assert.equal(summary.run.state, 'interrupted', 'expected interrupted state'); +assert.equal(summary.run.reason, 'SIGINT', 'expected SIGINT reason'); +assert.equal(summary.counts.planned, 2, 'expected planned count'); +assert.equal(summary.counts.finished, 1, 'expected one finished repo'); +assert.equal(summary.counts.unfinished, 1, 'expected one unfinished repo'); +assert.equal(summary.unfinishedRepos.length, 1, 'expected unfinished repo listing'); +assert.equal(summary.unfinishedRepos[0].repo, 'owner/repo-two', 'expected second repo to remain unfinished'); +assert.equal( + footerLines.some((line) => line.includes('State: interrupted')), + true, + 'expected interrupted footer line' +); + +console.log('bench-language interrupted closeout test passed'); diff --git a/tests/perf/bench/bench-language-lock-semantics.test.js b/tests/perf/bench/language-lock-semantics.test.js similarity index 100% rename from tests/perf/bench/bench-language-lock-semantics.test.js rename to tests/perf/bench/language-lock-semantics.test.js diff --git a/tests/perf/bench/language-lock.test.js b/tests/perf/bench/language-lock.test.js new file mode 100644 index 000000000..7c5dddb2d --- /dev/null +++ b/tests/perf/bench/language-lock.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-language-lock'); +const reposRoot = path.join(tempRoot, 'repos'); +const cacheRoot = path.join(tempRoot, 'cache'); +const resultsRoot = path.join(tempRoot, 'results'); +const configPath = path.join(tempRoot, 'repos.json'); +const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); +const repoId = 'test/lock-repo'; +const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoPath, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.mkdir(resultsRoot, { recursive: true }); + +await fsPromises.writeFile(path.join(repoPath, 'README.md'), 'bench lock test'); + +const config = { + javascript: { + label: 'JavaScript', + queries: queriesPath, + repos: { + small: [repoId] + } + } +}; +await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); + +const repoCacheRoot = getRepoCacheRoot(repoPath, { cache: { root: cacheRoot } }); +const lockDir = path.join(repoCacheRoot, 'locks'); +await fsPromises.mkdir(lockDir, { recursive: true }); +await fsPromises.writeFile( + path.join(lockDir, 'index.lock'), + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }) +); + +const scriptPath = path.join(root, 'tools', 'bench/language-repos.js'); +const result = runNode( + [ + scriptPath, + '--config', + configPath, + '--root', + reposRoot, + '--cache-root', + cacheRoot, + '--results', + resultsRoot, + '--no-clone', + '--dry-run', + '--lock-mode', + 'fail-fast', + '--json' + ], + 'bench language lock dry-run', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stderr || 'bench-language-lock test failed'); + process.exit(result.status ?? 1); +} + +const payload = JSON.parse(result.stdout || '{}'); +const task = Array.isArray(payload.tasks) ? payload.tasks[0] : null; +if (!task || !task.skipped) { + console.error('Expected bench task to be skipped due to lock.'); + process.exit(1); +} +if (task.skipReason !== 'lock') { + console.error(`Expected skipReason=lock, got ${task.skipReason}`); + process.exit(1); +} + +console.log('bench-language lock test passed'); + diff --git a/tests/perf/bench/language-log-closeout.test.js b/tests/perf/bench/language-log-closeout.test.js new file mode 100644 index 000000000..097a70892 --- /dev/null +++ b/tests/perf/bench/language-log-closeout.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { createBenchLanguageLogFixture } from './language-log-fixture.js'; + +const { logger, masterLogPath, reposRoot } = await createBenchLanguageLogFixture('run-log-closeout'); + +const { + initMasterLog, + initRepoLog, + flushLogs, + writeLog, + closeRepoLog, + closeMasterLog, + getRepoLogPath +} = logger; + +initMasterLog(); +writeLog('[test] before repo'); +const firstRepoLog = await initRepoLog({ + label: 'owner/repo-one', + tier: 'small', + repoPath: path.join(reposRoot, 'repo-one'), + slug: 'repo-one' +}); +writeLog('[test] repo one line'); +await flushLogs(); +const firstRepoTextMidRun = await fsPromises.readFile(firstRepoLog, 'utf8'); +assert.match(firstRepoTextMidRun, /\[test\] repo one line/, 'repo one log should be inspectable before rotation'); + +const secondRepoLog = await initRepoLog({ + label: 'owner/repo-two', + tier: 'small', + repoPath: path.join(reposRoot, 'repo-two'), + slug: 'repo-two' +}); +writeLog('[test] repo two final line'); +await flushLogs(); + +assert.ok(firstRepoLog && secondRepoLog, 'expected repo log paths'); +assert.notEqual(firstRepoLog, secondRepoLog, 'expected unique per-repo log paths'); + +await closeRepoLog(); +assert.equal(getRepoLogPath(), null, 'expected repo log path cleared after close'); +await closeMasterLog(); + +const [masterText, firstRepoText, secondRepoText] = await Promise.all([ + fsPromises.readFile(masterLogPath, 'utf8'), + fsPromises.readFile(firstRepoLog, 'utf8'), + fsPromises.readFile(secondRepoLog, 'utf8') +]); +assert.match(masterText, /\[test\] repo one line/, 'master log missing repo one line'); +assert.match(masterText, /\[test\] repo two final line/, 'master log missing repo two line'); +assert.match(firstRepoText, /\[test\] repo one line/, 'repo one log missing expected line'); +assert.doesNotMatch(firstRepoText, /\[test\] repo two final line/, 'repo one log should not contain repo two line'); +assert.match(secondRepoText, /\[test\] repo two final line/, 'repo two log missing expected line'); + +console.log('bench-language log closeout test passed'); diff --git a/tests/perf/bench/language-log-emergency-close.test.js b/tests/perf/bench/language-log-emergency-close.test.js new file mode 100644 index 000000000..74ac2aeb6 --- /dev/null +++ b/tests/perf/bench/language-log-emergency-close.test.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { createBenchLanguageLogFixture } from './language-log-fixture.js'; + +const { logger, masterLogPath, reposRoot } = await createBenchLanguageLogFixture('run-log-emergency-close'); + +logger.initMasterLog(); +const repoLogPath = await logger.initRepoLog({ + label: 'owner/repo-emergency', + tier: 'small', + repoPath: path.join(reposRoot, 'repo-emergency'), + slug: 'repo-emergency' +}); +logger.writeLog('[test] emergency close line'); +logger.closeLogsSync(); + +const [masterText, repoText] = await Promise.all([ + fsPromises.readFile(masterLogPath, 'utf8'), + fsPromises.readFile(repoLogPath, 'utf8') +]); +assert.match(masterText, /\[test\] emergency close line/, 'expected emergency close to preserve master log line'); +assert.match(repoText, /\[test\] emergency close line/, 'expected emergency close to preserve repo log line'); + +console.log('bench language log emergency close test passed'); diff --git a/tests/perf/bench/language-log-fixture.js b/tests/perf/bench/language-log-fixture.js new file mode 100644 index 000000000..1222e28c9 --- /dev/null +++ b/tests/perf/bench/language-log-fixture.js @@ -0,0 +1,41 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { createBenchLogger } from '../../../tools/bench/language-repos/logging.js'; + +const silentDisplay = { + log: () => {}, + warn: () => {}, + error: () => {}, + logLine: () => {} +}; + +export const createBenchLanguageLogFixture = async (runSuffix) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, `bench-language-${runSuffix}`); + const reposRoot = path.join(tempRoot, 'repos'); + const cacheRoot = path.join(tempRoot, 'cache'); + const resultsRoot = path.join(tempRoot, 'results'); + const masterLogPath = path.join(resultsRoot, 'logs', 'bench-language', `${runSuffix}.log`); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(reposRoot, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.mkdir(resultsRoot, { recursive: true }); + + return { + masterLogPath, + reposRoot, + logger: createBenchLogger({ + display: silentDisplay, + configPath: path.join(tempRoot, 'repos.json'), + reposRoot, + cacheRoot, + resultsRoot, + masterLogPath, + runSuffix, + repoLogsEnabled: true + }) + }; +}; diff --git a/tests/perf/bench/language-mirror-cache.test.js b/tests/perf/bench/language-mirror-cache.test.js new file mode 100644 index 000000000..3095e7c57 --- /dev/null +++ b/tests/perf/bench/language-mirror-cache.test.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + __setGitCommandRunnerForTests, + DEFAULT_MIRROR_CHECKOUT_TIMEOUT_MS, + DEFAULT_MIRROR_CLONE_TIMEOUT_MS, + DEFAULT_MIRROR_REFRESH_MS, + DEFAULT_MIRROR_REFRESH_TIMEOUT_MS, + DEFAULT_MIRROR_TIMEOUT_MS, + resolveMirrorCacheRoot, + resolveMirrorRefreshMs, + resolveMirrorRepoPath, + shouldRefreshMirror, + tryMirrorClone +} from '../../../tools/bench/language/repos.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bench-language-mirror-cache-')); +const reposRoot = path.join(tempRoot, 'repos'); +const mirrorRoot = resolveMirrorCacheRoot({ reposRoot }); +const mirrorRepoPath = resolveMirrorRepoPath({ + mirrorCacheRoot: mirrorRoot, + repo: 'example-org/example-repo' +}); + +assert.equal( + mirrorRoot, + path.join(path.resolve(reposRoot), '.mirror-cache'), + 'expected mirror cache root to live under repos root' +); +assert.equal( + mirrorRepoPath.endsWith(`example-org__example-repo.git`), + true, + 'expected mirror path to include normalized repo slug' +); + +assert.equal( + shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: DEFAULT_MIRROR_REFRESH_MS }), + true, + 'missing mirror should be refreshable' +); + +await fs.mkdir(mirrorRepoPath, { recursive: true }); +const now = new Date(); +await fs.utimes(mirrorRepoPath, now, now); +assert.equal( + shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: 10 * 60 * 1000 }), + false, + 'recent mirror should not refresh before interval' +); + +const staleDate = new Date(Date.now() - (2 * 60 * 60 * 1000)); +await fs.utimes(mirrorRepoPath, staleDate, staleDate); +assert.equal( + shouldRefreshMirror({ mirrorPath: mirrorRepoPath, refreshMs: 60 * 60 * 1000 }), + true, + 'stale mirror should refresh after interval' +); + +assert.equal(resolveMirrorRefreshMs('60000'), 60000, 'expected numeric mirror refresh override'); +assert.equal(resolveMirrorRefreshMs('0'), 0, 'expected explicit zero mirror refresh override'); +assert.equal( + resolveMirrorRefreshMs(null, 1234), + 1234, + 'expected null mirror refresh override to use fallback' +); +assert.equal( + resolveMirrorRefreshMs(undefined, 1234), + 1234, + 'expected undefined mirror refresh override to use fallback' +); +assert.equal( + resolveMirrorRefreshMs('invalid', 1234), + 1234, + 'expected fallback mirror refresh value for invalid input' +); +assert.equal(DEFAULT_MIRROR_CLONE_TIMEOUT_MS > DEFAULT_MIRROR_TIMEOUT_MS, true, 'expected mirror clone timeout to exceed legacy single timeout'); +assert.equal(DEFAULT_MIRROR_REFRESH_TIMEOUT_MS > DEFAULT_MIRROR_TIMEOUT_MS, true, 'expected mirror refresh timeout to exceed legacy single timeout'); +assert.equal(DEFAULT_MIRROR_CHECKOUT_TIMEOUT_MS > DEFAULT_MIRROR_TIMEOUT_MS, true, 'expected mirror checkout timeout to exceed legacy single timeout'); + +const shouldFallbackToDirectClone = (mirrorCloneResult) => ( + mirrorCloneResult?.attempted === true && mirrorCloneResult?.ok !== true +); + +try { + __setGitCommandRunnerForTests((cmd, args) => { + if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); + if (Array.isArray(args) && args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; + } + if (Array.isArray(args) && args[0] === 'clone' && args[1] === '--mirror') { + const error = new Error('mirror clone timeout'); + error.code = 'ETIMEDOUT'; + error.shortMessage = 'mirror clone timed out'; + throw error; + } + throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); + }); + const timeoutResult = tryMirrorClone({ + repo: 'example-org/timeout-repo', + repoPath: path.join(tempRoot, 'timeout-repo'), + mirrorCacheRoot: mirrorRoot, + timeoutMs: 17 + }); + assert.equal(timeoutResult.ok, false, 'expected mirror timeout to fail mirror clone'); + assert.equal(timeoutResult.attempted, true, 'expected mirror timeout to report attempted mirror clone'); + assert.equal(timeoutResult.mirrorAction, 'clone-timeout', 'expected timeout action from mirror clone'); + assert.match(timeoutResult.reason, /timed out after 17ms/i, 'expected timeout reason to include timeout duration'); + assert.equal(shouldFallbackToDirectClone(timeoutResult), true, 'expected mirror timeout to trigger direct clone fallback'); + + __setGitCommandRunnerForTests((cmd, args) => { + if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); + if (Array.isArray(args) && args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; + } + if (Array.isArray(args) && args[0] === 'clone' && args[1] === '--mirror') { + return { ok: false, status: 128, stdout: '', stderr: 'fatal: mirror fetch failed' }; + } + throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); + }); + const cloneErrorResult = tryMirrorClone({ + repo: 'example-org/error-repo', + repoPath: path.join(tempRoot, 'error-repo'), + mirrorCacheRoot: mirrorRoot, + timeoutMs: 25 + }); + assert.equal(cloneErrorResult.ok, false, 'expected mirror command failure to fail mirror clone'); + assert.equal(cloneErrorResult.attempted, true, 'expected mirror command failure to report attempted mirror clone'); + assert.equal(cloneErrorResult.mirrorAction, 'clone-failed', 'expected non-timeout mirror clone failure action'); + assert.match(cloneErrorResult.reason, /mirror clone failed/i, 'expected command failure reason prefix'); + assert.match(cloneErrorResult.reason, /fatal: mirror fetch failed/i, 'expected command failure details in reason'); + assert.equal(shouldFallbackToDirectClone(cloneErrorResult), true, 'expected mirror command failure to trigger direct clone fallback'); + + const refreshRepo = 'example-org/refresh-timeout-repo'; + const refreshMirrorPath = resolveMirrorRepoPath({ + mirrorCacheRoot: mirrorRoot, + repo: refreshRepo + }); + await fs.mkdir(refreshMirrorPath, { recursive: true }); + __setGitCommandRunnerForTests((cmd, args) => { + if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); + if (Array.isArray(args) && args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.46.0', stderr: '' }; + } + if (Array.isArray(args) && args[0] === '-C' && args[2] === 'remote' && args[3] === 'update') { + const error = new Error('mirror refresh timeout'); + error.code = 'ETIMEDOUT'; + throw error; + } + throw new Error(`unexpected git args: ${Array.isArray(args) ? args.join(' ') : ''}`); + }); + const refreshTimeoutResult = tryMirrorClone({ + repo: refreshRepo, + repoPath: path.join(tempRoot, 'refresh-timeout-repo'), + mirrorCacheRoot: mirrorRoot, + mirrorRefreshMs: 0, + timeoutMs: 23 + }); + assert.equal(refreshTimeoutResult.ok, false, 'expected mirror refresh timeout to fail mirror clone'); + assert.equal(refreshTimeoutResult.attempted, true, 'expected mirror refresh timeout to report attempted mirror clone'); + assert.equal(refreshTimeoutResult.mirrorAction, 'refresh-timeout', 'expected timeout action from mirror refresh'); + assert.match( + refreshTimeoutResult.reason, + /timed out after 23ms/i, + 'expected mirror refresh timeout reason to include timeout duration' + ); + assert.equal( + shouldFallbackToDirectClone(refreshTimeoutResult), + true, + 'expected mirror refresh timeout to trigger direct clone fallback' + ); +} finally { + __setGitCommandRunnerForTests(null); +} + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench-language mirror cache test passed'); diff --git a/tests/perf/bench/language-ownership.test.js b/tests/perf/bench/language-ownership.test.js new file mode 100644 index 000000000..47a1efa1d --- /dev/null +++ b/tests/perf/bench/language-ownership.test.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +ensureTestingEnv(process.env); + +const buildStageTimingProfile = ({ + scanMs = 0, + schedulerMs = 0, + artifactCloseoutMs = 0, + sqliteMs = 0 +} = {}) => ({ + schemaVersion: 1, + stages: { + discovery: Math.floor(scanMs / 3), + importScan: Math.floor(scanMs / 3), + scmMeta: scanMs - (Math.floor(scanMs / 3) * 2), + parseChunk: 0, + inference: 0, + artifactWrite: artifactCloseoutMs, + embedding: 0, + sqliteBuild: sqliteMs + }, + stageTotalMs: scanMs + artifactCloseoutMs + sqliteMs, + breakdown: { + parseChunk: { totalMs: 0, byLanguage: {}, bySizeBin: {} }, + inference: { totalMs: 0, byLanguage: {}, bySizeBin: {} }, + embedding: { totalMs: 0, byLanguage: {}, bySizeBin: {} } + }, + watchdog: { + queueDelayMs: { + summary: { + count: schedulerMs > 0 ? 1 : 0, + totalMs: schedulerMs, + minMs: schedulerMs, + maxMs: schedulerMs, + avgMs: schedulerMs + }, + histogram: { + bucketsMs: [], + counts: [], + overflow: 0 + } + } + } +}); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: '/tmp/results', + methodology: { + mode: 'warm', + cacheMode: 'warm', + toolingMode: 'disabled', + corpusVersion: 'repos-fixture', + policyVersion: 'bench-language-methodology-v1', + controlSlice: { taskIds: [] } + }, + config: { + python: { label: 'Python' }, + shell: { label: 'Shell' }, + rust: { label: 'Rust' } + }, + results: [ + { + language: 'python', + tier: 'large', + repo: 'django/django', + summary: { + backends: ['memory', 'sqlite'], + latencyMsAvg: { memory: 8, sqlite: 15 }, + hitRate: { memory: 0.55, sqlite: 0.61 }, + resultCountAvg: { memory: 3, sqlite: 3 }, + memoryRss: { + memory: { mean: 1024 }, + sqlite: { mean: 1900 * 1024 * 1024 } + }, + buildMs: { index: 280000, sqlite: 85000 } + }, + diagnostics: { + process: { + countsByType: { + artifact_tail_stall: 3, + queue_delay_hotspot: 1 + } + } + }, + stageTimingProfile: buildStageTimingProfile({ + scanMs: 55000, + schedulerMs: 4000, + artifactCloseoutMs: 140000, + sqliteMs: 85000 + }) + }, + { + language: 'shell', + tier: 'medium', + repo: 'ohmyzsh/ohmyzsh', + summary: { + backends: ['memory', 'sqlite'], + latencyMsAvg: { memory: 5, sqlite: 10 }, + hitRate: { memory: 0.6, sqlite: 0.58 }, + resultCountAvg: { memory: 2, sqlite: 2 }, + memoryRss: { + memory: { mean: 1024 }, + sqlite: { mean: 1250 * 1024 * 1024 } + }, + buildMs: { index: 220000, sqlite: 45000 } + }, + diagnostics: { + process: { + countsByType: { + queue_delay_hotspot: 2 + } + } + }, + stageTimingProfile: buildStageTimingProfile({ + scanMs: 90000, + schedulerMs: 65000, + artifactCloseoutMs: 25000, + sqliteMs: 45000 + }) + }, + { + language: 'rust', + tier: 'small', + repo: 'rust-lang/cargo', + summary: { + backends: ['memory', 'sqlite'], + latencyMsAvg: { memory: 4, sqlite: 7 }, + hitRate: { memory: 0.91, sqlite: 0.82 }, + resultCountAvg: { memory: 3, sqlite: 3 }, + memoryRss: { + memory: { mean: 1024 }, + sqlite: { mean: 700 * 1024 * 1024 } + }, + buildMs: { index: 90000, sqlite: 20000 } + }, + stageTimingProfile: buildStageTimingProfile({ + scanMs: 70000, + schedulerMs: 3000, + artifactCloseoutMs: 12000, + sqliteMs: 20000 + }) + } + ] +}); + +assert.equal(output.overallSummary?.reuse?.mode, 'warm', 'expected reuse summary mode'); +assert.equal(output.overallSummary?.reuse?.coldStart?.averageHitRate, null, 'expected cold-start lane to be disabled for warm mode'); +assert.equal(output.overallSummary?.reuse?.intraRun?.averageHitRate, 0.6867, 'expected intra-run reuse average'); +assert.equal(output.overallSummary?.reuse?.crossRun?.averageHitRate, 0.67, 'expected cross-run reuse average'); +assert.equal(output.ownership?.policyVersion, 'bench-language-family-ownership-v1', 'expected ownership policy version'); + +const scriptingFamily = output.ownership?.families?.find((entry) => ( + Array.isArray(entry?.topOffenders) + && entry.topOffenders.some((repo) => repo.repo === 'django/django') +)); +assert.ok(scriptingFamily, 'expected scripting family ownership summary'); +assert.equal(scriptingFamily.phaseOwnership?.dominantPhase, 'artifactCloseout', 'expected artifact closeout dominant phase for scripting family'); +assert.equal(scriptingFamily.guardrails?.breachedCount >= 4, true, 'expected multiple scripting budget breaches'); +assert.equal(scriptingFamily.reuse?.intraRun?.guardrail?.status, 'breached', 'expected intra-run reuse guardrail breach'); +assert.equal(scriptingFamily.rss?.guardrail?.status, 'breached', 'expected sqlite RSS guardrail breach'); +assert.equal( + scriptingFamily.topOffenders[0]?.issues.includes('artifact_tail_stall'), + true, + 'expected top offender to capture artifact closeout issue' +); + +console.log('bench language ownership test passed'); diff --git a/tests/perf/bench/language-policy.test.js b/tests/perf/bench/language-policy.test.js new file mode 100644 index 000000000..f2c6f3380 --- /dev/null +++ b/tests/perf/bench/language-policy.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + BENCH_METHODOLOGY_POLICY_VERSION, + createBenchMethodologyPolicy, + filterTasksToControlSlice, + selectBenchControlSlice +} from '../../../tools/bench/language/policy.js'; + +const tasks = [ + { language: 'javascript', tier: 'small', repo: 'org/js-small' }, + { language: 'javascript', tier: 'large', repo: 'org/js-large' }, + { language: 'python', tier: 'small', repo: 'org/py-small' }, + { language: 'python', tier: 'medium', repo: 'org/py-medium' }, + { language: 'rust', tier: 'small', repo: 'org/rust-small' } +]; + +const controlSlice = selectBenchControlSlice(tasks, { maxTasks: 4 }); +assert.equal(controlSlice.tasks.length, 4, 'expected bounded control slice size'); +assert.deepEqual( + controlSlice.taskIds, + [ + 'javascript:small:org/js-small', + 'javascript:large:org/js-large', + 'python:small:org/py-small', + 'python:medium:org/py-medium' + ], + 'expected deterministic control-slice ordering by language and representative tier' +); + +const methodology = createBenchMethodologyPolicy({ + argv: { mode: 'tooling', 'control-slice-max': 4 }, + tasks +}); +assert.equal(methodology.policyVersion, BENCH_METHODOLOGY_POLICY_VERSION, 'expected methodology policy version'); +assert.equal(methodology.mode, 'tooling', 'expected explicit mode'); +assert.equal(methodology.toolingMode, 'included', 'expected tooling mode to be included for tooling runs'); +assert.equal(methodology.timeoutPolicyVersion, '1.1.0', 'expected timeout policy version tagging'); + +const filtered = filterTasksToControlSlice(tasks, methodology); +assert.equal(filtered.length, 4, 'expected filtering to match control slice'); + +console.log('bench language policy test passed'); diff --git a/tests/perf/bench/language-process-crash-attribution.test.js b/tests/perf/bench/language-process-crash-attribution.test.js new file mode 100644 index 000000000..1b922da53 --- /dev/null +++ b/tests/perf/bench/language-process-crash-attribution.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildBenchCrashAttribution } from '../../../tools/bench/language/process.js'; + +ensureTestingEnv(process.env); + +const attribution = buildBenchCrashAttribution({ + code: 3221225477, + signal: null, + activeLabel: 'bench cmake/dreamworksanimation/openmoonray', + activeChildPid: 12345, + activePhase: 'execute', + logHistory: [ + '[cleanup] runtime.scheduler.shutdown done (1ms)', + '[cleanup] runtime.worker-pools.destroy start', + 'Build failed: build index' + ] +}); + +assert.ok(attribution && typeof attribution === 'object', 'expected crash attribution payload'); +assert.equal(attribution.crashClass, 'windows_access_violation', 'expected Windows access violation classification'); +assert.equal(attribution.ntStatusHex, '0xC0000005', 'expected NTSTATUS hex rendering'); +assert.equal(attribution.activeLabel, 'bench cmake/dreamworksanimation/openmoonray'); +assert.equal(attribution.activeChildPid, 12345); +assert.equal(attribution.activePhase, 'execute'); +assert.equal(attribution.recentCleanupLabel, 'runtime.worker-pools.destroy'); +assert.equal(Array.isArray(attribution.recentLogTail), true, 'expected retained crash log tail'); + +const nonCrash = buildBenchCrashAttribution({ + code: 1, + signal: null, + logHistory: [] +}); + +assert.equal(nonCrash, null, 'expected generic nonzero exit to avoid crash attribution'); + +console.log('bench language process crash attribution test passed'); diff --git a/tests/perf/bench/language-process-diagnostics-stream.test.js b/tests/perf/bench/language-process-diagnostics-stream.test.js new file mode 100644 index 000000000..bab90867a --- /dev/null +++ b/tests/perf/bench/language-process-diagnostics-stream.test.js @@ -0,0 +1,208 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { createProcessRunner } from '../../../tools/bench/language/process.js'; +import { + BENCH_DIAGNOSTIC_EVENT_TYPES, + BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION +} from '../../../tools/bench/language/logging.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-process-diagnostics-stream'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +const masterLogPath = path.join(tempRoot, 'run-all.log'); +const captured = []; +const logHistory = []; +const runner = createProcessRunner({ + appendLog: (line) => { + if (line) captured.push(String(line)); + }, + writeLog: () => {}, + writeLogSync: () => {}, + logHistory, + logPath: masterLogPath, + getLogPaths: () => [masterLogPath], + onProgressEvent: () => {} +}); + +const script = [ + "const progress = (payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), ...payload }));", + "progress({ level: 'error', stage: 'parse', taskId: 'stage:parse', message: 'tree-sitter parser crash while parsing src/main.c' });", + "console.error('[scm] timeout while collecting git metadata');", + "progress({ level: 'warn', stage: 'watchdog', taskId: 'stage:watchdog', message: '[tree-sitter:schedule] queue delay hotspot 1450ms' });", + "console.log('[perf] artifact write stall critical: chunk_meta.binary-columnar.bundle in-flight for 32s (threshold=30s, family=chunk-meta, lane=massive, phase=materialize:chunk-meta-binary-columnar)');", + "console.log('[tooling] preflight:start provider=gopls id=gopls.workspace-model class=workspace timeoutMs=20000');", + "console.log('[tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked');", + "console.log('[tooling] request:timeout provider=pyright method=textDocument/documentSymbol stage=documentSymbol workspacePartition=. class=timeout');", + "console.log('[tooling] request:failed provider=sourcekit method=textDocument/semanticTokens/full stage=semantic_tokens workspacePartition=swift-package class=request_failed');", + "console.log('[tooling] pyright circuit breaker tripped.');", + "console.log('[tooling] pyright degraded mode active (fail-open).');", + "console.log('[tooling] pyright degraded mode cleared.');", + "console.log('[tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via compile_commands.json.');", + "console.log('[imports] suppression: policy=live count=4 degraded=1 visible=1 total=7 actionable=3 omittedFailureCauses=resolver_gap,parser_artifact');", + "console.log('[tooling] workspace:partition provider=gopls state=degraded reason=gopls_workspace_partition_incomplete workspacePartition=multiple partitionCount=2 unmatchedDocuments=1 unmatchedTargets=1');", + "progress({ level: 'info', stage: 'watchdog', taskId: 'stage:watchdog', message: 'structured watchdog budget extension', benchDiagnostic: { eventType: 'runtime_timeout_budget_extended', message: 'watchdog budget extended for healthy progress', timeoutKind: 'idle', phase: 'execute', resourceClass: 'cpu-bound', failureMode: 'budget_exhausted_with_progress', decisionReason: 'healthy_progress', outcome: 'extend_budget', effectiveBudgetMs: 1200, skippedWork: ['provider-enrichment'], partialSuccess: true } });", + "progress({ level: 'warn', stage: 'watchdog', taskId: 'stage:watchdog', message: 'structured watchdog timeout', benchDiagnostic: { eventType: 'runtime_timeout', message: 'watchdog timeout after progress-aware budget', timeoutKind: 'hard', phase: 'provider_bootstrap', resourceClass: 'provider-bound', failureMode: 'budget_exhausted_with_progress', decisionReason: 'progress_budget_exhausted', outcome: 'terminate', effectiveBudgetMs: 1600, skippedWork: ['provider-requests', 'workspace-preflight'], partialSuccess: true } });", + "const fallback = JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), level: 'warn', stage: 'parse', taskId: 'stage:parse', message: 'using fallback parser for unsupported grammar' });", + 'console.log(fallback);', + 'console.log(fallback);', + 'process.exit(0);' +].join(''); + +const result = await runner.runProcess( + 'ub050-diagnostics', + process.execPath, + ['-e', script], + { continueOnError: true } +); + +assert.equal(result.ok, true, 'expected subprocess success'); +assert.ok(result.diagnostics && typeof result.diagnostics === 'object', 'expected diagnostics summary on result'); +assert.equal( + result.diagnostics.schemaVersion, + BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, + 'expected diagnostics schema version' +); +assert.equal(result.diagnostics.eventCount, 19, 'expected structured diagnostics to include tooling/runtime events'); +assert.equal(result.diagnostics.countsByType.fallback_used, 2, 'expected fallback duplicate count in full stream'); +assert.deepEqual( + result.diagnostics.countsBySeverity, + { error: 2, info: 3, warn: 14 }, + 'expected consequence-based severity counts in process diagnostics summary' +); + +for (const type of BENCH_DIAGNOSTIC_EVENT_TYPES) { + assert.equal( + Number(result.diagnostics.countsByType[type] || 0) > 0, + true, + `expected required diagnostic type ${type}` + ); +} + +const diagnosticsPath = path.join(tempRoot, 'run-all.diagnostics.jsonl'); +assert.equal(fs.existsSync(diagnosticsPath), true, 'expected diagnostics stream file to exist'); +assert.equal( + result.diagnostics.streamPaths.includes(diagnosticsPath), + true, + 'expected diagnostics path in process result summary' +); + +const streamLines = (await fsPromises.readFile(diagnosticsPath, 'utf8')) + .split(/\r?\n/) + .filter((line) => line.trim()); +assert.equal(streamLines.length, 19, 'expected full JSON event stream with all occurrences'); + +const streamEvents = streamLines.map((line) => JSON.parse(line)); +const fallbackEvents = streamEvents.filter((entry) => entry.eventType === 'fallback_used'); +assert.equal(fallbackEvents.length, 2, 'expected two fallback events in persisted stream'); +assert.equal( + new Set(fallbackEvents.map((entry) => entry.eventId)).size, + 1, + 'expected stable fallback event ID for dedupe/rate-limiting' +); +assert.deepEqual( + fallbackEvents.map((entry) => entry.occurrence), + [1, 2], + 'expected fallback occurrence counter to increment' +); +const requestTimeoutEvent = streamEvents.find((entry) => entry.eventType === 'provider_request_timeout'); +assert.equal(requestTimeoutEvent?.providerId, 'pyright', 'expected provider correlation on request timeout'); +assert.equal(requestTimeoutEvent?.requestMethod, 'textDocument/documentSymbol', 'expected request method on timeout event'); +const preflightBlockedEvent = streamEvents.find((entry) => entry.eventType === 'provider_preflight_blocked'); +assert.equal(preflightBlockedEvent?.providerId, 'gopls', 'expected provider correlation on preflight blocked event'); +assert.equal(preflightBlockedEvent?.preflightState, 'blocked', 'expected blocked preflight state on stream event'); +const workspacePartitionEvent = streamEvents.find((entry) => entry.eventType === 'workspace_partition_decision'); +assert.equal(workspacePartitionEvent?.providerId, 'gopls', 'expected provider correlation on workspace routing event'); +assert.equal(workspacePartitionEvent?.workspacePartition, 'multiple', 'expected workspace partition identifier on routing event'); +const warningSuppressedEvent = streamEvents.find((entry) => entry.eventType === 'warning_suppressed'); +assert.equal(warningSuppressedEvent?.providerId, 'clangd', 'expected provider correlation on warning suppression'); +assert.equal(warningSuppressedEvent?.severity, 'warn', 'expected warning suppression to surface at warn severity'); +const importSuppressionEvent = streamEvents.find((entry) => entry.eventType === 'warning_suppressed' && entry.failureClass === 'imports_live:4'); +assert.equal(importSuppressionEvent?.suppressionPolicy, 'live', 'expected import suppression policy on structured warning event'); +assert.equal(importSuppressionEvent?.suppressedCount, 4, 'expected import suppression count on structured warning event'); +assert.equal(importSuppressionEvent?.degradedRun, true, 'expected degraded-run flag on structured warning event'); +assert.equal(importSuppressionEvent?.visibleSampleCount, 1, 'expected retained visible-sample count on structured warning event'); +assert.equal(importSuppressionEvent?.actionableCount, 3, 'expected actionable unresolved count on structured warning event'); +assert.equal(importSuppressionEvent?.totalCount, 7, 'expected total unresolved count on structured warning event'); +assert.deepEqual(importSuppressionEvent?.omittedSampleClasses, ['resolver_gap', 'parser_artifact'], 'expected omitted sample classes on structured warning event'); +const parserCrashEvent = streamEvents.find((entry) => entry.eventType === 'parser_crash'); +assert.equal(parserCrashEvent?.severity, 'error', 'expected parser crash to surface at error severity'); +const preflightStartEvent = streamEvents.find((entry) => entry.eventType === 'provider_preflight_start'); +assert.equal(preflightStartEvent?.severity, 'info', 'expected preflight start to remain informational'); +const runtimeTimeoutEvent = streamEvents.find((entry) => entry.eventType === 'runtime_timeout'); +assert.equal(runtimeTimeoutEvent?.timeoutKind, 'hard', 'expected runtime timeout kind on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.phase, 'provider_bootstrap', 'expected timeout phase on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.resourceClass, 'provider-bound', 'expected timeout resource class on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.failureMode, 'budget_exhausted_with_progress', 'expected timeout failure mode on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.decisionReason, 'progress_budget_exhausted', 'expected timeout decision reason on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.outcome, 'terminate', 'expected timeout outcome on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.effectiveBudgetMs, 1600, 'expected timeout budget on structured stream entry'); +assert.deepEqual(runtimeTimeoutEvent?.skippedWork, ['provider-requests', 'workspace-preflight'], 'expected skipped work on structured stream entry'); +assert.equal(runtimeTimeoutEvent?.partialSuccess, true, 'expected partial success flag on structured stream entry'); +const runtimeBudgetExtendedEvent = streamEvents.find((entry) => entry.eventType === 'runtime_timeout_budget_extended'); +assert.equal(runtimeBudgetExtendedEvent?.timeoutKind, 'idle', 'expected runtime timeout extension kind on structured stream entry'); +assert.equal(runtimeBudgetExtendedEvent?.decisionReason, 'healthy_progress', 'expected timeout extension decision reason on structured stream entry'); +assert.equal(runtimeBudgetExtendedEvent?.outcome, 'extend_budget', 'expected timeout extension outcome on structured stream entry'); +const artifactTailStallEvent = streamEvents.find((entry) => entry.eventType === 'artifact_tail_stall'); +assert.equal(artifactTailStallEvent?.failureClass, 'family:chunk-meta', 'expected artifact stall family classification in stream entry'); +assert.equal(artifactTailStallEvent?.phase, 'materialize:chunk-meta-binary-columnar', 'expected artifact stall phase classification in stream entry'); +for (const entry of streamEvents) { + assert.match(entry.eventId, /^ub050:v1:[a-z_]+:[a-f0-9]{12}$/); + assert.equal(entry.schemaVersion, BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, 'expected schema version on stream entry'); +} + +const interactiveDiagnostics = captured + .filter((line) => line.startsWith('[diagnostics]')) + .map((line) => line.replace(/ub050:v1:[a-z_]+:[a-f0-9]{12}/g, '')) + .sort(); +const expectedInteractivePrefixes = [ + '[diagnostics] artifact_tail_stall [perf] artifact write stall critical: chunk_meta.binary-columnar.bundle in-flight for 32s (threshold=30s, fam...', + '[diagnostics] warning_suppressed [imports] suppression: policy=live count=4 degraded=1 visible=1 total=7 actionable=3 omittedFailureCauses=res...', + '[diagnostics] parser_crash tree-sitter parser crash while parsing src/main.c', + '[diagnostics] provider_circuit_breaker [tooling] pyright circuit breaker tripped.', + '[diagnostics] provider_degraded_mode_cleared [tooling] pyright degraded mode cleared.', + '[diagnostics] provider_degraded_mode_entered [tooling] pyright degraded mode active (fail-open).', + '[diagnostics] provider_preflight_blocked [tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked', + '[diagnostics] provider_request_failed [tooling] request:failed provider=sourcekit method=textDocument/semanticTokens/full stage=semantic_tokens wor...', + '[diagnostics] provider_request_timeout [tooling] request:timeout provider=pyright method=textDocument/documentSymbol stage=documentSymbol workspaceP...', + '[diagnostics] queue_delay_hotspot [tree-sitter:schedule] queue delay hotspot 1450ms', + '[diagnostics] scm_timeout [scm] timeout while collecting git metadata', + '[diagnostics] warning_suppressed [tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via c...' +]; +assert.equal(interactiveDiagnostics.length, expectedInteractivePrefixes.length, 'expected one concise interactive line per unique diagnostic'); +for (const prefix of expectedInteractivePrefixes) { + assert.equal( + interactiveDiagnostics.some((line) => line.startsWith(prefix)), + true, + `expected interactive diagnostics to include prefix: ${prefix}` + ); +} +const unexpectedInteractivePrefixes = [ + '[diagnostics] fallback_used using fallback parser for unsupported grammar', + '[diagnostics] provider_preflight_finish [tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked', + '[diagnostics] provider_preflight_start [tooling] preflight:start provider=gopls id=gopls.workspace-model class=workspace timeoutMs=20000', + '[diagnostics] workspace_partition_decision [tooling] workspace:partition provider=gopls state=degraded reason=gopls_workspace_partition_incomplete' + , + '[diagnostics] runtime_timeout watchdog timeout after progress-aware budget', + '[diagnostics] runtime_timeout_budget_extended watchdog budget extended for healthy progress' +]; +for (const prefix of unexpectedInteractivePrefixes) { + assert.equal( + interactiveDiagnostics.some((line) => line.startsWith(prefix)), + false, + `expected interactive diagnostics to suppress prefix: ${prefix}` + ); +} + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language process diagnostics stream test passed'); diff --git a/tests/perf/bench/language-process-fixture.js b/tests/perf/bench/language-process-fixture.js new file mode 100644 index 000000000..896c353fc --- /dev/null +++ b/tests/perf/bench/language-process-fixture.js @@ -0,0 +1,47 @@ +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { createProcessRunner } from '../../../tools/bench/language/process.js'; + +export const runIdleActivityProbeScenario = async ({ + label, + expectedOkMessage, + makeActivityProbe +}) => { + ensureTestingEnv(process.env); + + const captured = []; + let probeCount = 0; + const runner = createProcessRunner({ + appendLog: (line) => { + if (line) captured.push(String(line)); + }, + writeLog: () => {}, + writeLogSync: () => {}, + logHistory: [], + logPath: null, + getLogPaths: () => [], + onProgressEvent: () => {}, + sampleProcessActivity: (pid) => makeActivityProbe(pid, () => { + probeCount += 1; + return probeCount; + }) + }); + + const result = await runner.runProcess( + label, + process.execPath, + ['-e', 'setTimeout(() => process.exit(0), 2300);'], + { + continueOnError: true, + idleTimeoutMs: 900, + timeoutMs: 6000 + } + ); + + return { + captured, + expectedOkMessage, + label, + probeCount, + result + }; +}; diff --git a/tests/perf/bench/language-process-idle-activity-probe-async.test.js b/tests/perf/bench/language-process-idle-activity-probe-async.test.js new file mode 100644 index 000000000..a215aebba --- /dev/null +++ b/tests/perf/bench/language-process-idle-activity-probe-async.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { runIdleActivityProbeScenario } from './language-process-fixture.js'; + +const { captured, expectedOkMessage, label, probeCount, result } = await runIdleActivityProbeScenario({ + label: 'bench-idle-activity-probe-async', + expectedOkMessage: 'expected async activity probe to suppress idle timeout', + makeActivityProbe: async (pid, nextProbeCount) => { + const count = nextProbeCount(); + await new Promise((resolve) => setTimeout(resolve, 5)); + return { + alive: true, + pid, + cpuMs: 100 + (count * 250), + rssBytes: (64 + (count * 4)) * 1024 * 1024 + }; + } +}); + +assert.equal(result.ok, true, expectedOkMessage); +assert.ok(probeCount >= 2, 'expected idle watchdog to consult the async activity probe'); +assert.equal( + captured.some((line) => line.includes(`[run] idle timeout: ${label}`)), + false, + 'expected no idle-timeout warning for async CPU-active child' +); + +console.log('bench language process idle activity probe async test passed'); diff --git a/tests/perf/bench/language-process-idle-activity-probe.test.js b/tests/perf/bench/language-process-idle-activity-probe.test.js new file mode 100644 index 000000000..c5dac23a3 --- /dev/null +++ b/tests/perf/bench/language-process-idle-activity-probe.test.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { runIdleActivityProbeScenario } from './language-process-fixture.js'; + +const { captured, expectedOkMessage, label, probeCount, result } = await runIdleActivityProbeScenario({ + label: 'bench-idle-activity-probe', + expectedOkMessage: 'expected active child CPU or RSS activity to suppress idle timeout', + makeActivityProbe: (pid, nextProbeCount) => { + const count = nextProbeCount(); + return { + alive: true, + pid, + cpuMs: 100 + (count * 250), + rssBytes: (64 + (count * 4)) * 1024 * 1024 + }; + } +}); + +assert.equal(result.ok, true, expectedOkMessage); +assert.ok(probeCount >= 2, 'expected idle watchdog to consult the activity probe'); +assert.equal( + captured.some((line) => line.includes(`[run] idle timeout: ${label}`)), + false, + 'expected no idle-timeout warning for CPU-active child' +); + +console.log('bench language process idle activity probe test passed'); diff --git a/tests/perf/bench/language-process-idle-timeout.test.js b/tests/perf/bench/language-process-idle-timeout.test.js new file mode 100644 index 000000000..c41e11446 --- /dev/null +++ b/tests/perf/bench/language-process-idle-timeout.test.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { createProcessRunner } from '../../../tools/bench/language/process.js'; + +ensureTestingEnv(process.env); + +const captured = []; +const runner = createProcessRunner({ + appendLog: (line) => { + if (line) captured.push(String(line)); + }, + writeLog: () => {}, + writeLogSync: () => {}, + logHistory: [], + logPath: null, + getLogPaths: () => [], + onProgressEvent: () => {}, + sampleProcessActivity: async (pid) => ({ + alive: true, + pid, + cpuMs: 0, + rssBytes: 64 * 1024 * 1024 + }) +}); + +const activeScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:start', { taskId: 'overall', stage: 'overall', current: 0, total: 3, message: 'start' });", + ' await wait(80);', + " emit('task:progress', { taskId: 'overall', stage: 'overall', current: 1, total: 3, message: 'progress 1' });", + ' await wait(80);', + " emit('task:progress', { taskId: 'overall', stage: 'overall', current: 2, total: 3, message: 'progress 2' });", + ' await wait(80);', + " emit('task:end', { taskId: 'overall', stage: 'overall', current: 3, total: 3, status: 'done', message: 'done' });", + ' process.exit(0);', + '})();' +].join(''); + +const activeResult = await runner.runProcess( + 'bench-idle-progress-active', + process.execPath, + ['-e', activeScript], + { + continueOnError: true, + idleTimeoutMs: 180, + timeoutMs: 2000 + } +); + +assert.equal(activeResult.ok, true, 'expected active progress events to keep subprocess alive past idle timeout budget'); + +const silentScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:start', { taskId: 'overall', stage: 'overall', current: 0, total: 2, message: 'start' });", + ' await wait(10_000);', + '})();' +].join(''); + +const silentResult = await runner.runProcess( + 'bench-idle-progress-silent', + process.execPath, + ['-e', silentScript], + { + continueOnError: true, + idleTimeoutMs: 180, + timeoutMs: 1500 + } +); + +assert.equal(silentResult.ok, false, 'expected silent subprocess to fail'); +assert.equal(silentResult.timeoutKind, 'idle', 'expected idle timeout classification'); +assert.equal( + captured.some((line) => line.includes('[run] idle timeout: bench-idle-progress-silent')), + true, + 'expected idle timeout summary log line' +); + +const noisyScript = [ + "setInterval(() => console.log('non-progress noise from child'), 20);", + 'setTimeout(() => {}, 10_000);' +].join(''); + +const noisyResult = await runner.runProcess( + 'bench-idle-output-noise', + process.execPath, + ['-e', noisyScript], + { + continueOnError: true, + idleTimeoutMs: 180, + timeoutMs: 1500 + } +); + +assert.equal(noisyResult.ok, false, 'expected noisy subprocess without owned progress to fail'); +assert.equal(noisyResult.timeoutKind, 'idle', 'expected noisy subprocess to be classified as idle timeout'); +assert.equal( + captured.some((line) => line.includes('[run] idle timeout: bench-idle-output-noise')), + true, + 'expected idle timeout despite non-progress output chatter' +); + +console.log('bench language process idle-timeout test passed'); diff --git a/tests/perf/bench/bench-language-process-import.test.js b/tests/perf/bench/language-process-import.test.js similarity index 100% rename from tests/perf/bench/bench-language-process-import.test.js rename to tests/perf/bench/language-process-import.test.js diff --git a/tests/perf/bench/bench-language-process-progress-confidence.test.js b/tests/perf/bench/language-process-progress-confidence.test.js similarity index 100% rename from tests/perf/bench/bench-language-process-progress-confidence.test.js rename to tests/perf/bench/language-process-progress-confidence.test.js diff --git a/tests/perf/bench/language-process-reuse-classification.test.js b/tests/perf/bench/language-process-reuse-classification.test.js new file mode 100644 index 000000000..9722b9dea --- /dev/null +++ b/tests/perf/bench/language-process-reuse-classification.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { createProcessRunner } from '../../../tools/bench/language/process.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-process-reuse-classification'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +const masterLogPath = path.join(tempRoot, 'run-all.log'); +const runner = createProcessRunner({ + appendLog: () => {}, + writeLog: () => {}, + writeLogSync: () => {}, + logHistory: [], + logPath: masterLogPath, + getLogPaths: () => [masterLogPath], + onProgressEvent: () => {} +}); + +const script = [ + "console.log('[scm] file-meta snapshot: source=mixed-fallback requested=12 reused=7 fetched=5. elapsedMs=240 timeoutCount=2 timeoutRetries=1 cooldownSkips=1 unavailableChunks=1');", + "console.log('[tooling] provider cache read failed for pyright; using live run.');", + "console.log('[tooling] provider 1/1 done id=pyright outcome=done source=live chunks=4 elapsedMs=180.');", + 'process.exit(0);' +].join(''); + +const result = await runner.runProcess( + 'ub050-reuse', + process.execPath, + ['-e', script], + { continueOnError: true } +); + +assert.equal(result.ok, true, 'expected subprocess success'); +assert.equal(result.diagnostics?.countsByType?.fallback_used, 2, 'expected structured fallback events only for SCM fallback and cache invalidation'); + +const diagnosticsPath = path.join(tempRoot, 'run-all.diagnostics.jsonl'); +const events = (await fsPromises.readFile(diagnosticsPath, 'utf8')) + .split(/\r?\n/) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); + +assert.equal(events.length, 2, 'expected only two persisted fallback diagnostics'); + +const scmFallback = events.find((entry) => entry.reuseSurface === 'scm-derived'); +assert.ok(scmFallback, 'expected SCM fallback event'); +assert.equal(scmFallback.failureClass, 'provider_unhealthy'); +assert.equal(scmFallback.reuseSource, 'mixed-fallback'); +assert.equal(scmFallback.qualityImpact, 'partial-provider-fidelity'); +assert.equal(scmFallback.timeCostMs, 240); +assert.equal(scmFallback.requestedCount, 12); +assert.equal(scmFallback.reusedCount, 7); +assert.equal(scmFallback.fetchedCount, 5); + +const providerCacheFallback = events.find((entry) => entry.reuseSurface === 'provider-result'); +assert.ok(providerCacheFallback, 'expected provider-result cache invalidation fallback event'); +assert.equal(providerCacheFallback.failureClass, 'cache_invalid'); +assert.equal(providerCacheFallback.reuseSource, 'live'); +assert.equal(providerCacheFallback.qualityImpact, 'none'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language process reuse classification test passed'); diff --git a/tests/perf/bench/bench-language-process-scheduler-events.test.js b/tests/perf/bench/language-process-scheduler-events.test.js similarity index 100% rename from tests/perf/bench/bench-language-process-scheduler-events.test.js rename to tests/perf/bench/language-process-scheduler-events.test.js diff --git a/tests/perf/bench/language-process-timeout-decision.test.js b/tests/perf/bench/language-process-timeout-decision.test.js new file mode 100644 index 000000000..682e589dd --- /dev/null +++ b/tests/perf/bench/language-process-timeout-decision.test.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { createProcessRunner } from '../../../tools/bench/language/process.js'; + +ensureTestingEnv(process.env); + +const logLines = []; +const runner = createProcessRunner({ + appendLog: (line) => logLines.push(String(line || '')), + writeLog: () => {}, + writeLogSync: () => {}, + logHistory: [], + logPath: null, + getLogPaths: () => [], + onProgressEvent: () => {}, + sampleProcessActivity: async () => null +}); + +const queueSilentScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:start', { taskId: 'overall', stage: 'overall', current: 0, total: 4, message: 'start', inFlight: 4, meta: { queueAgeMs: 220 } });", + ' await wait(10_000);', + '})();' +].join(''); + +const idleResult = await runner.runProcess( + 'bench-timeout-decision-idle', + process.execPath, + ['-e', queueSilentScript], + { + continueOnError: true, + idleTimeoutMs: 100, + timeoutMs: 1000 + } +); + +assert.equal(idleResult.ok, false, 'expected idle timeout result'); +assert.equal(idleResult.timeoutKind, 'idle', 'expected idle timeout kind'); +assert.equal( + idleResult.timeoutDecision?.timeoutClass, + 'no_queue_movement', + 'expected queue movement timeout classification' +); +assert.equal( + Number(idleResult.diagnostics?.countsByType?.runtime_timeout || 0), + 1, + 'expected idle timeout run to emit one structured runtime timeout event' +); + +const hardResult = await runner.runProcess( + 'bench-timeout-decision-hard', + process.execPath, + ['-e', 'setTimeout(() => {}, 10_000);'], + { + continueOnError: true, + timeoutMs: 120 + } +); + +assert.equal(hardResult.ok, false, 'expected hard timeout result'); +assert.equal(hardResult.timeoutKind, 'hard', 'expected hard timeout kind'); +assert.equal( + hardResult.timeoutDecision?.timeoutClass, + 'global_wall_clock_cap', + 'expected wall clock timeout classification' +); +assert.equal(hardResult.timeoutDecision?.phase, 'execute', 'expected default hard timeout phase attribution'); +assert.equal(hardResult.timeoutDecision?.resourceClass, 'cpu-bound', 'expected default hard timeout resource attribution'); +assert.equal( + hardResult.timeoutDecision?.failureMode, + 'phase_stalled', + 'expected no-progress hard timeout to be classified as phase stalled' +); +assert.equal( + Number(hardResult.diagnostics?.countsByType?.runtime_timeout || 0), + 1, + 'expected hard timeout run to emit one structured runtime timeout event' +); + +const providerTimeoutScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const progress = (payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " progress({ level: 'warn', stage: 'tooling', taskId: 'tooling:gopls', message: '[tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked' });", + " progress({ level: 'warn', stage: 'tooling', taskId: 'tooling:pyright', message: '[tooling] request:timeout provider=pyright method=textDocument/documentSymbol stage=documentSymbol workspacePartition=python class=timeout' });", + " progress({ level: 'warn', stage: 'tooling', taskId: 'tooling:pyright', message: '[tooling] pyright degraded mode active (fail-open).' });", + ' await wait(10_000);', + '})();' +].join(''); + +const providerHardResult = await runner.runProcess( + 'bench-timeout-decision-provider', + process.execPath, + ['-e', providerTimeoutScript], + { + continueOnError: true, + timeoutMs: 600 + } +); + +assert.equal(providerHardResult.ok, false, 'expected provider hard timeout result'); +assert.equal(providerHardResult.timeoutKind, 'hard', 'expected provider timeout kind'); +assert.equal(providerHardResult.timeoutDecision?.phase, 'provider_bootstrap', 'expected provider phase attribution'); +assert.equal(providerHardResult.timeoutDecision?.resourceClass, 'provider-bound', 'expected provider resource attribution'); +assert.equal( + providerHardResult.timeoutDecision?.failureMode, + 'budget_exhausted_with_progress', + 'expected provider timeout with live progress signals to be classified as budget exhausted with progress' +); +assert.deepEqual( + providerHardResult.timeoutDecision?.qualityDelta?.skippedWork, + ['provider-enrichment', 'provider-ladder', 'provider-requests', 'workspace-preflight'], + 'expected provider timeout quality delta to surface skipped enrichment classes' +); +assert.equal( + Number(providerHardResult.diagnostics?.countsByType?.runtime_timeout || 0), + 1, + 'expected provider timeout run to emit one structured runtime timeout event' +); + +const artifactTimeoutScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const progress = (payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event: 'log', ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " progress({ level: 'warn', stage: 'write', taskId: 'write:field-postings', message: 'artifact tail stalled for 32000ms while writing field_postings.json shard' });", + " progress({ level: 'warn', stage: 'watchdog', taskId: 'stage:watchdog', message: '[tree-sitter:schedule] queue delay hotspot 1450ms' });", + ' await wait(10_000);', + '})();' +].join(''); + +const artifactHardResult = await runner.runProcess( + 'bench-timeout-decision-artifact', + process.execPath, + ['-e', artifactTimeoutScript], + { + continueOnError: true, + timeoutMs: 600 + } +); + +assert.equal(artifactHardResult.ok, false, 'expected artifact hard timeout result'); +assert.equal(artifactHardResult.timeoutDecision?.phase, 'artifact_write', 'expected artifact phase attribution'); +assert.equal(artifactHardResult.timeoutDecision?.resourceClass, 'write-bound', 'expected write-bound resource attribution'); +assert.equal( + artifactHardResult.timeoutDecision?.failureMode, + 'budget_exhausted_with_progress', + 'expected artifact timeout with queue activity to be classified as budget exhausted with progress' +); +assert.equal( + Number(artifactHardResult.diagnostics?.countsByType?.runtime_timeout || 0), + 1, + 'expected artifact timeout run to emit one structured runtime timeout event' +); + +const sqlitePhaseScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:progress', { taskId: 'phase:step', stage: 'neutral-stage', current: 4, total: 10, message: 'still working', meta: { phase: 'sqlite' } });", + ' await wait(10_000);', + '})();' +].join(''); + +const sqliteHardResult = await runner.runProcess( + 'bench-timeout-decision-sqlite', + process.execPath, + ['-e', sqlitePhaseScript], + { + continueOnError: true, + timeoutMs: 600 + } +); + +assert.equal(sqliteHardResult.ok, false, 'expected sqlite hard timeout result'); +assert.equal(sqliteHardResult.timeoutDecision?.phase, 'sqlite', 'expected explicit runtime phase token to drive sqlite attribution'); +assert.equal(sqliteHardResult.timeoutDecision?.resourceClass, 'write-bound', 'expected sqlite phase to map to write-bound resource class'); +assert.equal( + sqliteHardResult.timeoutDecision?.failureMode, + 'budget_exhausted_with_progress', + 'expected explicit sqlite phase progress token to count as owned progress' +); +assert.equal( + Number(sqliteHardResult.diagnostics?.countsByType?.runtime_timeout || 0), + 1, + 'expected sqlite timeout run to emit one structured runtime timeout event' +); + +const extensionScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:start', { taskId: 'overall', stage: 'overall', current: 200, total: 400, message: 'start', inFlight: 4, meta: { queueAgeMs: 180 } });", + ' await wait(180);', + " emit('task:progress', { taskId: 'overall', stage: 'overall', current: 260, total: 400, message: 'resumed', inFlight: 4, meta: { queueAgeMs: 160 } });", + ' await wait(25);', + '})();', + 'setTimeout(() => process.exit(0), 260);' +].join(''); + +const extensionResult = await runner.runProcess( + 'bench-timeout-decision-extend', + process.execPath, + ['-e', extensionScript], + { + continueOnError: true, + idleTimeoutMs: 100, + timeoutMs: 900 + } +); + +assert.equal(extensionResult.ok, true, 'expected progress extension run to complete'); +console.log('bench language process timeout decision test passed'); diff --git a/tests/perf/bench/language-process-timeout-ownership.test.js b/tests/perf/bench/language-process-timeout-ownership.test.js new file mode 100644 index 000000000..a7cede1d7 --- /dev/null +++ b/tests/perf/bench/language-process-timeout-ownership.test.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + createProcessRunner, + resolveIdleBudgetExtension +} from '../../../tools/bench/language/process.js'; + +ensureTestingEnv(process.env); + +const runner = createProcessRunner({ + appendLog: () => {}, + writeLog: () => {}, + writeLogSync: () => {}, + logHistory: [], + logPath: null, + getLogPaths: () => [], + onProgressEvent: () => {}, + sampleProcessActivity: async () => null +}); + +const clampedExtension = resolveIdleBudgetExtension({ + decision: { + effectiveBudgetMs: 280 + }, + currentIdleBudgetMs: 140, + hardTimeoutCapMs: 160, + processElapsedMs: 150 +}); +assert.equal(clampedExtension.extended, false, 'expected hard-cap headroom clamp to suppress fake idle-budget extensions'); +assert.equal(clampedExtension.nextIdleBudgetMs, 10, 'expected clamped idle budget to honor remaining hard-cap headroom'); + +const qualityDeltaScript = [ + "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const emit = (event, payload) => console.log(JSON.stringify({ proto: 'poc.progress@2', event, ts: new Date().toISOString(), ...payload }));", + '(async () => {', + " emit('task:progress', {", + " taskId: 'tooling:providers',", + " stage: 'relations',", + " current: 120,", + " total: 300,", + " message: 'provider runtime active',", + " meta: {", + " timeoutPolicy: {", + " ownerId: 'tooling-orchestrator',", + " ladderId: 'provider-bootstrap',", + " phase: 'provider_bootstrap',", + " queueExpected: false,", + " byteProgressExpected: false,", + " optionalPhase: true,", + " skippedWork: ['provider-enrichment', 'provider-ladder', 'provider-requests', 'workspace-preflight'],", + " partialSuccess: true", + ' }', + ' }', + ' });', + ' await wait(10_000);', + '})();' +].join(''); + +const qualityDeltaResult = await runner.runProcess( + 'bench-timeout-ownership-quality-delta', + process.execPath, + ['-e', qualityDeltaScript], + { + continueOnError: true, + timeoutMs: 140 + } +); + +assert.equal(qualityDeltaResult.ok, false, 'expected provider timeout result'); +assert.equal(qualityDeltaResult.timeoutKind, 'hard', 'expected provider hard timeout kind'); +assert.equal( + qualityDeltaResult.timeoutDecision?.phase, + 'provider_bootstrap', + 'expected stage-owned policy to control timeout phase attribution' +); +assert.deepEqual( + qualityDeltaResult.timeoutDecision?.qualityDelta?.skippedWork, + ['provider-enrichment', 'provider-ladder', 'provider-requests', 'workspace-preflight'], + 'expected timeout quality delta to come from the stage-owned degradation ladder' +); +assert.equal( + qualityDeltaResult.timeoutDecision?.qualityDelta?.partialSuccess, + true, + 'expected stage-owned degradation ladder to declare partial success' +); + +console.log('bench language process timeout ownership test passed'); diff --git a/tests/perf/bench/bench-language-process-timeout.test.js b/tests/perf/bench/language-process-timeout.test.js similarity index 100% rename from tests/perf/bench/bench-language-process-timeout.test.js rename to tests/perf/bench/language-process-timeout.test.js diff --git a/tests/perf/bench/bench-language-progress-confidence.test.js b/tests/perf/bench/language-progress-confidence.test.js similarity index 100% rename from tests/perf/bench/bench-language-progress-confidence.test.js rename to tests/perf/bench/language-progress-confidence.test.js diff --git a/tests/perf/bench/bench-language-progress-parse.test.js b/tests/perf/bench/language-progress-parse.test.js similarity index 100% rename from tests/perf/bench/bench-language-progress-parse.test.js rename to tests/perf/bench/language-progress-parse.test.js diff --git a/tests/perf/bench/bench-language-query-backends.test.js b/tests/perf/bench/language-query-backends.test.js similarity index 100% rename from tests/perf/bench/bench-language-query-backends.test.js rename to tests/perf/bench/language-query-backends.test.js diff --git a/tests/perf/bench/language-regression-gate.test.js b/tests/perf/bench/language-regression-gate.test.js new file mode 100644 index 000000000..c01797142 --- /dev/null +++ b/tests/perf/bench/language-regression-gate.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-perf-budget-')); +const budgetPath = path.join(tempRoot, 'budget.json'); + +await fs.writeFile(budgetPath, JSON.stringify({ + schemaVersion: 1, + toleranceFraction: 0, + tests: { + 'runner/harness/pass-target': 1 + } +}, null, 2)); + +const runPath = path.join(root, 'tests', 'run.js'); +const result = runNode( + [runPath, '--lane', 'all', '--match', 'runner/harness/pass-target', '--json', '--perf-budget-file', budgetPath], + 'perf regression gate child runner', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(result.status, 1, 'expected perf budget regression to fail run.js with exit code 1'); +assert.equal( + (result.stderr || '').includes('[perf] regression budget violations='), + true, + 'expected perf budget regression diagnostics in stderr' +); + +console.log('perf regression gate test passed'); diff --git a/tests/perf/bench/language-repo-preflight.test.js b/tests/perf/bench/language-repo-preflight.test.js new file mode 100644 index 000000000..b5bb523b8 --- /dev/null +++ b/tests/perf/bench/language-repo-preflight.test.js @@ -0,0 +1,452 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + __resetRepoPreflightFailureCacheForTests, + __setGitCommandRunnerForTests, + buildNonInteractiveGitEnv, + ensureRepoBenchmarkReady, + parseSubmoduleStatusLines +} from '../../../tools/bench/language/repos.js'; +import { createRepoLifecycle } from '../../../tools/bench/language-repos/lifecycle.js'; +import { + classifyRepoPreflightBlock, + resolveRepoPlatformCompatibility +} from '../../../tools/bench/language/repo-preflight-contracts.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const parsed = parseSubmoduleStatusLines([ + '-a1b2c3d extern/doctest (heads/main)', + ' f0f0f0f include/fmt', + '+1234567 third_party/json (v3.11.0)', + 'U89abcde bad/submodule (merge conflict)' +].join('\n')); + +assert.equal(parsed.length, 4, 'expected four parsed submodule status entries'); +assert.deepEqual( + parsed.map((entry) => ({ + marker: entry.marker, + path: entry.path, + missing: entry.missing, + dirty: entry.dirty + })), + [ + { marker: '-', path: 'extern/doctest', missing: true, dirty: false }, + { marker: ' ', path: 'include/fmt', missing: false, dirty: false }, + { marker: '+', path: 'third_party/json', missing: false, dirty: true }, + { marker: 'U', path: 'bad/submodule', missing: false, dirty: true } + ], + 'expected parser to retain marker semantics used for preflight decisions' +); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-repo-preflight'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +const missingRepo = path.join(tempRoot, 'missing-repo'); + +const summary = ensureRepoBenchmarkReady({ repoPath: missingRepo }); +assert.equal(summary.gitRepo, false, 'expected non-git dirs to skip preflight without throwing'); +assert.equal(summary.submodules.detected, 0, 'unexpected submodule detection for non-git dir'); +assert.equal(summary.lfs.pulled, false, 'unexpected lfs pull for non-git dir'); + +const env = buildNonInteractiveGitEnv({ HOME: '/tmp/home' }); +assert.equal(env.GIT_TERMINAL_PROMPT, '0', 'expected bench preflight git commands to disable prompts'); +assert.equal(env.GCM_INTERACTIVE, 'Never', 'expected bench preflight to disable interactive credential manager'); +assert.equal(env.HOME, '/tmp/home', 'expected caller env vars to remain intact'); + +const kubeaszCompatibility = resolveRepoPlatformCompatibility({ + repo: 'easzlab/kubeasz', + platform: 'win32' +}); +assert.equal(kubeaszCompatibility.state, 'blocked', 'expected kubeasz to be preclassified as blocked on Windows'); +assert.equal( + kubeaszCompatibility.failureReason, + 'platform_incompatible_checkout', + 'expected precise platform checkout failure reason' +); +assert.match( + kubeaszCompatibility.detail || '', + /Windows-incompatible checkout paths/i, + 'expected explicit Windows compatibility detail' +); + +const invalidPathBlock = classifyRepoPreflightBlock({ + detail: 'error: invalid path roles/containerd/templates/easzlab.io.local:5000/hosts.toml.j2' +}); +assert.equal( + invalidPathBlock.state, + 'platform_incompatible_checkout', + 'expected invalid path clone output to classify as platform-incompatible checkout' +); +assert.equal( + invalidPathBlock.blockedClass, + 'platform_checkout', + 'expected platform checkout blocked class' +); + +const setupMockRepo = async (name, gitmodulesContent) => { + const repoPath = path.join(tempRoot, name); + await fs.mkdir(repoPath, { recursive: true }); + if (typeof gitmodulesContent === 'string') { + await fs.writeFile(path.join(repoPath, '.gitmodules'), gitmodulesContent, 'utf8'); + } + return repoPath; +}; + +const withMockGitRunner = (runner, action) => { + __setGitCommandRunnerForTests(runner); + try { + return action(); + } finally { + __setGitCommandRunnerForTests(null); + __resetRepoPreflightFailureCacheForTests(); + } +}; + +const sshRepoPath = await setupMockRepo( + 'ssh-rewrite', + [ + '[submodule "vendor/nvmrc"]', + ' path = vendor/nvmrc', + ' url = git@github.com:nvm-sh/nvmrc.git' + ].join('\n') +); +const sshCalls = []; +const sshLogs = []; +let sshStatusChecks = 0; +const sshSummary = withMockGitRunner((cmd, args) => { + sshCalls.push([cmd, args]); + assert.equal(cmd, 'git', 'expected git command for preflight'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'submodule' && args[3] === 'status') { + sshStatusChecks += 1; + if (sshStatusChecks === 1) { + return { ok: true, status: 0, stdout: '-1234567 vendor/nvmrc\n', stderr: '' }; + } + return { ok: true, status: 0, stdout: '-1234567 vendor/nvmrc\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === sshRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if ( + args[0] === '-C' + && args[1] === sshRepoPath + && args[2] === '-c' + && args[3] === 'url.https://github.com/.insteadOf=git@github.com:' + && args[4] === 'submodule' + && args[5] === 'update' + ) { + return { + ok: false, + status: 128, + stdout: 'Cloning into \'vendor/nvmrc\'...\n', + stderr: [ + 'Host key verification failed.', + 'fatal: Could not read from remote repository.' + ].join('\n') + }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}, () => ensureRepoBenchmarkReady({ + repoPath: sshRepoPath, + onLog: (message) => sshLogs.push(String(message || '')) +})); + +assert.equal(sshSummary.ok, false, 'expected failing submodule init to fail preflight'); +assert.equal(sshSummary.failureReason, 'preflight-submodule-init', 'expected init failure reason'); +assert.equal(sshSummary.submodules.rewriteGithubSshToHttps, true, 'expected SSH rewrite marker'); +assert.equal(sshSummary.preflight.state, 'blocked_auth', 'expected auth classification for SSH host-key failure'); +assert.equal(sshSummary.preflight.stage, 'submodule_update', 'expected failing preflight stage'); +assert.equal(sshSummary.preflight.blockedClass, 'auth', 'expected auth blocked class'); +assert.match(sshSummary.failureDetail || '', /Host key verification failed\./, 'expected stderr detail tail'); +assert.match( + sshSummary.failureDetail || '', + /Could not read from remote repository\./, + 'expected tail detail to include meaningful fatal line' +); +assert.ok( + sshCalls.some(([, args]) => args.includes('url.https://github.com/.insteadOf=git@github.com:')), + 'expected submodule update to inject HTTPS rewrite config' +); +assert.ok( + sshLogs.some((line) => line.includes('submodule init failed')), + 'expected submodule init failure to be logged' +); + +const cacheRepoPath = await setupMockRepo( + 'cache-hit', + [ + '[submodule "deps/example"]', + ' path = deps/example', + ' url = https://github.com/example/example.git' + ].join('\n') +); +const cacheCalls = []; +let cacheStatusChecks = 0; +const cacheRunner = (cmd, args) => { + cacheCalls.push([cmd, args]); + assert.equal(cmd, 'git', 'expected git command for cache test'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === cacheRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === cacheRepoPath && args[2] === 'submodule' && args[3] === 'status') { + cacheStatusChecks += 1; + return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === cacheRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if (args[0] === '-C' && args[1] === cacheRepoPath && args[2] === 'submodule' && args[3] === 'update') { + return { + ok: false, + status: 128, + stdout: '', + stderr: 'fatal: could not read Username for \'https://github.com\': terminal prompts disabled' + }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}; +__setGitCommandRunnerForTests(cacheRunner); +const cacheSummaryFirst = ensureRepoBenchmarkReady({ repoPath: cacheRepoPath }); +const cacheSummarySecond = ensureRepoBenchmarkReady({ repoPath: cacheRepoPath }); +__setGitCommandRunnerForTests(null); +__resetRepoPreflightFailureCacheForTests(); +assert.equal(cacheSummaryFirst.ok, false, 'expected first cache test attempt to fail'); +assert.equal(cacheSummarySecond.ok, false, 'expected second cache test attempt to reuse failure'); +assert.equal(cacheSummaryFirst.preflight.state, 'blocked_auth', 'expected auth block classification on first failure'); +assert.equal(cacheSummarySecond.preflight.cacheHit, true, 'expected second failure to be served from cache'); +assert.equal( + cacheCalls.filter(([, args]) => args[0] === '-C' && args[1] === cacheRepoPath && args.includes('update')).length, + 1, + 'expected doomed submodule update to run only once under failure cache' +); + +const verifyRepoPath = await setupMockRepo( + 'verify-missing', + [ + '[submodule "deps/example"]', + ' path = deps/example', + ' url = https://github.com/example/example.git' + ].join('\n') +); +let verifyStatusChecks = 0; +const verifySummary = withMockGitRunner((cmd, args) => { + assert.equal(cmd, 'git', 'expected git command for preflight'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'status') { + verifyStatusChecks += 1; + if (verifyStatusChecks === 1) { + return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; + } + return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if (args[0] === '-C' && args[1] === verifyRepoPath && args[2] === 'submodule' && args[3] === 'update') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}, () => ensureRepoBenchmarkReady({ repoPath: verifyRepoPath })); + +assert.equal(verifySummary.ok, false, 'expected unresolved submodules to fail preflight'); +assert.equal( + verifySummary.failureReason, + 'preflight-submodule-incomplete', + 'expected post-update missing submodules to fail verification' +); +assert.equal(verifySummary.submodules.initialMissing, 1, 'expected one missing submodule before update'); +assert.equal(verifySummary.submodules.missing, 1, 'expected one missing submodule after update'); +assert.equal(verifySummary.submodules.updated, false, 'expected update flag to remain false on incomplete state'); +assert.equal(verifySummary.preflight.state, 'blocked_repo_contract', 'expected contract classification for required missing submodule'); +assert.equal(verifySummary.preflight.failingSubmodule, 'deps/example', 'expected failing submodule path'); +assert.deepEqual(verifySummary.submodules.requiredMissingPaths, ['deps/example'], 'expected required missing path classification'); + +const successRepoPath = await setupMockRepo( + 'verify-success', + [ + '[submodule "deps/example"]', + ' path = deps/example', + ' url = https://github.com/example/example.git' + ].join('\n') +); +let successStatusChecks = 0; +const successLogs = []; +const successSummary = withMockGitRunner((cmd, args) => { + assert.equal(cmd, 'git', 'expected git command for preflight success case'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === successRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === successRepoPath && args[2] === 'submodule' && args[3] === 'status') { + successStatusChecks += 1; + if (successStatusChecks === 1) { + return { ok: true, status: 0, stdout: '-89abcde deps/example\n', stderr: '' }; + } + return { ok: true, status: 0, stdout: ' 89abcde deps/example\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === successRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if (args[0] === '-C' && args[1] === successRepoPath && args[2] === 'submodule' && args[3] === 'update') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}, () => ensureRepoBenchmarkReady({ + repoPath: successRepoPath, + onLog: (message) => successLogs.push(String(message || '')) +})); + +assert.equal(successSummary.ok, true, 'expected successful submodule update to keep preflight healthy'); +assert.equal(successSummary.submodules.updated, true, 'expected update marker after successful repair'); +assert.equal(successSummary.submodules.initialMissing, 1, 'expected one initial missing submodule'); +assert.equal(successSummary.submodules.missing, 0, 'expected zero missing submodules after verification'); +assert.ok( + successLogs.some((line) => line.includes('submodules ready') && line.includes('missing=0, dirty=0')), + 'expected success log to report final submodule state' +); +assert.ok( + successLogs.some((line) => line.includes('initialMissing=1, initialDirty=0')), + 'expected success log to preserve initial submodule state for diagnostics' +); + +const optionalRepoPath = await setupMockRepo( + 'optional-only', + [ + '[submodule "deps/optional"]', + ' path = deps/optional', + ' url = https://github.com/example/optional.git' + ].join('\n') +); +let optionalStatusChecks = 0; +const optionalSummary = withMockGitRunner((cmd, args) => { + assert.equal(cmd, 'git', 'expected git command for optional submodule case'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === optionalRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === optionalRepoPath && args[2] === 'submodule' && args[3] === 'status') { + optionalStatusChecks += 1; + return { ok: true, status: 0, stdout: '-89abcde deps/optional\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === optionalRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if (args[0] === '-C' && args[1] === optionalRepoPath && args[2] === 'submodule' && args[3] === 'update') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}, () => ensureRepoBenchmarkReady({ + repoPath: optionalRepoPath, + repoContract: { + key: 'optional-only', + optionalSubmodules: ['deps/optional'], + requiredSubmodules: [] + } +})); + +assert.equal(optionalSummary.ok, true, 'expected optional missing submodule to remain runnable'); +assert.equal(optionalSummary.preflight.state, 'ready_partial_optional_submodules', 'expected partial-ready state'); +assert.equal(optionalSummary.preflight.partialReady, true, 'expected partial-ready marker'); +assert.deepEqual(optionalSummary.submodules.optionalMissingPaths, ['deps/optional'], 'expected optional missing path classification'); +assert.deepEqual(optionalSummary.submodules.requiredMissingPaths, [], 'expected no required missing paths'); + +const timeoutRepoPath = await setupMockRepo( + 'timeout-case', + [ + '[submodule "deps/timeout"]', + ' path = deps/timeout', + ' url = https://github.com/example/timeout.git' + ].join('\n') +); +const timeoutSummary = withMockGitRunner((cmd, args) => { + assert.equal(cmd, 'git', 'expected git command for timeout case'); + if (args[0] === '--version') { + return { ok: true, status: 0, stdout: 'git version 2.49.0\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === timeoutRepoPath && args[2] === 'rev-parse') { + return { ok: true, status: 0, stdout: 'true\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === timeoutRepoPath && args[2] === 'submodule' && args[3] === 'status') { + return { ok: true, status: 0, stdout: '-89abcde deps/timeout\n', stderr: '' }; + } + if (args[0] === '-C' && args[1] === timeoutRepoPath && args[2] === 'submodule' && args[3] === 'sync') { + return { ok: true, status: 0, stdout: '', stderr: '' }; + } + if (args[0] === '-C' && args[1] === timeoutRepoPath && args[2] === 'submodule' && args[3] === 'update') { + return { ok: false, status: null, stdout: '', stderr: 'timed out after 120000ms', timedOut: true }; + } + throw new Error(`unexpected git invocation: ${JSON.stringify(args)}`); +}, () => ensureRepoBenchmarkReady({ repoPath: timeoutRepoPath })); + +assert.equal(timeoutSummary.ok, false, 'expected timeout case to fail'); +assert.equal(timeoutSummary.preflight.state, 'blocked_timeout', 'expected timeout classification'); +assert.equal(timeoutSummary.preflight.blockedClass, 'timeout', 'expected timeout blocked class'); + +const cloneLogs = []; +const cloneHistory = []; +let cloneInvoked = false; +const lifecycle = createRepoLifecycle({ + appendLog: (line) => { + const text = String(line || ''); + cloneLogs.push(text); + cloneHistory.push(text); + }, + display: null, + processRunner: { + async runProcess() { + cloneInvoked = true; + return { ok: false, code: 128, schedulerEvents: [] }; + } + }, + cloneEnabled: true, + dryRun: false, + keepCache: true, + cloneTool: { + label: 'git', + supportsMirrorClone: false, + buildArgs: (repo, repoPath) => ['clone', `https://github.com/${repo}.git`, repoPath] + }, + cloneCommandEnv: {}, + mirrorCacheRoot: path.join(tempRoot, 'mirror-cache'), + mirrorRefreshMs: 0, + cacheRoot: path.join(tempRoot, 'cache-root'), + runDiagnosticsRoot: path.join(tempRoot, 'diagnostics'), + runSuffix: 'run-test', + benchEnvironmentMetadata: {}, + logHistory: cloneHistory +}); + +const blockedClone = await lifecycle.ensureRepoPresent({ + task: { repo: 'easzlab/kubeasz' }, + repoPath: path.join(tempRoot, 'repos', 'groovy', 'easzlab__kubeasz'), + repoLabel: 'groovy easzlab/kubeasz' +}); +assert.equal(cloneInvoked, false, 'expected known Windows-incompatible repo to skip clone invocation'); +assert.equal(blockedClone.ok, false, 'expected blocked clone state'); +assert.equal( + blockedClone.failureReason, + 'platform_incompatible_checkout', + 'expected lifecycle clone result to preserve platform incompatibility reason' +); + +console.log('bench-language repo preflight parser test passed.'); diff --git a/tests/perf/bench/language-report-blocker-confirmations.test.js b/tests/perf/bench/language-report-blocker-confirmations.test.js new file mode 100644 index 000000000..49f380a31 --- /dev/null +++ b/tests/perf/bench/language-report-blocker-confirmations.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { createCleanSdkBenchmarkReport } from '../../tooling/bench/bench-runtime-fixture.js'; + +ensureTestingEnv(process.env); + +const cleanOutput = await createCleanSdkBenchmarkReport(); + +const cleanConfirmation = cleanOutput.blockerConfirmations?.summary?.canaries?.find((entry) => entry.id === 'sdk-artifact-tail-live'); +assert.ok(cleanConfirmation, 'expected blocker confirmation entry for sdk'); +assert.equal(cleanConfirmation.benchmarkConfirmed, true, 'expected clean sdk benchmark run to satisfy blocker confirmation'); +assert.equal(cleanConfirmation.taskStatus?.resultClass, 'passed', 'expected clean task result class to be preserved'); + +const degradedOutput = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: '/tmp/results', + runLabel: 'bench-language medium', + config: { + python: { label: 'Python' } + }, + results: [ + { + language: 'python', + tier: 'medium', + repo: 'basedosdados/sdk', + summary: { + backends: ['memory'], + latencyMsAvg: { memory: 8 }, + hitRate: { memory: 0.8 }, + resultCountAvg: { memory: 2 }, + memoryRss: { memory: { mean: 1024 } }, + buildMs: { index: 70 } + }, + diagnostics: { + process: { + countsByType: { + artifact_tail_stall: 2 + }, + countsBySeverity: { + warn: 1 + } + } + } + } + ] +}); + +const degradedConfirmation = degradedOutput.blockerConfirmations?.summary?.canaries?.find((entry) => entry.id === 'sdk-artifact-tail-live'); +assert.ok(degradedConfirmation, 'expected degraded blocker confirmation entry for sdk'); +assert.equal(degradedConfirmation.benchmarkConfirmed, false, 'expected artifact tail stall run to fail blocker confirmation'); +assert.ok( + degradedConfirmation.targetFailures.some((entry) => entry.includes('artifact_tail_stall')), + 'expected target failures to explain the failed confirmation' +); + +console.log('bench language report blocker confirmations test passed'); diff --git a/tests/perf/bench/bench-language-report-crash-retention.test.js b/tests/perf/bench/language-report-crash-retention.test.js similarity index 100% rename from tests/perf/bench/bench-language-report-crash-retention.test.js rename to tests/perf/bench/language-report-crash-retention.test.js diff --git a/tests/perf/bench/language-report-cross-repo-dedupe.test.js b/tests/perf/bench/language-report-cross-repo-dedupe.test.js new file mode 100644 index 000000000..a10dd9ec6 --- /dev/null +++ b/tests/perf/bench/language-report-cross-repo-dedupe.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-report-cross-repo-dedupe'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const runSuffix = '20260311-111111'; +const diagnosticBase = { + schemaVersion: 1, + ts: new Date().toISOString(), + eventType: 'parser_crash', + eventId: 'same-event-id', + signature: 'same-event-id', + source: 'progress-event', + message: 'same parser crash' +}; +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-a.diagnostics.jsonl`), + `${JSON.stringify({ ...diagnosticBase, label: 'repo-a' })}\n`, + 'utf8' +); +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-b.diagnostics.jsonl`), + `${JSON.stringify({ ...diagnosticBase, label: 'repo-b' })}\n`, + 'utf8' +); + +const progressEvent = { + ts: '2026-03-11T00:00:00.000Z', + score: 0.5, + bucket: 'medium', + reason: 'periodic' +}; +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-a.progress-confidence.jsonl`), + `${JSON.stringify({ ...progressEvent, label: 'repo-a' })}\n`, + 'utf8' +); +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-b.progress-confidence.jsonl`), + `${JSON.stringify({ ...progressEvent, label: 'repo-b' })}\n`, + 'utf8' +); + +const preflightLine = '[tooling] preflight:ok provider=gopls id=gopls.workspace-model durationMs=87 state=ready'; +const preflightSummaryLine = '[tooling] preflight summary total=1 cached=0 timedOut=0 failed=0 queuePeak=1 teardownTimedOut=0 states=ready:1 classes=workspace:1 policies=block:1'; +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-repo-a.log`), `${preflightLine}\n${preflightSummaryLine}\n`, 'utf8'); +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-repo-b.log`), `${preflightLine}\n${preflightSummaryLine}\n`, 'utf8'); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: tempRoot, + results: [], + config: {}, + runSuffix +}); + +assert.equal(output.diagnostics.stream.eventCount, 2, 'expected diagnostics from two repo streams to remain distinct'); +assert.equal( + output.diagnostics.progressConfidence.eventCount, + 2, + 'expected progress-confidence events from two repo streams to remain distinct' +); +assert.equal( + output.diagnostics.preflight.eventCount, + 2, + 'expected preflight events from two repo logs to remain distinct' +); +assert.equal( + output.diagnostics.preflight.summary.lineCount, + 2, + 'expected identical preflight summaries from two repo logs to remain distinct' +); + +console.log('bench language report cross repo dedupe test passed'); diff --git a/tests/perf/bench/language-report-environment-metadata.test.js b/tests/perf/bench/language-report-environment-metadata.test.js new file mode 100644 index 000000000..9daa9ec64 --- /dev/null +++ b/tests/perf/bench/language-report-environment-metadata.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { buildBenchEnvironmentMetadata } from '../../../tools/bench/language/logging.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-language-report-environment'); +const resultsRoot = path.join(tempRoot, 'results'); +const environmentMetadata = buildBenchEnvironmentMetadata({ + PAIROFCLEATS_TESTING: '1', + ORG_GRADLE_DAEMON: 'false', + GRADLE_OPTS: '-Dorg.gradle.daemon=false' +}); + +assert.ok(environmentMetadata.fingerprint, 'expected environment metadata fingerprint'); +assert.equal(environmentMetadata.selected.PAIROFCLEATS_TESTING, '1'); +assert.equal(environmentMetadata.selected.ORG_GRADLE_DAEMON, 'false'); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot, + results: [], + config: {}, + environmentMetadata +}); + +assert.equal(output.environment?.fingerprint, environmentMetadata.fingerprint, 'expected report output to carry environment fingerprint'); +assert.equal(output.environment?.selected?.PAIROFCLEATS_TESTING, '1', 'expected report output to carry selected environment metadata'); + +console.log('bench language report environment metadata test passed'); diff --git a/tests/perf/bench/language-report-master-dedupe.test.js b/tests/perf/bench/language-report-master-dedupe.test.js new file mode 100644 index 000000000..7bde7fcf3 --- /dev/null +++ b/tests/perf/bench/language-report-master-dedupe.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-report-master-dedupe'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const runSuffix = '20260311-000000'; +const event = JSON.stringify({ + eventType: 'parser_crash', + eventId: 'dedupe-event', + signature: 'dedupe-event', + message: 'duplicated', + label: 'repo-a' +}); +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-all.diagnostics.jsonl`), `${event}\n`, 'utf8'); +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-repo-a.diagnostics.jsonl`), `${event}\n`, 'utf8'); +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-all.progress-confidence.jsonl`), + `${JSON.stringify({ score: 0.5, bucket: 'medium', label: 'repo-a', ts: '2026-03-11T00:00:00.000Z' })}\n`, + 'utf8' +); +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-a.progress-confidence.jsonl`), + `${JSON.stringify({ score: 0.5, bucket: 'medium', label: 'repo-a', ts: '2026-03-11T00:00:00.000Z' })}\n`, + 'utf8' +); +const preflightLine = '[tooling] preflight:ok provider=gopls id=gopls.workspace-model durationMs=87 state=ready'; +const preflightSummaryLine = '[tooling] preflight summary total=1 cached=0 timedOut=0 failed=0 queuePeak=1 teardownTimedOut=0 states=ready:1 classes=workspace:1 policies=block:1'; +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-all.log`), `${preflightLine}\n${preflightSummaryLine}\n`, 'utf8'); +await fsPromises.writeFile(path.join(logsRoot, `run-${runSuffix}-repo-a.log`), `${preflightLine}\n${preflightSummaryLine}\n`, 'utf8'); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: tempRoot, + results: [], + config: {}, + runSuffix +}); + +assert.equal(output.diagnostics.stream.fileCount, 2, 'expected both master and repo diagnostics streams to be scanned'); +assert.equal(output.diagnostics.stream.eventCount, 1, 'expected deduped diagnostics event count'); +assert.equal(output.diagnostics.stream.rawEventCount, 2, 'expected raw diagnostics count across master and repo'); +assert.equal(output.diagnostics.stream.duplicateEventCount, 1, 'expected one duplicate diagnostics event'); +assert.equal(output.diagnostics.progressConfidence.fileCount, 2, 'expected both master and repo progress-confidence streams to be scanned'); +assert.equal(output.diagnostics.progressConfidence.eventCount, 1, 'expected deduped progress-confidence event count'); +assert.equal(output.diagnostics.preflight.fileCount, 2, 'expected both master and repo logs to be scanned for preflight summary'); +assert.equal(output.diagnostics.preflight.eventCount, 1, 'expected one preflight event after master-log dedupe'); +assert.equal(output.diagnostics.preflight.rawEventCount, 2, 'expected raw preflight count across master and repo'); +assert.equal(output.diagnostics.preflight.duplicateEventCount, 1, 'expected one duplicate preflight event'); +assert.equal(output.diagnostics.preflight.summary.lineCount, 1, 'expected master preflight summary to be ignored when repo summary exists'); + +console.log('bench language report master dedupe test passed'); diff --git a/tests/perf/bench/language-report-master-summary-recovery.test.js b/tests/perf/bench/language-report-master-summary-recovery.test.js new file mode 100644 index 000000000..e08affe2d --- /dev/null +++ b/tests/perf/bench/language-report-master-summary-recovery.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-report-master-summary-recovery'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const runSuffix = '20260312-000000'; +const sharedSummary = '[tooling] preflight summary total=1 cached=0 timedOut=0 failed=0 queuePeak=1 teardownTimedOut=0 states=ready:1 classes=workspace:1 policies=block:1'; +const masterUniqueSummary = '[tooling] preflight summary total=2 cached=1 timedOut=0 failed=0 queuePeak=2 teardownTimedOut=0 states=ready:2 classes=workspace:2 policies=block:2'; +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-all.log`), + `${sharedSummary}\n${masterUniqueSummary}\n`, + 'utf8' +); +await fsPromises.writeFile( + path.join(logsRoot, `run-${runSuffix}-repo-a.log`), + `${sharedSummary}\n`, + 'utf8' +); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: tempRoot, + results: [], + config: {}, + runSuffix +}); + +assert.equal( + output.diagnostics.preflight.summary.lineCount, + 2, + 'expected unique master-only summaries to remain visible when repo logs are incomplete' +); + +console.log('bench language report master summary recovery test passed'); diff --git a/tests/perf/bench/language-report-run-scope.test.js b/tests/perf/bench/language-report-run-scope.test.js new file mode 100644 index 000000000..1d87e7308 --- /dev/null +++ b/tests/perf/bench/language-report-run-scope.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-language-report-run-scope'); +const resultsRoot = path.join(tempRoot, 'results'); +const logsRoot = path.join(resultsRoot, 'logs', 'bench-language'); +const oldRunSuffix = '20260301-010101'; +const activeRunSuffix = '20260304-112231'; + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const write = (name, content) => fsPromises.writeFile(path.join(logsRoot, name), content, 'utf8'); + +await write( + `run-${oldRunSuffix}-legacy.diagnostics.jsonl`, + `${JSON.stringify({ eventType: 'parser_crash', eventId: 'old', signature: 'old', message: 'old event' })}\n` +); +await write( + `run-${activeRunSuffix}-active.diagnostics.jsonl`, + `${JSON.stringify({ eventType: 'scm_timeout', eventId: 'new', signature: 'new', message: 'new event' })}\n` +); +await write( + `run-${oldRunSuffix}-legacy.progress-confidence.jsonl`, + `${JSON.stringify({ score: 0.2, bucket: 'low', label: 'legacy', reason: 'old', ts: '2026-03-01T00:00:00.000Z' })}\n` +); +await write( + `run-${activeRunSuffix}-active.progress-confidence.jsonl`, + `${JSON.stringify({ score: 0.9, bucket: 'high', label: 'active', reason: 'new', ts: '2026-03-04T00:00:00.000Z' })}\n` +); +await write( + `run-${oldRunSuffix}-legacy.log`, + '[tooling] preflight:start provider=clangd id=clangd.workspace-model class=workspace timeoutMs=20000\n' +); +await write( + `run-${activeRunSuffix}-active.log`, + '[tooling] preflight:ok provider=clangd id=clangd.workspace-model class=workspace durationMs=87 state=ready\n' +); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot, + results: [], + config: {}, + runSuffix: activeRunSuffix +}); + +assert.equal(output.diagnostics.stream.fileCount, 1, 'expected diagnostics summary to include only active-run stream files'); +assert.equal(output.diagnostics.stream.eventCount, 1, 'expected only active-run diagnostic events'); +assert.equal(output.diagnostics.stream.required.scm_timeout, 1, 'expected active-run diagnostic event type count'); +assert.equal(output.diagnostics.stream.required.parser_crash, 0, 'expected legacy-run diagnostic event to be excluded'); + +assert.equal(output.diagnostics.progressConfidence.fileCount, 1, 'expected progress-confidence summary to include only active run'); +assert.equal(output.diagnostics.progressConfidence.eventCount, 1, 'expected only active-run confidence events'); +assert.equal(output.diagnostics.progressConfidence.latestByLabel.length, 1, 'expected only active run labels'); +assert.equal(output.diagnostics.progressConfidence.latestByLabel[0]?.label, 'active', 'expected active run label'); + +assert.equal(output.diagnostics.preflight.fileCount, 1, 'expected preflight summary to include only active-run logs'); +assert.equal(output.diagnostics.preflight.eventCount, 1, 'expected only active-run preflight events'); +assert.equal(output.diagnostics.preflight.countsByEvent.ok, 1, 'expected active preflight ok event to be counted'); +assert.equal(output.diagnostics.preflight.countsByEvent.start || 0, 0, 'expected legacy preflight start event to be excluded'); + +console.log('bench language report run-scope test passed'); diff --git a/tests/perf/bench/language-repos-fixture.js b/tests/perf/bench/language-repos-fixture.js new file mode 100644 index 000000000..f6adb6ab6 --- /dev/null +++ b/tests/perf/bench/language-repos-fixture.js @@ -0,0 +1,71 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +export const createBenchLanguageRepoFixture = async ({ + name, + repoId, + readme = 'bench language fixture' +}) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, name); + const reposRoot = path.join(tempRoot, 'repos'); + const cacheRoot = path.join(tempRoot, 'cache'); + const resultsRoot = path.join(tempRoot, 'results'); + const configPath = path.join(tempRoot, 'repos.json'); + const queriesPath = path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'); + const repoPath = path.join(reposRoot, 'javascript', repoId.replace('/', '__')); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(repoPath, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.mkdir(resultsRoot, { recursive: true }); + await fsPromises.writeFile(path.join(repoPath, 'README.md'), readme); + await fsPromises.writeFile(configPath, JSON.stringify({ + javascript: { + label: 'JavaScript', + queries: queriesPath, + repos: { + small: [repoId] + } + } + }, null, 2)); + + return { + cacheRoot, + configPath, + reposRoot, + resultsRoot, + root, + scriptPath: path.join(root, 'tools', 'bench', 'language-repos.js'), + tempRoot + }; +}; + +export const runBenchLanguageRepos = ({ + fixture, + args = [], + timeout = undefined +}) => runNode( + [ + fixture.scriptPath, + '--config', + fixture.configPath, + '--root', + fixture.reposRoot, + '--cache-root', + fixture.cacheRoot, + '--results', + fixture.resultsRoot, + '--no-clone', + '--dry-run', + ...args + ], + 'bench language repos', + fixture.root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', encoding: 'utf8', timeoutMs: timeout, allowFailure: true } +); diff --git a/tests/perf/bench/language-repos.test.js b/tests/perf/bench/language-repos.test.js new file mode 100644 index 000000000..2502afacd --- /dev/null +++ b/tests/perf/bench/language-repos.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createBenchLanguageRepoFixture, + runBenchLanguageRepos +} from './language-repos-fixture.js'; + +const repoId = 'test/repos-smoke'; +const fixture = await createBenchLanguageRepoFixture({ + name: 'bench-language-repos', + repoId, + readme: 'bench repos smoke' +}); + +const result = runBenchLanguageRepos({ fixture, args: ['--json'] }); + +if (result.status !== 0) { + console.error(result.stderr || 'bench-language-repos test failed'); + process.exit(result.status ?? 1); +} + +const payload = JSON.parse(result.stdout || '{}'); +assert.ok(Array.isArray(payload.tasks), 'expected tasks array in bench payload'); +assert.equal(payload.tasks.length, 1, 'expected exactly one scheduled bench task'); +assert.equal(payload.tasks[0]?.repo, repoId, 'expected synthetic repo task in bench payload'); +assert.equal(payload.methodology?.mode, 'warm', 'expected default methodology mode'); +assert.equal(payload.methodology?.cacheMode, 'warm', 'expected warm cache methodology default'); + +const controlSliceResult = runBenchLanguageRepos({ + fixture, + args: ['--json', '--mode', 'cold', '--control-slice'] +}); +if (controlSliceResult.status !== 0) { + console.error(controlSliceResult.stderr || 'bench-language control-slice test failed'); + process.exit(controlSliceResult.status ?? 1); +} +const controlSlicePayload = JSON.parse(controlSliceResult.stdout || '{}'); +assert.equal(controlSlicePayload.methodology?.mode, 'cold', 'expected explicit cold methodology mode'); +assert.equal(controlSlicePayload.tasks.length, 1, 'expected control slice to keep the representative repo'); + +console.log('bench-language repos test passed'); diff --git a/tests/perf/bench/language-run-diff.test.js b/tests/perf/bench/language-run-diff.test.js new file mode 100644 index 000000000..3b98029eb --- /dev/null +++ b/tests/perf/bench/language-run-diff.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildBenchRunDiff } from '../../../tools/bench/language/diff.js'; + +const before = { + generatedAt: '2026-03-17T00:00:00.000Z', + methodology: { + mode: 'cold', + cacheMode: 'cold', + toolingMode: 'disabled', + corpusVersion: 'repos-a', + policyVersion: 'bench-language-methodology-v1' + }, + tasks: [ + { + language: 'python', + tier: 'small', + repo: 'org/py', + summary: { + hitRate: { memory: 0.9, sqlite: 0.8 }, + memoryRss: { sqlite: { mean: 512 * 1024 * 1024 } }, + buildMs: { index: 100 } + }, + taskStatus: { + resultClass: 'passed', + degradationClasses: [] + }, + diagnostics: { + process: { + countsByType: { + artifact_tail_stall: 1 + } + } + } + } + ] +}; + +const after = { + generatedAt: '2026-03-18T00:00:00.000Z', + methodology: { + mode: 'cold', + cacheMode: 'cold', + toolingMode: 'disabled', + corpusVersion: 'repos-a', + policyVersion: 'bench-language-methodology-v1' + }, + tasks: [ + { + language: 'python', + tier: 'small', + repo: 'org/py', + summary: { + hitRate: { memory: 0.5, sqlite: 0.4 }, + memoryRss: { sqlite: { mean: 1024 * 1024 * 1024 } }, + buildMs: { index: 180 } + }, + taskStatus: { + resultClass: 'timed_out', + degradationClasses: ['artifact_tail_stall'] + }, + diagnostics: { + process: { + countsByType: { + artifact_tail_stall: 3 + } + } + } + } + ] +}; + +const diff = buildBenchRunDiff({ before, after }); +assert.equal(diff.schemaVersion, 1, 'expected diff schema version'); +assert.equal(diff.byLanguage.length, 1, 'expected one language diff row'); +assert.equal(diff.byRepo.length, 1, 'expected one repo diff row'); +assert.equal(diff.byRepo[0]?.buildIndexMs?.delta, 80, 'expected build index delta'); +assert.equal(diff.byRepo[0]?.timeoutCount?.after, 1, 'expected timeout count in after report'); +assert.equal(diff.byRepo[0]?.artifactTailStallCount?.delta, 2, 'expected artifact-tail-stall delta'); +assert.equal(diff.byRepo[0]?.cacheHitRate?.delta, -0.4, 'expected cache hit rate delta'); +assert.equal(diff.byRepo[0]?.coldStartHitRate?.delta, -0.4, 'expected cold-start hit rate delta'); +assert.equal(diff.byRepo[0]?.intraRunHitRate?.delta, -0.4, 'expected intra-run hit rate delta'); +assert.equal(diff.byRepo[0]?.crossRunHitRate?.delta, -0.4, 'expected cross-run hit rate delta'); +assert.equal(diff.byRepo[0]?.sqliteRssMb?.delta, 512, 'expected sqlite rss delta in MB'); +assert.equal(diff.ownership?.byFamily?.length, 1, 'expected ownership diff family row'); +assert.equal(diff.ownership?.byFamily?.[0]?.family, 'scripting', 'expected scripting family ownership row'); +assert.equal(diff.ownership?.topRegressions?.[0]?.family, 'scripting', 'expected scripting family regression'); + +console.log('bench language run diff test passed'); diff --git a/tests/perf/bench/language-run-ledger-summary.test.js b/tests/perf/bench/language-run-ledger-summary.test.js new file mode 100644 index 000000000..78e6a1a10 --- /dev/null +++ b/tests/perf/bench/language-run-ledger-summary.test.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildBenchRunSummaryFromLedgerEvents } from '../../../tools/bench/language-repos/run-ledger.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-run-ledger-summary'); +const diagnosticsRoot = path.join(tempRoot, 'diagnostics'); +const bundleDir = path.join(diagnosticsRoot, 'owner__repo-two'); +const bundlePath = path.join(bundleDir, 'retained-crash-bundle.json'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); +await fs.writeFile(bundlePath, JSON.stringify({ ok: true }, null, 2)); + +const events = [ + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:00.000Z', + eventType: 'run.started', + payload: { + runSuffix: 'run-ledger-fixture', + plannedRepoCount: 2, + taskCount: 2, + environment: { + platform: 'win32', + arch: 'x64', + nodeVersion: 'v24.13.0', + selected: { + PAIROFCLEATS_TESTING: '1' + }, + fingerprint: 'sha1:run-ledger-env' + }, + masterLogPath: path.join(tempRoot, 'run-all.log'), + ledgerPath: path.join(tempRoot, 'run-ledger.jsonl'), + summaryPath: path.join(tempRoot, 'run-summary.json'), + footerPath: path.join(tempRoot, 'run-footer.log'), + diagnosticsRoot + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:01.000Z', + eventType: 'repo.started', + payload: { language: 'javascript', tier: 'small', repo: 'owner/repo-one' } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:02.000Z', + eventType: 'repo.completed', + payload: { + result: { + language: 'javascript', + tier: 'small', + repo: 'owner/repo-one', + repoPath: 'C:\\repo-one', + outFile: 'C:\\repo-one.json', + failed: false, + skipped: false, + diagnostics: { + process: { + countsByType: { + fallback_used: 2 + }, + countsBySeverity: { + warn: 2 + } + } + } + } + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:03.000Z', + eventType: 'repo.started', + payload: { language: 'go', tier: 'large', repo: 'owner/repo-two' } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:04.000Z', + eventType: 'repo.completed', + payload: { + result: { + language: 'go', + tier: 'large', + repo: 'owner/repo-two', + repoPath: 'C:\\repo-two', + outFile: 'C:\\repo-two.json', + failed: true, + skipped: false, + failureReason: 'bench', + failureCode: 1, + diagnostics: { + process: { + countsByType: { + artifact_tail_stall: 1 + }, + countsBySeverity: { + warn: 1 + } + }, + crashRetention: { + bundlePath, + diagnosticsDir: bundleDir + } + } + } + } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:05.000Z', + eventType: 'closeout.started', + payload: { state: 'completed' } + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:06.000Z', + eventType: 'closeout.summary_written', + payload: {} + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:07.000Z', + eventType: 'closeout.footer_written', + payload: {} + }, + { + schemaVersion: 1, + eventVersion: 1, + ts: '2026-03-21T12:00:08.000Z', + eventType: 'run.ended', + payload: { + state: 'completed', + reason: 'completed', + exitCode: 1 + } + } +]; + +const summary = await buildBenchRunSummaryFromLedgerEvents({ + events, + diagnosticsRoot, + runSuffix: 'run-ledger-fixture' +}); + +assert.equal(summary.run.state, 'completed', 'expected completed run state'); +assert.equal(summary.counts.planned, 2, 'expected planned repo count'); +assert.equal(summary.counts.finished, 2, 'expected finished repo count'); +assert.equal(summary.counts.unfinished, 0, 'expected no unfinished repos'); +assert.equal(summary.environment?.fingerprint, 'sha1:run-ledger-env', 'expected run summary to preserve environment metadata'); +assert.equal(summary.verdict.aggregateResultClass, 'repo_failed', 'expected repo_failed verdict'); +assert.equal(summary.verdict.countsByDiagnosticSeverity.warn, 3, 'expected diagnostic severity counts in run summary verdict'); +assert.equal(summary.parities.crashRetention.ledgerCount, 1, 'expected one ledger crash bundle'); +assert.equal(summary.parities.crashRetention.directoryCount, 1, 'expected one retained crash bundle on disk'); +assert.equal(summary.parities.crashRetention.ok, true, 'expected crash-retention parity'); +assert.equal(summary.closeout.closeoutStarted, true, 'expected closeout started marker'); +assert.equal(summary.closeout.closeoutSummaryWritten, true, 'expected closeout summary marker'); +assert.equal(summary.closeout.closeoutFooterWritten, true, 'expected closeout footer marker'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language run ledger summary test passed'); diff --git a/tests/perf/bench/language-run-loop-crash-quarantine.test.js b/tests/perf/bench/language-run-loop-crash-quarantine.test.js new file mode 100644 index 000000000..bbb7bacc4 --- /dev/null +++ b/tests/perf/bench/language-run-loop-crash-quarantine.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createBenchRunLoopFixture, createMemoryBenchSummary } from './run-loop-fixture.js'; + +const fixture = await createBenchRunLoopFixture({ + name: 'bench-crash-quarantine', + repo: 'dreamworksanimation/openmoonray', + language: 'cmake', + repoDirName: 'openmoonray', + repoLabel: 'cmake/dreamworksanimation/openmoonray', + fallbackLogSlug: 'openmoonray' +}); + +const runCalls = []; +let attachCalls = 0; +const results = await fixture.run({ + processRunner: { + runProcess: async (label, _cmd, _args, options = {}) => { + runCalls.push({ + label, + workerPool: options?.env?.PAIROFCLEATS_WORKER_POOL || null + }); + if (runCalls.length === 1) { + return { + ok: false, + code: 3221225477, + signal: null, + schedulerEvents: [], + diagnostics: { + crashAttribution: { + crashClass: 'windows_access_violation', + recentCleanupLabel: 'runtime.worker-pools.destroy', + activePhase: 'execute' + } + }, + progressConfidence: { bucket: 'low', score: 0.25 } + }; + } + await fs.writeFile(fixture.outFile, JSON.stringify(createMemoryBenchSummary()), 'utf8'); + return { + ok: true, + schedulerEvents: [], + diagnostics: { countsByType: {} }, + progressConfidence: { bucket: 'high', score: 1 } + }; + } + }, + lifecycle: { + hasRepoPath: () => true, + ensureRepoPresent: async () => ({ ok: true }), + prepareRepoWorkspace: async () => ({ ok: true }), + attachCrashRetention: async () => { + attachCalls += 1; + return { + bundlePath: path.join(fixture.tempRoot, 'crash-bundle.json'), + crashState: { + phase: 'stage3:init' + } + }; + }, + cleanRepoCache: async () => {} + } +}); + +assert.equal(runCalls.length, 2, 'expected one crash attempt and one quarantine retry'); +assert.equal(runCalls[0].workerPool, null, 'expected initial attempt to use default worker-pool config'); +assert.equal(runCalls[1].workerPool, 'off', 'expected quarantine retry to disable worker pools'); +assert.equal(attachCalls, 1, 'expected one retained crash bundle before quarantine retry'); +assert.equal(results.length, 1, 'expected one repo result'); +assert.equal(results[0]?.failed, undefined, 'expected quarantine retry to recover the repo result'); +assert.equal( + results[0]?.diagnostics?.crashQuarantineRecovery?.quarantineId, + 'openmoonray-worker-pool-off', + 'expected quarantine recovery metadata on successful retry' +); + +await fixture.cleanup(); + +console.log('bench run loop crash quarantine test passed'); diff --git a/tests/perf/bench/language-run-verdict.test.js b/tests/perf/bench/language-run-verdict.test.js new file mode 100644 index 000000000..5faebbd6c --- /dev/null +++ b/tests/perf/bench/language-run-verdict.test.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +ensureTestingEnv(process.env); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: '/tmp/results', + methodology: { + mode: 'warm', + cacheMode: 'warm', + toolingMode: 'disabled', + corpusVersion: 'repos-fixture', + policyVersion: 'bench-language-methodology-v1', + controlSlice: { taskIds: ['javascript:small:owner/passed'] } + }, + config: { + javascript: { label: 'JavaScript' } + }, + results: [ + { + language: 'javascript', + tier: 'small', + repo: 'owner/passed', + summary: { + backends: ['memory'], + latencyMsAvg: { memory: 4 }, + hitRate: { memory: 1 }, + resultCountAvg: { memory: 3 }, + memoryRss: { memory: { mean: 1024 } }, + buildMs: { index: 50 } + } + }, + { + language: 'javascript', + tier: 'small', + repo: 'owner/degraded', + summary: { + backends: ['memory'], + latencyMsAvg: { memory: 8 }, + hitRate: { memory: 0.9 }, + resultCountAvg: { memory: 2 }, + memoryRss: { memory: { mean: 1024 } }, + buildMs: { index: 70 } + }, + diagnostics: { + process: { + countsByType: { + fallback_used: 2, + queue_delay_hotspot: 1 + }, + countsBySeverity: { + warn: 3 + } + } + } + } + ] +}); + +assert.ok(Array.isArray(output.tasks), 'expected task list in report output'); +assert.equal(output.run.aggregateResultClass, 'passed_with_degradation', 'expected degradation-aware run verdict'); +assert.equal(output.run.exitCode, 0, 'expected degradations to remain zero-exit by default'); +assert.equal(output.run.productionClean?.status, 'fail', 'expected degraded run to fail production-clean gate'); +assert.equal(output.run.productionClean?.exitCode, 1, 'expected production-clean gate to carry failing exit code'); +assert.equal(output.run.productionClean?.metrics?.degradedRepos, 1, 'expected one degraded repo in clean-gate metrics'); +assert.equal(output.run.productionClean?.metrics?.fallbackRepos, 1, 'expected fallback repo counted for clean gate'); +assert.equal(output.methodology?.mode, 'warm', 'expected methodology payload in report output'); +assert.equal(output.overallSummary?.metricTags?.cacheMode, 'warm', 'expected report metric tags to carry cache mode'); +assert.equal(output.run.repoCounts.passed, 1, 'expected one clean passing repo'); +assert.equal(output.run.repoCounts.passedWithDegradation, 1, 'expected one degraded passing repo'); +assert.equal(output.run.countsByDiagnosticTypeScope, 'repo_presence', 'expected explicit diagnostic count scope'); +assert.equal(output.run.countsByDiagnosticType.fallback_used, 1, 'expected diagnostic type counted once at repo level'); +assert.equal(output.run.countsByDiagnosticType.queue_delay_hotspot, 1, 'expected hotspot diagnostic counted once at repo level'); +assert.equal(output.run.countsByDiagnosticSeverity.warn, 3, 'expected severity counts to aggregate across task diagnostics'); + +const degradedTask = output.tasks.find((entry) => entry.repo === 'owner/degraded'); +assert.ok(degradedTask, 'expected degraded task payload'); +assert.equal(degradedTask.taskStatus.resultClass, 'passed_with_degradation', 'expected degraded task class'); +assert.equal(degradedTask.benchContext?.metricTags?.policyVersion, 'bench-language-methodology-v1', 'expected task metric tags'); +assert.deepEqual( + degradedTask.taskStatus.degradationClasses, + ['fallback_used', 'queue_delay_hotspot'], + 'expected degradation classes to be preserved' +); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-language-run-verdict'); +const waiverPath = path.join(tempRoot, 'waivers.json'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.writeFile( + waiverPath, + JSON.stringify({ + schemaVersion: 1, + policyVersion: 'bench-language-policy-v1', + waivers: [ + { + id: 'waive-benchmark-failure', + owner: 'bench-owner', + justification: 'fixture coverage for waived repo failure verdicts', + allowedUntil: '2099-01-01T00:00:00.000Z', + resultClass: 'repo_failed', + failureClass: 'benchmark_failed', + repo: 'owner/waived' + } + ] + }, null, 2) +); + +const waivedOutput = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: '/tmp/results', + waiverFile: waiverPath, + methodology: { + mode: 'reliability', + cacheMode: 'warm', + toolingMode: 'disabled', + corpusVersion: 'repos-fixture', + policyVersion: 'bench-language-methodology-v1', + controlSlice: { taskIds: [] } + }, + config: { + javascript: { label: 'JavaScript' } + }, + results: [ + { + language: 'javascript', + tier: 'small', + repo: 'owner/waived', + failed: true, + failureReason: 'bench', + failureCode: 1 + } + ] +}); + +assert.equal( + waivedOutput.run.aggregateResultClass, + 'passed_with_degradation', + 'expected waived repo failures to downgrade aggregate result' +); +assert.equal(waivedOutput.run.exitCode, 0, 'expected waived repo failures to stay zero-exit'); +assert.equal(waivedOutput.run.productionClean?.status, 'pass', 'expected waived fixture to satisfy zero-threshold clean gate'); +assert.deepEqual( + waivedOutput.run.policy.matchedWaiverIds, + ['waive-benchmark-failure'], + 'expected matched waiver id to be recorded' +); +assert.equal(waivedOutput.run.issues.waivedCount, 1, 'expected one waived issue'); + +console.log('bench language run verdict test passed'); diff --git a/tests/perf/bench/bench-language-timeout-autoscale.test.js b/tests/perf/bench/language-timeout-autoscale.test.js similarity index 100% rename from tests/perf/bench/bench-language-timeout-autoscale.test.js rename to tests/perf/bench/language-timeout-autoscale.test.js diff --git a/tests/perf/bench/language-timeout-profile.test.js b/tests/perf/bench/language-timeout-profile.test.js new file mode 100644 index 000000000..7c51dd35f --- /dev/null +++ b/tests/perf/bench/language-timeout-profile.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + resolveBenchProcessTimeoutProfile, + resolveBenchRuntimeAdaptationPlan +} from '../../../tools/bench/language/timeout.js'; + +const baseline = resolveBenchProcessTimeoutProfile({ repoTimeoutMs: 30 * 60 * 1000 }); +assert.equal(baseline.idleTimeoutMs, 30 * 60 * 1000, 'expected idle timeout to match configured repo timeout'); +assert.ok(baseline.hardTimeoutMs > baseline.idleTimeoutMs, 'expected hard timeout to exceed idle timeout'); + +const disabled = resolveBenchProcessTimeoutProfile({ repoTimeoutMs: 0 }); +assert.deepEqual(disabled, { idleTimeoutMs: 0, hardTimeoutMs: 0 }, 'expected disabled timeout profile to remain disabled'); + +const small = resolveBenchProcessTimeoutProfile({ + repoTimeoutMs: 4321, + hardTimeoutScale: 1.5, + hardTimeoutPaddingMs: 1000, + maxHardTimeoutMs: 10000 +}); +assert.equal(small.idleTimeoutMs, 4321, 'expected explicit repo timeout to be preserved as idle budget'); +assert.equal(small.hardTimeoutMs, 6482, 'expected hard timeout to honor scaling and exceed idle budget'); + +const adapted = resolveBenchRuntimeAdaptationPlan({ + repoTimeoutMs: 30 * 60 * 1000, + language: 'starlark', + buildIndex: true, + buildSqlite: true, + realEmbeddings: true, + lineStats: { + totals: { + code: 3_400_000, + prose: 25_000, + 'extracted-prose': 12_000, + records: 0 + }, + linesByFile: { + code: {}, + prose: {}, + 'extracted-prose': {}, + records: {} + } + } +}); +assert.equal(adapted.repoShape.tier, 'xlarge', 'expected very large repo shape classification'); +assert.equal(adapted.repoShape.heavyLanguage, true, 'expected heavy-language classification'); +assert.equal(adapted.adapted, true, 'expected adaptive timeout plan for large build repo'); +assert.equal(adapted.recommendedThreads, 2, 'expected xlarge heavy repo thread cap recommendation'); +assert.ok(adapted.repoTimeoutMs > 30 * 60 * 1000, 'expected adaptive timeout budget to exceed baseline'); + +console.log('bench language timeout profile test passed'); diff --git a/tests/perf/bench/language-tooling-lsp-guardrail.test.js b/tests/perf/bench/language-tooling-lsp-guardrail.test.js new file mode 100644 index 000000000..b88977b82 --- /dev/null +++ b/tests/perf/bench/language-tooling-lsp-guardrail.test.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', `bench-tooling-guardrail-${process.pid}-${Date.now()}`); +const scriptPath = path.join(root, 'tools', 'bench', 'language', 'tooling-lsp-guardrail.js'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const runGuardrail = (reportPath, jsonPath, extraArgs = []) => runNode( + [scriptPath, '--report', reportPath, '--json', jsonPath, ...extraArgs], + 'bench language tooling lsp guardrail', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +const benchReportPath = path.join(tempRoot, 'bench-report.json'); +const benchJsonPath = path.join(tempRoot, 'bench-guardrail.json'); +await fs.writeFile(benchReportPath, JSON.stringify({ + tasks: [ + { summary: { latencyMsAvg: { memory: 12.3 } } }, + { summary: { latencyMsAvg: { memory: 9.1 } } } + ], + diagnostics: { + crashRetention: { retainedCount: 0 } + }, + throughputLedger: { + topRegressions: [] + } +}, null, 2), 'utf8'); +const benchResult = runGuardrail(benchReportPath, benchJsonPath); +if (benchResult.status !== 0) { + console.error('bench-language tooling lsp guardrail test failed'); + console.error(benchResult.stderr || benchResult.stdout || ''); +} +assert.equal(benchResult.status, 0, `expected guardrail exit code 0, received ${benchResult.status}`); +const benchPayload = JSON.parse(await fs.readFile(benchJsonPath, 'utf8')); +assert.equal(benchPayload?.status, 'ok', `expected status=ok, received ${String(benchPayload?.status)}`); +assert.equal(benchPayload?.metrics?.summaryCoverage, 1, 'expected full summary coverage'); + +const sloReportPath = path.join(tempRoot, 'slo-report.json'); +const sloJsonPath = path.join(tempRoot, 'slo-guardrail.json'); +await fs.writeFile(sloReportPath, JSON.stringify({ + sampleCount: 500, + metrics: { + requests: 500, + enrichmentCoverage: 0.95, + fatalFailures: 1, + timedOut: 40 + } +}, null, 2), 'utf8'); +const sloResult = runGuardrail(sloReportPath, sloJsonPath); +if (sloResult.status !== 0) { + console.error('bench-language tooling lsp guardrail slo scaling test failed'); + console.error(sloResult.stderr || sloResult.stdout || ''); +} +assert.equal(sloResult.status, 0, `expected slo guardrail exit code 0, received ${sloResult.status}`); +const sloPayload = JSON.parse(await fs.readFile(sloJsonPath, 'utf8')); +assert.equal(sloPayload?.status, 'ok', `expected SLO status=ok, received ${String(sloPayload?.status)}`); +assert.equal(sloPayload?.metrics?.topRegressionCount, 40, 'expected timedOut count in metrics'); +assert.equal(sloPayload?.metrics?.timeoutAbsoluteScaledMax, 100, 'expected sample-scaled absolute timeout max'); + +const sloFailReportPath = path.join(tempRoot, 'slo-fail-report.json'); +const sloFailJsonPath = path.join(tempRoot, 'slo-fail-guardrail.json'); +await fs.writeFile(sloFailReportPath, JSON.stringify({ + sampleCount: 500, + metrics: { + requests: 500, + enrichmentCoverage: 0.95, + fatalFailures: 0, + timedOut: 130 + } +}, null, 2), 'utf8'); +const sloFailResult = runGuardrail(sloFailReportPath, sloFailJsonPath); +assert.equal(sloFailResult.status, 0, `expected informational slo guardrail exit code 0, received ${sloFailResult.status}`); +const sloFailPayload = JSON.parse(await fs.readFile(sloFailJsonPath, 'utf8')); +assert.equal(sloFailPayload?.status, 'warn', `expected SLO failure status=warn, received ${String(sloFailPayload?.status)}`); +assert.ok( + Array.isArray(sloFailPayload?.failures) && sloFailPayload.failures.some((entry) => String(entry).includes('timed out ratio')), + 'expected timed out ratio failure message for high timeout ratio' +); +const sloFailEnforcedResult = runGuardrail(sloFailReportPath, sloFailJsonPath, ['--enforce']); +assert.equal( + sloFailEnforcedResult.status, + 3, + `expected enforced slo guardrail exit code 3, received ${sloFailEnforcedResult.status}` +); +const sloFailEnforcedPayload = JSON.parse(await fs.readFile(sloFailJsonPath, 'utf8')); +assert.equal( + sloFailEnforcedPayload?.status, + 'error', + `expected enforced SLO failure status=error, received ${String(sloFailEnforcedPayload?.status)}` +); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('bench-language tooling lsp guardrail test passed'); diff --git a/tests/perf/bench/language-waiver-exit.test.js b/tests/perf/bench/language-waiver-exit.test.js new file mode 100644 index 000000000..c87ec155f --- /dev/null +++ b/tests/perf/bench/language-waiver-exit.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { evaluateBenchVerdict, loadBenchPolicy } from '../../../tools/bench/language/verdict.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('bench-language-waiver-exit'); +const waiverPath = path.join(tempRoot, 'waivers.json'); +const repoId = 'test/waiver-exit'; + +await fsPromises.writeFile( + waiverPath, + JSON.stringify({ + schemaVersion: 1, + policyVersion: 'bench-language-policy-v1', + waivers: [ + { + id: 'waive-benchmark-failure', + owner: 'bench-owner', + justification: 'intentional harness fixture failure for waiver coverage', + allowedUntil: '2099-01-01T00:00:00.000Z', + resultClass: 'repo_failed', + failureClass: 'benchmark_failed', + repo: repoId + } + ] + }, null, 2) +); + +const failedVerdict = evaluateBenchVerdict({ + tasks: [ + { + repo: repoId, + language: 'javascript', + tier: 'small', + failed: true, + failureReason: 'bench' + } + ], + policy: await loadBenchPolicy() +}); +if (failedVerdict.run.aggregateResultClass !== 'repo_failed') { + console.error(`expected repo_failed aggregate verdict, got ${failedVerdict.run.aggregateResultClass}`); + process.exit(1); +} +if ((failedVerdict.run.issues.unwaivedCount || 0) !== 1) { + console.error(`expected exactly one unwaived issue, got ${failedVerdict.run.issues.unwaivedCount}`); + process.exit(1); +} + +const policy = await loadBenchPolicy({ waiverFile: waiverPath }); +const waivedVerdict = evaluateBenchVerdict({ + tasks: failedVerdict.tasks, + policy +}); +if (waivedVerdict.run.aggregateResultClass !== 'passed_with_degradation') { + console.error(`expected passed_with_degradation verdict, got ${waivedVerdict.run.aggregateResultClass}`); + process.exit(1); +} +if ((waivedVerdict.run.issues.waivedCount || 0) !== 1) { + console.error(`expected exactly one waived issue, got ${waivedVerdict.run.issues.waivedCount}`); + process.exit(1); +} +if (waivedVerdict.run.exitCode !== 0) { + console.error(`expected waived verdict exit code 0, got ${waivedVerdict.run.exitCode}`); + process.exit(1); +} +if (!Array.isArray(waivedVerdict.run.policy.matchedWaiverIds) || !waivedVerdict.run.policy.matchedWaiverIds.includes('waive-benchmark-failure')) { + console.error('expected waiver match recorded in run policy summary'); + process.exit(1); +} + +console.log('bench language waiver exit test passed'); diff --git a/tests/perf/bench/bench-micro-baseline.test.js b/tests/perf/bench/micro-baseline.test.js similarity index 100% rename from tests/perf/bench/bench-micro-baseline.test.js rename to tests/perf/bench/micro-baseline.test.js diff --git a/tests/perf/bench/progress-format.test.js b/tests/perf/bench/progress-format.test.js new file mode 100644 index 000000000..a7c03dcf4 --- /dev/null +++ b/tests/perf/bench/progress-format.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { formatShardFileProgress } from '../../../tools/bench/progress-format.js'; + +const shardByLabel = new Map([['alpha', { index: 2, total: 10 }]]); +const output = formatShardFileProgress({ + shardLabel: 'alpha', + fileIndex: 5, + fileTotal: 20, + pct: 25.0, + file: 'src/app.js' +}, { shardByLabel, lineTotal: 100 }); + +if (!output.includes('[shard 2/10]')) { + console.error('bench progress format test failed: missing shard index'); + process.exit(1); +} +if (!output.includes('5/20')) { + console.error('bench progress format test failed: missing file counts'); + process.exit(1); +} +if (!output.includes('lines 100')) { + console.error('bench progress format test failed: missing line count'); + process.exit(1); +} +if (!output.includes('src/app.js')) { + console.error('bench progress format test failed: missing file path'); + process.exit(1); +} + +console.log('bench progress format test passed'); diff --git a/tests/perf/bench/bench-query-generator-language-family.test.js b/tests/perf/bench/query-generator-language-family.test.js similarity index 100% rename from tests/perf/bench/bench-query-generator-language-family.test.js rename to tests/perf/bench/query-generator-language-family.test.js diff --git a/tests/perf/bench/query-runtime.js b/tests/perf/bench/query-runtime.js new file mode 100644 index 000000000..79e2d225f --- /dev/null +++ b/tests/perf/bench/query-runtime.js @@ -0,0 +1,783 @@ +import fsSync from 'node:fs'; +import os from 'node:os'; +import { fork } from 'node:child_process'; +import { readIndexArtifactBytes } from '../../../src/shared/ops/resource-visibility.js'; +import { killProcessTree } from '../../../src/shared/kill-tree.js'; +import { getIndexDir, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; +import { attachSilentLogging } from '../../helpers/test-env.js'; + +export const DEFAULT_QUERY_WORKER_HEARTBEAT_MS = 5000; +export const DEFAULT_QUERY_WORKER_STALL_WARN_MS = 30000; +export const DEFAULT_QUERY_WORKER_STALL_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_QUERY_WORKER_SHUTDOWN_TIMEOUT_MS = 2000; +const DEFAULT_QUERY_WORKER_STDERR_TAIL_LINES = 40; +const DEFAULT_SQLITE_QUERY_MAX_RUNS_PER_WORKER = 1; +const MiB = 1024 * 1024; +const GiB = 1024 * MiB; + +const toFiniteNonNegative = (value, fallback = 0) => { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback; +}; + +const statSizeSync = (filePath) => { + try { + const stat = fsSync.statSync(filePath); + return Number.isFinite(stat.size) ? stat.size : 0; + } catch { + return 0; + } +}; + +const includesBackend = (backends, prefix) => ( + Array.isArray(backends) && backends.some((backend) => String(backend || '').startsWith(prefix)) +); + +const hasPositiveTimestamp = (value) => Number.isFinite(Number(value)) && Number(value) > 0; + +const classifyQueryBackend = (backend) => ( + String(backend || '').startsWith('sqlite') ? 'sqlite' : 'memory' +); + +/** + * Estimate a safe query-worker count for bench searches. Memory-backed queries + * duplicate large index caches per worker, and SQLite searches benefit little + * from wide parallelism against the same repo-local DBs. + * + * @param {{ + * requestedConcurrency:number, + * backends:string[], + * runtimeRoot?:string, + * userConfig?:object, + * totalSystemMemoryBytes?:number, + * codeArtifactBytes?:number, + * proseArtifactBytes?:number, + * sqliteCodeBytes?:number, + * sqliteProseBytes?:number + * }} input + * @returns {Promise<{ + * requestedConcurrency:number, + * effectiveConcurrency:number, + * reason:string, + * totalArtifactBytes:number, + * totalSqliteBytes:number, + * estimatedPerWorkerBytes:number, + * budgetBytes:number, + * backendConcurrency:Record, + * backendMaxRunsPerWorker:Record + * }>} + */ +export const resolveAdaptiveQueryWorkerCount = async ({ + requestedConcurrency, + backends, + runtimeRoot = '', + userConfig = null, + totalSystemMemoryBytes = os.totalmem(), + codeArtifactBytes = null, + proseArtifactBytes = null, + sqliteCodeBytes = null, + sqliteProseBytes = null +} = {}) => { + const requested = Math.max(1, Math.floor(Number(requestedConcurrency) || 1)); + const wantsMemory = Array.isArray(backends) && backends.includes('memory'); + const wantsSqlite = includesBackend(backends, 'sqlite'); + if (!wantsMemory && !wantsSqlite) { + return { + requestedConcurrency: requested, + effectiveConcurrency: requested, + reason: 'no_query_backends', + totalArtifactBytes: 0, + totalSqliteBytes: 0, + estimatedPerWorkerBytes: 0, + budgetBytes: 0, + backendConcurrency: {}, + backendMaxRunsPerWorker: {} + }; + } + + const codeBytes = Number.isFinite(Number(codeArtifactBytes)) + ? Number(codeArtifactBytes) + : (runtimeRoot ? await readIndexArtifactBytes(getIndexDir(runtimeRoot, 'code', userConfig) || '') : null); + const proseBytes = Number.isFinite(Number(proseArtifactBytes)) + ? Number(proseArtifactBytes) + : (runtimeRoot ? await readIndexArtifactBytes(getIndexDir(runtimeRoot, 'prose', userConfig) || '') : null); + const sqlitePaths = runtimeRoot ? resolveSqlitePaths(runtimeRoot, userConfig) : null; + const sqliteCode = Number.isFinite(Number(sqliteCodeBytes)) + ? Number(sqliteCodeBytes) + : statSizeSync(sqlitePaths?.codePath); + const sqliteProse = Number.isFinite(Number(sqliteProseBytes)) + ? Number(sqliteProseBytes) + : statSizeSync(sqlitePaths?.prosePath); + + const totalArtifactBytes = toFiniteNonNegative(codeBytes) + toFiniteNonNegative(proseBytes); + const totalSqliteBytes = toFiniteNonNegative(sqliteCode) + toFiniteNonNegative(sqliteProse); + const estimatedMemoryBytes = wantsMemory ? Math.max(256 * MiB, Math.ceil(totalArtifactBytes * 2.5)) : 0; + const estimatedSqliteBytes = wantsSqlite ? Math.max(256 * MiB, Math.ceil(totalSqliteBytes * 0.75)) : 0; + const estimatedPerWorkerBytes = estimatedMemoryBytes + estimatedSqliteBytes; + const budgetBytes = Math.max(512 * MiB, Math.floor(toFiniteNonNegative(totalSystemMemoryBytes) * 0.4)); + + let reason = 'requested'; + const backendConcurrency = {}; + const backendMaxRunsPerWorker = {}; + let remainingBudget = budgetBytes; + let remainingRequested = requested; + + if (wantsMemory) { + const memoryTarget = Math.max(1, requested - (wantsSqlite ? 1 : 0)); + let memoryCap = memoryTarget; + if (totalArtifactBytes >= 2 * GiB) { + memoryCap = 1; + reason = 'memory_artifact_very_large'; + } else if (totalArtifactBytes >= 512 * MiB) { + memoryCap = Math.min(memoryCap, 2); + reason = 'memory_artifact_large'; + } + const memoryBudgetCap = estimatedMemoryBytes > 0 + ? Math.max(1, Math.floor(remainingBudget / estimatedMemoryBytes)) + : memoryCap; + const memoryWorkers = Math.max(1, Math.min(memoryCap, remainingRequested, memoryBudgetCap)); + backendConcurrency.memory = memoryWorkers; + backendMaxRunsPerWorker.memory = Number.POSITIVE_INFINITY; + remainingBudget = Math.max(0, remainingBudget - (memoryWorkers * estimatedMemoryBytes)); + remainingRequested = Math.max(0, remainingRequested - memoryWorkers); + if (memoryWorkers < memoryTarget && reason === 'requested') { + reason = 'memory_budget'; + } + } + + if (wantsSqlite) { + const sqliteTarget = Math.max(1, wantsMemory ? 1 : Math.min(2, requested)); + let sqliteCap = sqliteTarget; + if (wantsMemory) { + sqliteCap = 1; + if (reason === 'requested') reason = 'sqlite_mixed_serialized'; + } else if (totalSqliteBytes >= GiB) { + sqliteCap = 1; + if (reason === 'requested') reason = 'sqlite_db_very_large'; + } + const sqliteBudgetCap = estimatedSqliteBytes > 0 + ? Math.max(1, Math.floor((remainingBudget || budgetBytes) / estimatedSqliteBytes)) + : sqliteCap; + const sqliteWorkers = Math.max(1, Math.min(sqliteCap, Math.max(1, remainingRequested || sqliteCap), sqliteBudgetCap)); + backendConcurrency.sqlite = sqliteWorkers; + backendMaxRunsPerWorker.sqlite = DEFAULT_SQLITE_QUERY_MAX_RUNS_PER_WORKER; + remainingBudget = Math.max(0, remainingBudget - (sqliteWorkers * estimatedSqliteBytes)); + if (sqliteWorkers < sqliteTarget && reason === 'requested') { + reason = 'sqlite_budget'; + } + } + + const effectiveConcurrency = Math.max( + 1, + Object.values(backendConcurrency).reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0) + ); + if (reason === 'requested' && estimatedPerWorkerBytes > 0) { + reason = 'within_budget'; + } + return { + requestedConcurrency: requested, + effectiveConcurrency, + reason, + totalArtifactBytes, + totalSqliteBytes, + estimatedPerWorkerBytes, + budgetBytes, + backendConcurrency, + backendMaxRunsPerWorker + }; +}; + +const createWorkerStallError = ({ label, workerLabel, id, elapsedMs }) => { + const error = new Error( + `Query worker ${workerLabel} stalled for ${Math.round(elapsedMs)}ms while running ${label} (request=${id}).` + ); + error.code = 'ERR_QUERY_WORKER_STALLED'; + return error; +}; + +const createWorkerClosedError = ({ label, workerLabel, requestId = null, meta = null }) => { + const backend = meta?.backend ? ` backend=${meta.backend}` : ''; + const queryText = typeof meta?.query === 'string' && meta.query.trim() + ? ` query="${meta.query.trim().slice(0, 80)}${meta.query.trim().length > 80 ? '...' : ''}"` + : ''; + const requestText = Number.isFinite(Number(requestId)) ? ` request=${Math.floor(Number(requestId))}` : ''; + const error = new Error( + `Query worker ${workerLabel} closed before completing ${label}.${requestText}${backend}${queryText}` + ); + error.code = 'ERR_QUERY_WORKER_CLOSED'; + error.meta = { + workerLabel, + label, + requestId: Number.isFinite(Number(requestId)) ? Math.floor(Number(requestId)) : null, + backend: meta?.backend || null, + query: meta?.query || null + }; + return error; +}; + +const createWorkerExitError = ({ + label, + workerLabel, + code, + signal, + requestId = null, + meta = null, + elapsedMs = null, + sinceHeartbeatMs = null, + rssBytes = null, + stderrTail = '', + completedRuns = 0 +}) => { + const backend = meta?.backend ? ` backend=${meta.backend}` : ''; + const queryText = typeof meta?.query === 'string' && meta.query.trim() + ? ` query="${meta.query.trim().slice(0, 80)}${meta.query.trim().length > 80 ? '...' : ''}"` + : ''; + const elapsedText = Number.isFinite(Number(elapsedMs)) ? ` elapsedMs=${Math.floor(Number(elapsedMs))}` : ''; + const heartbeatText = Number.isFinite(Number(sinceHeartbeatMs)) + ? ` sinceHeartbeatMs=${Math.floor(Number(sinceHeartbeatMs))}` + : ''; + const rssText = Number.isFinite(Number(rssBytes)) + ? ` rssMiB=${(Number(rssBytes) / MiB).toFixed(1)}` + : ''; + const requestText = Number.isFinite(Number(requestId)) ? ` request=${Math.floor(Number(requestId))}` : ''; + const error = new Error( + `Query worker ${workerLabel} exited early while running ${label} ` + + `(code=${code ?? 'null'}, signal=${signal ?? 'null'}, completedRuns=${completedRuns}).` + + `${requestText}${backend}${elapsedText}${heartbeatText}${rssText}${queryText}` + + (stderrTail ? ` stderrTail=${stderrTail}` : '') + ); + error.code = 'ERR_QUERY_WORKER_EXIT'; + error.meta = { + workerLabel, + label, + code, + signal, + requestId, + backend: meta?.backend || null, + query: meta?.query || null, + elapsedMs: Number.isFinite(Number(elapsedMs)) ? Math.floor(Number(elapsedMs)) : null, + sinceHeartbeatMs: Number.isFinite(Number(sinceHeartbeatMs)) ? Math.floor(Number(sinceHeartbeatMs)) : null, + rssBytes: Number.isFinite(Number(rssBytes)) ? Number(rssBytes) : null, + completedRuns, + stderrTail + }; + return error; +}; + +const resolveRequestElapsedMs = (request, nowTs) => { + if (hasPositiveTimestamp(request?.startedAt)) { + return Math.max(0, nowTs - Number(request.startedAt)); + } + if (hasPositiveTimestamp(request?.sentAt)) { + return Math.max(0, nowTs - Number(request.sentAt)); + } + return null; +}; + +const resolveSinceHeartbeatMs = (request, nowTs) => { + if (hasPositiveTimestamp(request?.lastHeartbeatAt)) { + return Math.max(0, nowTs - Number(request.lastHeartbeatAt)); + } + if (hasPositiveTimestamp(request?.startedAt)) { + return Math.max(0, nowTs - Number(request.startedAt)); + } + if (hasPositiveTimestamp(request?.sentAt)) { + return Math.max(0, nowTs - Number(request.sentAt)); + } + return null; +}; + +const normalizeMaxRunsPerProcess = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return Number.POSITIVE_INFINITY; + const normalized = Math.floor(numeric); + return normalized > 0 ? normalized : Number.POSITIVE_INFINITY; +}; + +/** + * Create a forked worker wrapper with heartbeat-aware stall detection. + * + * @param {{ + * label:string, + * env:NodeJS.ProcessEnv, + * workerScriptPath:string, + * onEvent?:(event:object)=>void, + * heartbeatMs?:number, + * stallWarnMs?:number, + * stallTimeoutMs?:number, + * shutdownTimeoutMs?:number, + * maxRunsPerProcess?:number, + * stderrTailLines?:number, + * now?:()=>number + * }} input + * @returns {{run:(args:string[],meta?:object)=>Promise,close:()=>Promise,isBusy:()=>boolean,completedRuns:()=>number}} + */ +const createSearchWorker = ({ + label, + env, + workerScriptPath, + onEvent = null, + heartbeatMs = DEFAULT_QUERY_WORKER_HEARTBEAT_MS, + stallWarnMs = DEFAULT_QUERY_WORKER_STALL_WARN_MS, + stallTimeoutMs = DEFAULT_QUERY_WORKER_STALL_TIMEOUT_MS, + shutdownTimeoutMs = DEFAULT_QUERY_WORKER_SHUTDOWN_TIMEOUT_MS, + maxRunsPerProcess = Number.POSITIVE_INFINITY, + stderrTailLines = DEFAULT_QUERY_WORKER_STDERR_TAIL_LINES, + now = () => Date.now() +}) => { + const normalizedMaxRunsPerProcess = normalizeMaxRunsPerProcess(maxRunsPerProcess); + let nextMessageId = 1; + let nextGeneration = 1; + let currentSession = null; + let watchdog = null; + let activeRequest = null; + let completedRuns = 0; + let closed = false; + + const emitEvent = (event) => { + if (typeof onEvent !== 'function') return; + try { + onEvent({ workerLabel: label, ...event }); + } catch {} + }; + + const clearWatchdog = () => { + if (!watchdog) return; + clearInterval(watchdog); + watchdog = null; + }; + + const terminateChild = (targetChild, signal = 'SIGTERM') => { + const pid = Number(targetChild?.pid); + if (!Number.isFinite(pid)) return Promise.resolve(); + return killProcessTree(pid, { + signal, + forceSignal: signal === 'SIGKILL' ? undefined : 'SIGKILL', + graceMs: 250, + killTree: true, + detached: process.platform !== 'win32' + }).catch(() => {}); + }; + + const appendStderr = (session, chunk) => { + const text = String(chunk || ''); + if (!text) return; + session.stderrLines.push(...text.split(/\r?\n/).filter(Boolean)); + if (session.stderrLines.length > stderrTailLines) { + session.stderrLines = session.stderrLines.slice(-stderrTailLines); + } + }; + + const getStderrTail = (session) => session?.stderrLines?.join(' | ') || ''; + + const ownsActiveRequest = (session, id) => ( + Number.isFinite(Number(id)) && + Boolean(activeRequest) && + activeRequest.id === Number(id) && + activeRequest.ownerGeneration === session.generation + ); + + const clearActiveRequest = (session, id) => { + if (!ownsActiveRequest(session, id)) return null; + const request = activeRequest; + activeRequest = null; + return request; + }; + + const createWorkerSession = () => { + const spawnedChild = fork(workerScriptPath, [], { + env, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }); + const session = { + generation: nextGeneration, + child: spawnedChild, + pid: Number(spawnedChild?.pid) || null, + runsSinceStart: 0, + stderrLines: [] + }; + nextGeneration += 1; + currentSession = session; + attachSilentLogging(spawnedChild, label); + spawnedChild.stderr?.on('data', (chunk) => appendStderr(session, chunk)); + spawnedChild.on('message', (message) => { + const id = Number(message?.id); + if (message?.type === 'run-start') { + if (!ownsActiveRequest(session, id)) return; + activeRequest.startedAt = now(); + activeRequest.lastHeartbeatAt = activeRequest.startedAt; + emitEvent({ + type: 'run-start', + id, + meta: activeRequest.meta, + elapsedMs: 0, + pid: session.pid + }); + return; + } + if (message?.type === 'run-heartbeat') { + if (!ownsActiveRequest(session, id)) return; + activeRequest.lastHeartbeatAt = now(); + activeRequest.lastRssBytes = toFiniteNonNegative(message.rssBytes); + emitEvent({ + type: 'run-heartbeat', + id, + meta: activeRequest.meta, + elapsedMs: toFiniteNonNegative(message.elapsedMs), + rssBytes: activeRequest.lastRssBytes, + pid: session.pid + }); + return; + } + if (message?.type === 'run-complete') { + if (!ownsActiveRequest(session, id)) return; + activeRequest.lastHeartbeatAt = now(); + emitEvent({ + type: 'run-complete', + id, + elapsedMs: toFiniteNonNegative(message.elapsedMs), + pid: session.pid + }); + return; + } + if (!Number.isFinite(id)) return; + const request = clearActiveRequest(session, id); + if (!request) return; + if (message?.ok) { + completedRuns += 1; + session.runsSinceStart += 1; + request.resolve(message.payload || {}); + return; + } + const err = new Error(message?.error?.message || `Query worker ${label} failed`); + err.code = message?.error?.code || 'ERR_QUERY_WORKER'; + request.reject(err); + }); + spawnedChild.on('error', (err) => { + const request = activeRequest?.ownerGeneration === session.generation ? activeRequest : null; + if (!request) return; + activeRequest = null; + request.reject(err instanceof Error ? err : new Error(String(err))); + }); + spawnedChild.on('exit', (code, signal) => { + if (currentSession === session) { + currentSession = null; + } + const request = activeRequest?.ownerGeneration === session.generation ? activeRequest : null; + if (!request) return; + activeRequest = null; + const nowTs = now(); + const elapsedMs = resolveRequestElapsedMs(request, nowTs); + const sinceHeartbeatMs = resolveSinceHeartbeatMs(request, nowTs); + request.reject(createWorkerExitError({ + label, + workerLabel: label, + code, + signal, + requestId: request.id, + meta: request.meta, + elapsedMs, + sinceHeartbeatMs, + rssBytes: request.lastRssBytes, + stderrTail: getStderrTail(session), + completedRuns + })); + }); + return session; + }; + + const closeChild = async (targetSession = currentSession, { cancelActiveRequest = false } = {}) => { + const targetChild = targetSession?.child; + if (!targetChild || targetChild.killed || targetChild.exitCode != null) return; + if (currentSession === targetSession) { + currentSession = null; + } + if ( + cancelActiveRequest === true && + activeRequest?.ownerGeneration === targetSession?.generation + ) { + const request = activeRequest; + activeRequest = null; + request.reject(createWorkerClosedError({ + label, + workerLabel: label, + requestId: request.id, + meta: request.meta + })); + } + await new Promise((resolve) => { + let settled = false; + let timeout = null; + const finish = () => { + if (settled) return; + settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + resolve(); + }; + targetChild.once('exit', finish); + try { + targetChild.send({ type: 'shutdown' }); + } catch { + finish(); + } + timeout = setTimeout(async () => { + await terminateChild(targetChild, 'SIGTERM'); + finish(); + }, Math.max(250, Math.floor(shutdownTimeoutMs))); + timeout.unref?.(); + }); + }; + + const ensureChild = () => { + if (closed) { + const err = new Error(`Query worker ${label} is closed.`); + err.code = 'ERR_QUERY_WORKER_CLOSED'; + throw err; + } + if ( + currentSession && + currentSession.child?.exitCode == null && + currentSession.child?.killed !== true + ) { + return currentSession; + } + const session = createWorkerSession(); + clearWatchdog(); + watchdog = setInterval(() => { + if (!activeRequest) return; + const currentTs = now(); + const elapsedMs = resolveRequestElapsedMs(activeRequest, currentTs); + const sinceHeartbeatMs = resolveSinceHeartbeatMs(activeRequest, currentTs); + if (sinceHeartbeatMs >= stallWarnMs && currentTs - activeRequest.lastWarnAt >= stallWarnMs) { + activeRequest.lastWarnAt = currentTs; + emitEvent({ + type: 'stall-warning', + id: activeRequest.id, + meta: activeRequest.meta, + elapsedMs, + sinceHeartbeatMs, + rssBytes: activeRequest.lastRssBytes, + pid: activeRequest.ownerPid + }); + } + if (stallTimeoutMs > 0 && sinceHeartbeatMs >= stallTimeoutMs) { + const request = activeRequest; + activeRequest = null; + const error = createWorkerStallError({ label, workerLabel: label, id: request.id, elapsedMs }); + emitEvent({ + type: 'stalled', + id: request.id, + meta: request.meta, + elapsedMs, + sinceHeartbeatMs, + rssBytes: request.lastRssBytes, + pid: request.ownerPid + }); + request.reject(error); + if (currentSession?.generation === request.ownerGeneration) { + currentSession = null; + } + terminateChild(request.ownerSession?.child, 'SIGTERM'); + clearWatchdog(); + } + }, Math.max(250, Math.floor(heartbeatMs))); + watchdog.unref?.(); + return session; + }; + + const run = async (args, meta = null) => { + if (closed) { + throw createWorkerClosedError({ label, workerLabel: label, meta }); + } + if (activeRequest) { + const err = new Error(`Query worker ${label} received concurrent work while busy.`); + err.code = 'ERR_QUERY_WORKER_BUSY'; + throw err; + } + const activeSession = ensureChild(); + const id = nextMessageId; + nextMessageId += 1; + const payload = await new Promise((resolve, reject) => { + activeRequest = { + id, + resolve, + reject, + meta, + args, + sentAt: now(), + startedAt: null, + lastHeartbeatAt: now(), + lastWarnAt: 0, + lastRssBytes: null, + ownerGeneration: activeSession.generation, + ownerPid: activeSession.pid, + ownerSession: activeSession + }; + try { + activeSession.child.send({ type: 'run', id, args, meta }); + } catch (error) { + activeRequest = null; + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + if (activeSession.runsSinceStart >= normalizedMaxRunsPerProcess) { + await closeChild(activeSession); + } + return payload; + }; + + const close = async () => { + closed = true; + clearWatchdog(); + await closeChild(currentSession, { cancelActiveRequest: true }); + }; + + return { + run, + close, + isBusy: () => Boolean(activeRequest), + completedRuns: () => completedRuns + }; +}; + +/** + * Create a worker pool for bench search queries. + * + * @param {{ + * size:number, + * maxRunsPerProcess?:number, + * sizeByBackend?:Record|null, + * maxRunsPerWorkerByBackend?:Record|null, + * env:NodeJS.ProcessEnv, + * workerScriptPath:string, + * onEvent?:(event:object)=>void, + * heartbeatMs?:number, + * stallWarnMs?:number, + * stallTimeoutMs?:number + * }} input + * @returns {{run:(args:string[],meta?:object)=>Promise,close:()=>Promise}} + */ +export const createSearchWorkerPool = ({ + size, + maxRunsPerProcess = Number.POSITIVE_INFINITY, + sizeByBackend = null, + maxRunsPerWorkerByBackend = null, + env, + workerScriptPath, + onEvent = null, + heartbeatMs = DEFAULT_QUERY_WORKER_HEARTBEAT_MS, + stallWarnMs = DEFAULT_QUERY_WORKER_STALL_WARN_MS, + stallTimeoutMs = DEFAULT_QUERY_WORKER_STALL_TIMEOUT_MS +}) => { + const normalizedSizeByBackend = sizeByBackend && typeof sizeByBackend === 'object' + ? Object.fromEntries( + Object.entries(sizeByBackend) + .map(([key, value]) => [String(key), Math.max(0, Math.floor(Number(value) || 0))]) + .filter(([, value]) => value > 0) + ) + : null; + const backendEntries = normalizedSizeByBackend + ? Object.entries(normalizedSizeByBackend) + : [['default', Math.max(1, Math.floor(size) || 1)]]; + const workerGroups = Object.fromEntries( + backendEntries.map(([backendKey, workerCount]) => [ + backendKey, + Array.from({ length: workerCount }, (_, index) => ( + createSearchWorker({ + label: `bench-worker:${backendKey}:${index + 1}`, + env, + workerScriptPath, + onEvent, + heartbeatMs, + stallWarnMs, + stallTimeoutMs, + maxRunsPerProcess: Number.isFinite(Number(maxRunsPerWorkerByBackend?.[backendKey])) + ? normalizeMaxRunsPerProcess(maxRunsPerWorkerByBackend[backendKey]) + : normalizeMaxRunsPerProcess(maxRunsPerProcess) + }) + )) + ]) + ); + const queue = []; + let pumping = false; + let closed = false; + + const createPoolClosedError = (meta = null) => { + const backend = meta?.backend ? ` backend=${meta.backend}` : ''; + const queryText = typeof meta?.query === 'string' && meta.query.trim() + ? ` query="${meta.query.trim().slice(0, 80)}${meta.query.trim().length > 80 ? '...' : ''}"` + : ''; + const error = new Error(`Bench query worker pool closed before dispatch.${backend}${queryText}`); + error.code = 'ERR_QUERY_WORKER_POOL_CLOSED'; + error.meta = { + backend: meta?.backend || null, + query: meta?.query || null + }; + return error; + }; + + const resolveGroupKey = (meta = null) => { + if (!normalizedSizeByBackend) return 'default'; + const preferredKey = classifyQueryBackend(meta?.backend); + if (workerGroups[preferredKey]?.length) return preferredKey; + return Object.keys(workerGroups)[0]; + }; + + const pump = async () => { + if (pumping) return; + pumping = true; + try { + while (queue.length) { + if (closed) { + while (queue.length) { + const request = queue.shift(); + request?.reject(createPoolClosedError(request?.meta)); + } + break; + } + let dispatched = false; + for (let index = 0; index < queue.length; index += 1) { + const request = queue[index]; + const groupKey = resolveGroupKey(request.meta); + const workers = workerGroups[groupKey] || []; + const worker = workers.find((candidate) => !candidate.isBusy()); + if (!worker) continue; + queue.splice(index, 1); + dispatched = true; + void worker.run(request.args, request.meta) + .then(request.resolve, request.reject) + .finally(() => { + void pump(); + }); + break; + } + if (!dispatched) break; + } + } finally { + pumping = false; + } + }; + + const run = (args, meta = null) => new Promise((resolve, reject) => { + if (closed) { + reject(createPoolClosedError(meta)); + return; + } + queue.push({ args, meta, resolve, reject }); + void pump(); + }); + + const close = async () => { + closed = true; + while (queue.length) { + const request = queue.shift(); + request?.reject(createPoolClosedError(request?.meta)); + } + const allWorkers = Object.values(workerGroups).flat(); + await Promise.all(allWorkers.map((worker) => worker.close())); + }; + return { run, close }; +}; diff --git a/tests/perf/bench/query-runtime.test.js b/tests/perf/bench/query-runtime.test.js new file mode 100644 index 000000000..646d52230 --- /dev/null +++ b/tests/perf/bench/query-runtime.test.js @@ -0,0 +1,354 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + createSearchWorkerPool, + resolveAdaptiveQueryWorkerCount +} from './query-runtime.js'; + +ensureTestingEnv(process.env); + +const GiB = 1024 * 1024 * 1024; +const MiB = 1024 * 1024; +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `bench-query-runtime-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const adaptive = await resolveAdaptiveQueryWorkerCount({ + requestedConcurrency: 4, + backends: ['memory', 'sqlite'], + totalSystemMemoryBytes: 64 * GiB, + codeArtifactBytes: 3 * GiB, + proseArtifactBytes: 128 * MiB, + sqliteCodeBytes: 256 * MiB, + sqliteProseBytes: 128 * MiB +}); +assert.equal( + adaptive.effectiveConcurrency, + 2, + 'expected giant memory artifacts to clamp mixed query workers to memory=1 and sqlite=1' +); +assert.equal(adaptive.reason, 'memory_artifact_very_large', 'expected giant artifact reason'); +assert.deepEqual( + adaptive.backendConcurrency, + { memory: 1, sqlite: 1 }, + 'expected mixed query plan to expose backend-specific worker counts' +); +assert.equal( + adaptive.backendMaxRunsPerWorker.sqlite, + 1, + 'expected sqlite worker plan to recycle each worker after one run' +); + +const workerScriptPath = path.join(tempRoot, 'worker.js'); +await fs.writeFile(workerScriptPath, [ + "const send = (payload) => { if (typeof process.send === 'function') process.send(payload); };", + "let heartbeat = null;", + "const clearHeartbeat = () => { if (heartbeat) clearInterval(heartbeat); heartbeat = null; };", + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') { clearHeartbeat(); process.exit(0); return; }", + " if (message?.type !== 'run') return;", + " const id = Number(message.id);", + " const mode = Array.isArray(message.args) ? String(message.args[0] || '') : '';", + " send({ type: 'run-start', id, elapsedMs: 0 });", + " if (mode === '--stall') return;", + " const startedAt = Date.now();", + " heartbeat = setInterval(() => {", + " send({ type: 'run-heartbeat', id, elapsedMs: Date.now() - startedAt, rssBytes: 32 * 1024 * 1024 });", + " }, 20);", + " heartbeat.unref?.();", + " setTimeout(() => {", + " clearHeartbeat();", + " send({ type: 'run-complete', id, elapsedMs: Date.now() - startedAt });", + " send({ id, ok: true, payload: { stats: { elapsedMs: 42, memory: { rss: 32 * 1024 * 1024 } }, code: [], prose: [] } });", + " }, 90);", + "});" +].join('\n'), 'utf8'); + +const successEvents = []; +const successPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath, + heartbeatMs: 20, + stallWarnMs: 250, + stallTimeoutMs: 1000, + onEvent: (event) => successEvents.push(event) +}); +const successPayload = await successPool.run(['--ok'], { backend: 'memory', query: 'select 1' }); +assert.equal(Number(successPayload?.stats?.elapsedMs), 42, 'expected worker payload to resolve'); +assert.equal( + successEvents.some((event) => event?.type === 'run-start'), + true, + 'expected run-start event' +); +assert.equal( + successEvents.some((event) => event?.type === 'run-heartbeat'), + true, + 'expected run-heartbeat event' +); +assert.equal( + successEvents.some((event) => event?.type === 'run-complete'), + true, + 'expected run-complete event' +); +await successPool.close(); + +const recycleScriptPath = path.join(tempRoot, 'recycle-worker.js'); +await fs.writeFile(recycleScriptPath, [ + "if (typeof process.send !== 'function') process.exit(2);", + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') { process.exit(0); return; }", + " if (message?.type !== 'run') return;", + " process.send({ type: 'run-start', id: Number(message.id), elapsedMs: 0 });", + " process.send({ type: 'run-complete', id: Number(message.id), elapsedMs: 0 });", + " process.send({ id: Number(message.id), ok: true, payload: { pid: process.pid } });", + "});" +].join('\n'), 'utf8'); + +const recyclingPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: recycleScriptPath, + maxRunsPerProcess: 1 +}); +const firstRecycle = await recyclingPool.run(['--ok'], { backend: 'sqlite', query: 'select 1' }); +const secondRecycle = await recyclingPool.run(['--ok'], { backend: 'sqlite', query: 'select 2' }); +assert.notEqual( + firstRecycle?.pid, + secondRecycle?.pid, + 'expected sqlite-style worker recycling to fork a fresh child after each run' +); +await recyclingPool.close(); + +const nonRecyclingPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: recycleScriptPath, + maxRunsPerProcess: 0 +}); +const firstNonRecycling = await nonRecyclingPool.run(['--ok'], { backend: 'memory', query: 'select keepalive 1' }); +const secondNonRecycling = await nonRecyclingPool.run(['--ok'], { backend: 'memory', query: 'select keepalive 2' }); +assert.equal( + firstNonRecycling?.pid, + secondNonRecycling?.pid, + 'expected maxRunsPerProcess=0 to preserve the same worker instead of forcing recycle' +); +await nonRecyclingPool.close(); + +const exitingScriptPath = path.join(tempRoot, 'exit-worker.js'); +await fs.writeFile(exitingScriptPath, [ + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') { process.exit(0); return; }", + " if (message?.type !== 'run') return;", + " process.stderr.write('worker exploded\\n');", + " process.exit(7);", + "});" +].join('\n'), 'utf8'); + +const exitPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: exitingScriptPath +}); +let exitError = null; +try { + await exitPool.run(['--explode'], { backend: 'sqlite', query: 'explode query' }); +} catch (error) { + exitError = error; +} +assert.equal(exitError?.code, 'ERR_QUERY_WORKER_EXIT', 'expected early worker exit to surface deterministic code'); +assert.match(exitError?.message || '', /backend=sqlite/, 'expected exit error to retain backend context'); +assert.match(exitError?.message || '', /explode query/, 'expected exit error to retain query preview'); +assert.match(exitError?.meta?.stderrTail || '', /worker exploded/, 'expected exit error to include stderr tail'); +await exitPool.close(); + +const stalledEvents = []; +const stalledPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath, + heartbeatMs: 20, + stallWarnMs: 40, + stallTimeoutMs: 80, + onEvent: (event) => stalledEvents.push(event) +}); +await assert.rejects( + () => stalledPool.run(['--stall'], { backend: 'sqlite', query: 'select stalled' }), + (error) => error?.code === 'ERR_QUERY_WORKER_STALLED' +); +assert.equal( + stalledEvents.some((event) => event?.type === 'stall-warning'), + true, + 'expected stall warning event' +); +assert.equal( + stalledEvents.some((event) => event?.type === 'stalled'), + true, + 'expected stalled event' +); +await stalledPool.close(); + +const slowScriptPath = path.join(tempRoot, 'slow-worker.js'); +await fs.writeFile(slowScriptPath, [ + "if (typeof process.send !== 'function') process.exit(2);", + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') { process.exit(0); return; }", + " if (message?.type !== 'run') return;", + " const id = Number(message.id);", + " process.send({ type: 'run-start', id, elapsedMs: 0 });", + " setTimeout(() => {", + " process.send({ type: 'run-heartbeat', id, elapsedMs: 500, rssBytes: 16 * 1024 * 1024 });", + " }, 500);", + "});" +].join('\n'), 'utf8'); + +const closeBusyPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: slowScriptPath, + heartbeatMs: 20, + stallWarnMs: 1000, + stallTimeoutMs: 2000 +}); +const busyRun = closeBusyPool.run(['--slow'], { backend: 'sqlite', query: 'close while busy' }); +const busyRunAssertion = assert.rejects( + () => busyRun, + (error) => error?.code === 'ERR_QUERY_WORKER_CLOSED' +); +await new Promise((resolve) => setTimeout(resolve, 40)); +await closeBusyPool.close(); +await busyRunAssertion; + +const queuedClosePool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: slowScriptPath, + heartbeatMs: 20, + stallWarnMs: 1000, + stallTimeoutMs: 2000 +}); +const queuedRunA = queuedClosePool.run(['--slow-a'], { backend: 'sqlite', query: 'queued close a' }); +const queuedRunB = queuedClosePool.run(['--slow-b'], { backend: 'sqlite', query: 'queued close b' }); +const queuedRunAAssertion = assert.rejects( + () => queuedRunA, + (error) => error?.code === 'ERR_QUERY_WORKER_CLOSED' +); +const queuedRunBAssertion = assert.rejects( + () => queuedRunB, + (error) => error?.code === 'ERR_QUERY_WORKER_POOL_CLOSED' +); +await new Promise((resolve) => setTimeout(resolve, 40)); +await queuedClosePool.close(); +await queuedRunAAssertion; +await queuedRunBAssertion; + +const stubbornShutdownScriptPath = path.join(tempRoot, 'stubborn-shutdown-worker.js'); +await fs.writeFile(stubbornShutdownScriptPath, [ + "if (typeof process.send !== 'function') process.exit(2);", + "let activeTimer = null;", + "process.on('SIGTERM', () => {", + " setTimeout(() => process.exit(0), 150);", + "});", + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') return;", + " if (message?.type !== 'run') return;", + " const id = Number(message.id);", + " process.send({ type: 'run-start', id, elapsedMs: 0 });", + " activeTimer = setTimeout(() => {", + " process.send({ type: 'run-heartbeat', id, elapsedMs: 500, rssBytes: 16 * 1024 * 1024 });", + " }, 500);", + "});" +].join('\n'), 'utf8'); + +const stubbornEvents = []; +const stubbornPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env }, + workerScriptPath: stubbornShutdownScriptPath, + heartbeatMs: 20, + stallWarnMs: 1000, + stallTimeoutMs: 2000, + onEvent: (event) => stubbornEvents.push(event) +}); +const stubbornRun = stubbornPool.run(['--slow'], { backend: 'sqlite', query: 'stubborn close' }); +const stubbornRunAssertion = assert.rejects( + () => stubbornRun, + (error) => error?.code === 'ERR_QUERY_WORKER_CLOSED' +); +await new Promise((resolve) => setTimeout(resolve, 40)); +const stubbornPid = stubbornEvents.find((event) => event?.type === 'run-start')?.pid ?? null; +await stubbornPool.close(); +await stubbornRunAssertion; +if (Number.isFinite(stubbornPid)) { + assert.throws( + () => process.kill(stubbornPid, 0), + /ESRCH|EPERM/, + 'expected pool close to wait for forced worker shutdown before resolving' + ); +} + +const staleExitScriptPath = path.join(tempRoot, 'stale-exit-worker.js'); +const staleExitMarkerPath = path.join(tempRoot, 'stale-exit-marker.txt'); +await fs.writeFile(staleExitScriptPath, [ + "import fs from 'node:fs';", + "const markerPath = process.env.STALE_EXIT_MARKER_PATH;", + "const send = (payload) => { if (typeof process.send === 'function') process.send(payload); };", + "const hasMarker = () => {", + " try { return fs.existsSync(markerPath); } catch { return false; }", + "};", + "const writeMarker = () => {", + " try { fs.writeFileSync(markerPath, 'spawned', 'utf8'); } catch {}", + "};", + "process.on('SIGTERM', () => {", + " setTimeout(() => process.exit(0), 120);", + "});", + "process.on('message', (message) => {", + " if (message?.type === 'shutdown') { process.exit(0); return; }", + " if (message?.type !== 'run') return;", + " const id = Number(message.id);", + " send({ type: 'run-start', id, elapsedMs: 0 });", + " if (!hasMarker()) {", + " writeMarker();", + " return;", + " }", + " setTimeout(() => {", + " send({ type: 'run-heartbeat', id, elapsedMs: 20, rssBytes: 16 * 1024 * 1024 });", + " send({ type: 'run-complete', id, elapsedMs: 20 });", + " send({ id, ok: true, payload: { pid: process.pid, phase: 'fresh-child' } });", + " }, 20);", + "});" +].join('\n'), 'utf8'); + +const staleExitEvents = []; +const staleExitPool = createSearchWorkerPool({ + size: 1, + env: { ...process.env, STALE_EXIT_MARKER_PATH: staleExitMarkerPath }, + workerScriptPath: staleExitScriptPath, + heartbeatMs: 20, + stallWarnMs: 200, + stallTimeoutMs: 500, + onEvent: (event) => staleExitEvents.push(event) +}); +await assert.rejects( + () => staleExitPool.run(['--first'], { backend: 'sqlite', query: 'first query' }), + (error) => error?.code === 'ERR_QUERY_WORKER_STALLED' +); +const recoveredPayload = await staleExitPool.run(['--second'], { backend: 'sqlite', query: 'second query' }); +assert.equal(recoveredPayload?.phase, 'fresh-child', 'expected replacement worker to complete the second request'); +assert.equal( + staleExitEvents.some((event) => event?.type === 'run-start' && event?.meta?.query === 'second query'), + true, + 'expected the replacement worker to start the second request after the stale worker retired' +); +await staleExitPool.close(); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench query runtime test passed'); diff --git a/tests/perf/bench/query-worker.js b/tests/perf/bench/query-worker.js index 84f619c0d..d6d84f92e 100644 --- a/tests/perf/bench/query-worker.js +++ b/tests/perf/bench/query-worker.js @@ -1,12 +1,18 @@ #!/usr/bin/env node import { runSearchCli } from '../../../src/retrieval/cli.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; +import { getEnvConfig } from '../../../src/shared/env/runtime.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; -applyTestEnv(); +ensureTestingEnv(process.env); +const envConfig = getEnvConfig(); +const HEARTBEAT_MS = Number.isFinite(Number(envConfig.tests?.benchQueryWorkerHeartbeatMs)) + ? Math.max(250, Math.floor(Number(envConfig.tests.benchQueryWorkerHeartbeatMs))) + : 5000; const indexCache = new Map(); const sqliteCache = new Map(); let chain = Promise.resolve(); +let shutdownRequested = false; const sendMessage = (payload) => { if (typeof process.send !== 'function') return; @@ -26,6 +32,17 @@ const runMessage = async (message) => { }); return; } + const startedAt = Date.now(); + sendMessage({ type: 'run-start', id, elapsedMs: 0 }); + const heartbeat = setInterval(() => { + sendMessage({ + type: 'run-heartbeat', + id, + elapsedMs: Date.now() - startedAt, + rssBytes: Number(process.memoryUsage?.().rss || 0) + }); + }, HEARTBEAT_MS); + heartbeat.unref?.(); try { const payload = await runSearchCli(args, { emitOutput: false, @@ -33,8 +50,10 @@ const runMessage = async (message) => { indexCache, sqliteCache }); + sendMessage({ type: 'run-complete', id, elapsedMs: Date.now() - startedAt }); sendMessage({ id, ok: true, payload }); } catch (err) { + sendMessage({ type: 'run-complete', id, elapsedMs: Date.now() - startedAt }); sendMessage({ id, ok: false, @@ -43,14 +62,20 @@ const runMessage = async (message) => { message: err?.message || String(err) } }); + } finally { + clearInterval(heartbeat); } }; process.on('message', (message) => { if (message?.type === 'shutdown') { - process.exit(0); + shutdownRequested = true; + chain = chain.finally(() => { + process.exit(0); + }); return; } + if (shutdownRequested) return; if (message?.type !== 'run') return; chain = chain .then(() => runMessage(message)) diff --git a/tests/perf/bench/repo-timeout-propagation.test.js b/tests/perf/bench/repo-timeout-propagation.test.js new file mode 100644 index 000000000..1ad706768 --- /dev/null +++ b/tests/perf/bench/repo-timeout-propagation.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +import { createBenchRunLoopFixture, createMemoryBenchSummary } from './run-loop-fixture.js'; +import { + resolveBenchProcessTimeoutProfile, + resolveBenchRuntimeAdaptationPlan +} from '../../../tools/bench/language/timeout.js'; + +const fixture = await createBenchRunLoopFixture({ + name: 'bench-timeout-propagation', + repoDirName: 'demo', + fallbackLogSlug: 'demo-repo' +}); + +let capturedTimeoutMs = null; +let capturedIdleTimeoutMs = null; +const results = await fixture.run({ + processRunner: { + runProcess: async (_label, _cmd, _args, options = {}) => { + capturedTimeoutMs = options.timeoutMs; + capturedIdleTimeoutMs = options.idleTimeoutMs; + await fs.writeFile(fixture.outFile, JSON.stringify(createMemoryBenchSummary()), 'utf8'); + return { ok: true, schedulerEvents: [] }; + } + } +}); + +const adaptationPlan = resolveBenchRuntimeAdaptationPlan({ + repoTimeoutMs: 4321, + language: null, + lineStats: null, + buildIndex: true, + buildSqlite: false, + backendCount: 1, + realEmbeddings: false, + requestedThreads: null +}); +const expectedTimeoutProfile = resolveBenchProcessTimeoutProfile({ + repoTimeoutMs: adaptationPlan.repoTimeoutMs +}); +assert.equal(capturedIdleTimeoutMs, expectedTimeoutProfile.idleTimeoutMs, 'expected idle bench timeout to be forwarded to the repo subprocess'); +assert.equal(capturedTimeoutMs, expectedTimeoutProfile.hardTimeoutMs, 'expected hard bench timeout profile to be forwarded to the repo subprocess'); +assert.equal(Array.isArray(results), true, 'expected result list'); +assert.equal(results.length, 1, 'expected one result row'); +assert.equal(results[0]?.failed, undefined, 'expected successful repo result'); + +await fixture.cleanup(); + +console.log('bench repo timeout propagation test passed'); diff --git a/tests/perf/bench/run-loop-fixture.js b/tests/perf/bench/run-loop-fixture.js new file mode 100644 index 000000000..7c5ea196a --- /dev/null +++ b/tests/perf/bench/run-loop-fixture.js @@ -0,0 +1,129 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runBenchExecutionLoop } from '../../../tools/bench/language-repos/run-loop.js'; + +export const createMemoryBenchSummary = ({ + queryWallMs = 10, + latencyMs = 1, + hitRate = 1, + resultCountAvg = 1, + buildMs = null +} = {}) => ({ + summary: { + queries: 1, + topN: 5, + annEnabled: false, + embeddingProvider: 'stub', + backends: ['memory'], + queryConcurrency: 4, + queryWallMs, + queryWallMsPerSearch: queryWallMs, + queryWallMsPerQuery: queryWallMs, + latencyMsAvg: { memory: latencyMs }, + latencyMs: { + memory: { mean: latencyMs, p50: latencyMs, p95: latencyMs, p99: latencyMs, min: latencyMs, max: latencyMs } + }, + hitRate: { memory: hitRate }, + resultCountAvg: { memory: resultCountAvg }, + missTaxonomy: { byBackend: { memory: {} }, lowHitByBackend: { memory: {} } }, + memoryRss: { memory: { mean: 1, p50: 1, p95: 1, p99: 1, min: 1, max: 1 } }, + buildMs + } +}); + +export const createBenchRunLoopFixture = async ({ + name, + repo = 'demo/repo', + language = null, + repoDirName = 'demo', + repoLabel = repo, + tierLabel = 'small', + fallbackLogSlug = repoDirName +}) => { + ensureTestingEnv(process.env); + + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, `${name}-${process.pid}-${Date.now()}`); + const repoPath = path.join(tempRoot, 'repos', repoDirName); + const outFile = path.join(tempRoot, 'results', `${repoDirName}.json`); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(repoPath, { recursive: true }); + await fs.mkdir(path.dirname(outFile), { recursive: true }); + await fs.writeFile(path.join(repoPath, 'README.md'), 'demo repo', 'utf8'); + + const task = { + repo, + queriesPath: path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt'), + ...(language ? { language } : {}) + }; + + return { + cleanup: () => fs.rm(tempRoot, { recursive: true, force: true }), + outFile, + repoPath, + root, + tempRoot, + run: (overrides = {}) => runBenchExecutionLoop({ + executionPlans: [{ + task, + repoPath, + repoLabel, + tierLabel, + repoCacheRoot: path.join(tempRoot, 'cache', repoDirName), + outFile, + fallbackLogSlug + }], + argv: { + build: false, + 'build-index': false, + 'build-sqlite': false, + progress: 'off', + quiet: true, + json: false, + incremental: false, + ann: false, + 'no-ann': true, + backend: 'memory', + top: 5, + limit: 1, + threads: null, + verbose: false, + 'stub-embeddings': true + }, + scriptRoot: root, + baseEnv: { ...process.env }, + appendLog: () => {}, + display: { error() {} }, + quietMode: true, + dryRun: false, + repoLogsEnabled: false, + initRepoLog: async () => null, + getRepoLogPath: () => null, + clearLogHistory: () => {}, + hasDiskFullMessageInHistory: () => false, + progressRuntime: { + beginRepo() {}, + update() {}, + completeRepo() {} + }, + lifecycle: { + hasRepoPath: () => true, + ensureRepoPresent: async () => ({ ok: true }), + prepareRepoWorkspace: async () => ({ ok: true }), + attachCrashRetention: async () => null, + cleanRepoCache: async () => {} + }, + wantsSqlite: false, + backendList: ['memory'], + lockMode: 'fail-fast', + lockWaitMs: 0, + lockStaleMs: 0, + benchTimeoutMs: 4321, + ...overrides + }) + }; +}; diff --git a/tests/perf/bench/run.test.js b/tests/perf/bench/run.test.js index 93f675355..615eb2c34 100644 --- a/tests/perf/bench/run.test.js +++ b/tests/perf/bench/run.test.js @@ -2,28 +2,66 @@ import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { fork, spawnSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { createCli } from '../../../src/shared/cli.js'; import { BENCH_OPTIONS, validateBenchArgs } from '../../../src/shared/cli-options.js'; import { createDisplay } from '../../../src/shared/cli/display.js'; -import { hasChunkMetaArtifactsSync } from '../../../src/shared/index-artifact-helpers.js'; +import { hasChunkMetaArtifactsSync } from '../../../src/shared/artifact-io/chunk-meta-presence.js'; import { buildSearchCliArgs } from '../../../tools/shared/search-cli-harness.js'; import { readQueryFileSafe, resolveTopNAndLimit, selectQueriesByLimit } from '../../../tools/shared/query-file-utils.js'; import { getIndexDir, getRuntimeConfig, loadUserConfig, resolveRuntimeEnv, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { getEnvConfig } from '../../../src/shared/env.js'; -import { runWithConcurrency } from '../../../src/shared/concurrency.js'; +import { getEnvConfig } from '../../../src/shared/env/runtime.js'; +import { runWithConcurrency } from '../../../src/shared/concurrency/run-with-queue.js'; import os from 'node:os'; import { createSafeRegex, normalizeSafeRegexConfig } from '../../../src/shared/safe-regex.js'; import { build as buildHistogram } from 'hdr-histogram-js'; -import { applyTestEnv, attachSilentLogging } from '../../helpers/test-env.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; import { formatBenchDuration as formatDuration, formatBenchDurationMs as formatDurationMs } from '../../helpers/duration-format.js'; import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; +import { createFastIndexingTestConfig } from '../../helpers/fast-indexing-config.js'; import { sanitizeBenchNodeOptions } from '../../../tools/bench/language/node-options.js'; import { resolveBenchQueryBackends } from '../../../tools/bench/language/query-backends.js'; +import { applyToolchainDaemonPolicyEnv } from '../../../src/shared/toolchain-env.js'; +import { createSearchWorkerPool, resolveAdaptiveQueryWorkerCount } from './query-runtime.js'; -applyTestEnv(); +const root = process.cwd(); +const originalRawArgs = process.argv.slice(2); +const testingDefaultsEnabled = process.env.PAIROFCLEATS_TESTING === '1' && originalRawArgs.length === 0; +const hasOption = (name) => process.argv.slice(2).some((arg) => arg === name || arg.startsWith(`${name}=`)); +if (testingDefaultsEnabled) { + if (!hasOption('--repo')) { + process.argv.push('--repo', path.join(root, 'tests', 'fixtures', 'sample')); + } + if (!hasOption('--queries')) { + process.argv.push('--queries', path.join(root, 'tests', 'fixtures', 'sample', 'queries.txt')); + } + if (!hasOption('--backend')) { + process.argv.push('--backend', 'memory'); + } + if (!hasOption('--limit')) { + process.argv.push('--limit', '1'); + } + if (!hasOption('--top')) { + process.argv.push('--top', '1'); + } + if (!hasOption('--stub-embeddings') && !hasOption('--real-embeddings')) { + process.argv.push('--stub-embeddings'); + } + if (!hasOption('--ann') && !hasOption('--no-ann')) { + process.argv.push('--no-ann'); + } + if (!hasOption('--quiet')) { + process.argv.push('--quiet'); + } +} +applyTestEnv({ + cacheRoot: path.join(root, '.testCache', 'bench-run'), + embeddings: 'stub', + testConfig: createFastIndexingTestConfig() +}); const rawArgs = process.argv.slice(2); +const testHarnessSearchMode = testingDefaultsEnabled ? 'code' : null; const argv = createCli({ scriptName: 'bench', options: BENCH_OPTIONS, @@ -68,7 +106,6 @@ if (safeRegex.test('a'.repeat(100))) { fatalExit('Safe regex maxInputLength guard failed.'); } -const root = process.cwd(); const repoArg = argv.repo ? path.resolve(argv.repo) : null; const reportPath = path.join(root, 'tools', 'index', 'report-artifacts.js'); const buildIndexPath = path.join(root, 'build_index.js'); @@ -175,7 +212,7 @@ const realEmbeddings = argv['real-embeddings'] === true; const stubEmbeddings = argv['stub-embeddings'] === true || (!realEmbeddings && envStubEmbeddings); -const baseEnvCandidate = { ...process.env, NODE_OPTIONS: baseNodeOptions }; +const baseEnvCandidate = applyToolchainDaemonPolicyEnv({ ...process.env, NODE_OPTIONS: baseNodeOptions }); const baseEnv = resolveRuntimeEnv(runtimeConfigForRun, baseEnvCandidate); if (realEmbeddings && baseEnv.PAIROFCLEATS_EMBEDDINGS) { delete baseEnv.PAIROFCLEATS_EMBEDDINGS; @@ -223,91 +260,33 @@ function buildSearchArgs(query, backend) { backend, topN, annArg, + mode: testHarnessSearchMode, repo: repoArg, extraArgs }); } -function createSearchWorker(label, env) { - const child = fork(queryWorkerPath, [], { - env, - stdio: ['ignore', 'pipe', 'pipe', 'ipc'] - }); - attachSilentLogging(child, label); - let nextMessageId = 1; - const pending = new Map(); - child.on('message', (message) => { - const id = Number(message?.id); - if (!Number.isFinite(id) || !pending.has(id)) return; - const entry = pending.get(id); - pending.delete(id); - if (message?.ok) { - entry.resolve(message.payload || {}); - return; - } - const err = new Error(message?.error?.message || `Query worker ${label} failed`); - err.code = message?.error?.code || 'ERR_QUERY_WORKER'; - entry.reject(err); - }); - const rejectAll = (reason) => { - for (const [, entry] of pending) { - entry.reject(reason); - } - pending.clear(); - }; - child.on('error', (err) => { - rejectAll(err instanceof Error ? err : new Error(String(err))); - }); - child.on('exit', (code, signal) => { - if (!pending.size) return; - rejectAll(new Error(`Query worker ${label} exited early (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)); - }); - const run = (args) => new Promise((resolve, reject) => { - const id = nextMessageId; - nextMessageId += 1; - pending.set(id, { resolve, reject }); - child.send({ type: 'run', id, args }); - }); - const close = async () => { - if (!child || child.killed) return; - await new Promise((resolve) => { - child.once('exit', () => resolve()); - try { - child.send({ type: 'shutdown' }); - } catch { - resolve(); - } - setTimeout(() => { - try { - child.kill('SIGTERM'); - } catch {} - resolve(); - }, 2000).unref?.(); - }); - }; - return { run, close }; -} - -function createSearchWorkerPool({ size, env }) { - const workerCount = Math.max(1, Math.floor(size) || 1); - const workers = Array.from({ length: workerCount }, (_, index) => ( - createSearchWorker(`bench-worker:${index + 1}`, env) - )); - let nextWorker = 0; - const run = (args) => { - const worker = workers[nextWorker]; - nextWorker = (nextWorker + 1) % workers.length; - return worker.run(args); - }; - const close = async () => { - await Promise.all(workers.map((worker) => worker.close())); - }; - return { run, close }; -} - function runSearch(pool, query, backend) { const args = buildSearchArgs(query, backend); - return pool.run(args); + return pool.run(args, { query, backend }); +} + +function applyBackendWorkerPlan(plan, backend) { + const normalizedBackend = String(backend || '').trim().toLowerCase(); + const baseEffective = Math.max(1, Math.floor(Number(plan?.effectiveConcurrency) || 1)); + if (normalizedBackend === 'sqlite') { + return { + ...plan, + effectiveConcurrency: 1, + reason: baseEffective === 1 + ? (plan?.reason || 'sqlite_backend_serialized') + : 'sqlite_backend_serialized' + }; + } + return { + ...plan, + effectiveConcurrency: baseEffective + }; } function buildQueryWorkerEnv() { @@ -372,7 +351,7 @@ function getRecommendedHeapMb() { }; } -function runBuild(args, label, env) { +function runBenchChildProcess(args, env, failureLabel) { const start = Date.now(); const result = spawnSync(process.execPath, args, { env, @@ -384,25 +363,18 @@ function runBuild(args, label, env) { if (result.stderr) process.stderr.write(result.stderr); } if (result.status !== 0) { - fatalExit(`Build failed: ${label}`, result.status ?? 1); + fatalExit(failureLabel, result.status ?? 1); } - return Date.now() - start; + return { durationMs: Date.now() - start }; +} + +function runBuild(args, label, env) { + return runBenchChildProcess(args, env, `Build failed: ${label}`).durationMs; } function runServiceQueue(queueName, env) { const args = [indexerServicePath, 'work', '--queue', queueName, '--concurrency', '1']; - const result = spawnSync(process.execPath, args, { - env, - encoding: 'utf8', - stdio: jsonOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit' - }); - if (jsonOutput) { - if (result.stdout) process.stderr.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - } - if (result.status !== 0) { - fatalExit(`Service queue failed: ${queueName}`, result.status ?? 1); - } + runBenchChildProcess(args, env, `Service queue failed: ${queueName}`); } const buildMs = {}; @@ -439,6 +411,7 @@ if (buildIndex || buildSqlite) { // of build_index to avoid duplicate sqlite passes and distorted timings. args.push('--no-sqlite'); if (repoArg) args.push('--repo', repoArg); + if (testHarnessSearchMode) args.push('--mode', testHarnessSearchMode); if (stubEmbeddings) args.push('--stub-embeddings'); if (buildIncremental) args.push('--incremental'); if (argv.threads) args.push('--threads', String(argv.threads)); @@ -587,13 +560,75 @@ const runQueries = async (requestedConcurrency) => { `(${totalSearches} searches) | concurrency ${requestedConcurrency}` ); logQueryProgress(true); - const workerPool = createSearchWorkerPool({ - size: Math.max(1, Math.min(requestedConcurrency, queryTasks.length || 1)), - env: buildQueryWorkerEnv() - }); + const queryWorkerEnv = buildQueryWorkerEnv(); + const workerPlans = Object.fromEntries(await Promise.all(backends.map(async (backend) => { + const basePlan = await resolveAdaptiveQueryWorkerCount({ + requestedConcurrency, + backends: [backend], + runtimeRoot, + userConfig + }); + return [backend, applyBackendWorkerPlan(basePlan, backend)]; + }))); + const requestedWorkerCount = Math.max(1, Math.min(requestedConcurrency, queryTasks.length || 1)); + for (const backend of backends) { + const plan = workerPlans[backend]; + const backendQueryCount = queryTasks.filter((task) => task.backend === backend).length; + const effectiveConcurrency = Math.max( + 1, + Math.min(Number(plan?.effectiveConcurrency) || 1, backendQueryCount || 1) + ); + plan.effectiveConcurrency = effectiveConcurrency; + logBench( + `[bench] Query worker pool backend=${backend} requested=${requestedWorkerCount} effective=${effectiveConcurrency} ` + + `reason=${plan.reason} artifacts=${formatGb(plan.totalArtifactBytes / (1024 * 1024))} ` + + `sqlite=${formatGb(plan.totalSqliteBytes / (1024 * 1024))} ` + + `budget=${formatGb(plan.budgetBytes / (1024 * 1024))} ` + + `per-worker=${formatGb(plan.estimatedPerWorkerBytes / (1024 * 1024))}` + ); + } + const workerPools = new Map(backends.map((backend) => [ + backend, + createSearchWorkerPool({ + size: workerPlans[backend].effectiveConcurrency, + env: queryWorkerEnv, + workerScriptPath: queryWorkerPath, + maxRunsPerProcess: backend === 'sqlite' ? 1 : 0, + onEvent: (event) => { + if (event?.type === 'stall-warning') { + const queryPreview = typeof event?.meta?.query === 'string' + ? formatQueryPreview(event.meta.query) + : 'unknown query'; + const rssText = Number.isFinite(Number(event?.rssBytes)) + ? `${(Number(event.rssBytes) / (1024 * 1024)).toFixed(1)} MB` + : 'n/a'; + logBench( + `[bench] query worker stall warning ${event.workerLabel} backend=${event?.meta?.backend || backend || 'unknown'} ` + + `elapsed=${formatDurationMs(Number(event?.elapsedMs) || 0)} ` + + `sinceHeartbeat=${formatDurationMs(Number(event?.sinceHeartbeatMs) || 0)} ` + + `rss=${rssText} | ${queryPreview}` + ); + return; + } + if (event?.type === 'stalled') { + const queryPreview = typeof event?.meta?.query === 'string' + ? formatQueryPreview(event.meta.query) + : 'unknown query'; + logBench( + `[bench] query worker stalled ${event.workerLabel} backend=${event?.meta?.backend || backend || 'unknown'} ` + + `elapsed=${formatDurationMs(Number(event?.elapsedMs) || 0)} | ${queryPreview}` + ); + } + } + }) + ])); const loggedQueries = new Set(); const runQueryTask = async (task) => { + const workerPool = workerPools.get(task.backend); + if (!workerPool) { + fatalExit(`[bench] Missing query worker pool for backend=${task.backend}`); + } if (!loggedQueries.has(task.queryIndex)) { loggedQueries.add(task.queryIndex); logBench( @@ -626,14 +661,18 @@ const runQueries = async (requestedConcurrency) => { }; try { if (queryTasks.length) { - await runWithConcurrency( - queryTasks, - Math.max(1, Math.min(requestedConcurrency, queryTasks.length)), - runQueryTask - ); + await Promise.all(backends.map(async (backend) => { + const backendTasks = queryTasks.filter((task) => task.backend === backend); + if (!backendTasks.length) return; + await runWithConcurrency( + backendTasks, + Math.max(1, Number(workerPlans[backend]?.effectiveConcurrency) || 1), + runQueryTask + ); + })); } } finally { - await workerPool.close(); + await Promise.all(Array.from(workerPools.values()).map((workerPool) => workerPool.close())); } logQueryProgress(true); const queryWallMs = Date.now() - queryProgress.startMs; @@ -654,7 +693,18 @@ const runQueries = async (requestedConcurrency) => { annEnabled, embeddingProvider, backends, - queryConcurrency: requestedConcurrency, + queryConcurrency: Object.values(workerPlans).reduce( + (max, plan) => Math.max(max, Math.max(1, Number(plan?.effectiveConcurrency) || 1)), + 1 + ), + queryConcurrencyRequested: requestedConcurrency, + queryConcurrencyAutoReason: backends.length === 1 + ? (workerPlans[backends[0]]?.reason || null) + : 'backend_specific', + queryConcurrencyAutoReasonByBackend: Object.fromEntries(backends.map((backend) => [ + backend, + workerPlans[backend]?.reason || null + ])), queryWallMs, queryWallMsPerSearch, queryWallMsPerQuery, @@ -689,7 +739,8 @@ if (repoArg) reportArgs.push('--repo', repoArg); const reportResult = spawnSync(process.execPath, reportArgs, { encoding: 'utf8' }); const artifactReport = reportResult.status === 0 ? JSON.parse(reportResult.stdout || '{}') : {}; const corruption = artifactReport?.corruption || null; -if (corruption && corruption.ok === false) { +const shouldCheckArtifactCorruption = !testingDefaultsEnabled || needsSqlite; +if (shouldCheckArtifactCorruption && corruption && corruption.ok === false) { const issues = Array.isArray(corruption.issues) && corruption.issues.length ? corruption.issues.join('; ') : 'unknown issues'; @@ -699,9 +750,9 @@ if (corruption && corruption.ok === false) { const summaries = runs.map((run) => run.summary).filter(Boolean); const concurrencyStats = {}; for (const runSummary of summaries) { - const concurrency = runSummary?.queryConcurrency; - if (concurrency === 4) { - concurrencyStats[String(concurrency)] = { + const requested = Number(runSummary?.queryConcurrencyRequested ?? runSummary?.queryConcurrency); + if (requested === 4) { + concurrencyStats[String(requested)] = { latencyMsAvg: runSummary.latencyMsAvg, latencyMs: runSummary.latencyMs, hitRate: runSummary.hitRate, @@ -731,8 +782,12 @@ if (argv.json) { } else { for (const runSummary of summaries) { if (!runSummary) continue; - const concurrencyLabel = Number.isFinite(runSummary.queryConcurrency) - ? ` (concurrency ${runSummary.queryConcurrency})` + const requestedConcurrencyLabel = Number(runSummary.queryConcurrencyRequested); + const effectiveConcurrencyLabel = Number(runSummary.queryConcurrency); + const concurrencyLabel = Number.isFinite(effectiveConcurrencyLabel) + ? (Number.isFinite(requestedConcurrencyLabel) && requestedConcurrencyLabel !== effectiveConcurrencyLabel + ? ` (concurrency ${effectiveConcurrencyLabel}, requested ${requestedConcurrencyLabel})` + : ` (concurrency ${effectiveConcurrencyLabel})`) : ''; logBench(`Benchmark summary${concurrencyLabel}`); logBench(`- Queries: ${runSummary.queries}`); diff --git a/tests/perf/bench/scenarios/matrix.test.js b/tests/perf/bench/scenarios/matrix.test.js index 9a68d4541..7cb1390e5 100644 --- a/tests/perf/bench/scenarios/matrix.test.js +++ b/tests/perf/bench/scenarios/matrix.test.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { applyTestEnv } from '../../../helpers/test-env.js'; if (!process.env.PAIROFCLEATS_BENCH_RUN) { console.log('[skip] set PAIROFCLEATS_BENCH_RUN=1 to run bench scenarios'); @@ -9,6 +10,7 @@ if (!process.env.PAIROFCLEATS_BENCH_RUN) { } const runPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'run.test.js'); +const scenarioEnv = applyTestEnv({ syncProcess: false }); const scenarios = [ { label: 'ann-on', @@ -35,7 +37,7 @@ const scenarios = [ for (const scenario of scenarios) { const result = spawnSync(process.execPath, [runPath, ...scenario.args], { stdio: 'inherit', - env: process.env + env: scenarioEnv }); if (result.status !== 0) { process.exit(result.status ?? 1); diff --git a/tests/perf/bench/stage1-windowed-throughput-bench.test.js b/tests/perf/bench/stage1-windowed-throughput.test.js similarity index 100% rename from tests/perf/bench/stage1-windowed-throughput-bench.test.js rename to tests/perf/bench/stage1-windowed-throughput.test.js diff --git a/tests/perf/chunking/chunking-limits-large-input.test.js b/tests/perf/chunking/limits-large-input.test.js similarity index 100% rename from tests/perf/chunking/chunking-limits-large-input.test.js rename to tests/perf/chunking/limits-large-input.test.js diff --git a/tests/perf/context-pack-risk-benchmark.test.js b/tests/perf/context-pack-risk-benchmark.test.js new file mode 100644 index 000000000..bdaa4decf --- /dev/null +++ b/tests/perf/context-pack-risk-benchmark.test.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { evaluateRiskPackDataset } from '../../tools/eval/risk-pack.js'; +import { applyTestEnv } from '../helpers/test-env.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { createRiskPackEvalFixtureSet } from '../helpers/risk-pack-eval.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'perf-risk-pack-benchmark'); +const { datasetPath, gatesPath } = await createRiskPackEvalFixtureSet(tempRoot); +const payload = await evaluateRiskPackDataset({ + datasetPath, + gatesPath +}); + +assert.equal(payload.summary?.cases, 3, 'expected benchmark eval to cover all risk-pack cases'); +assert.equal(payload.summary?.cappedCases, 1, 'expected one capped benchmark case'); +assert.ok(Number.isFinite(payload.summary?.avgElapsedMs), 'expected average elapsed time metric'); +assert.ok(Number.isFinite(payload.summary?.maxPeakRssMb), 'expected peak RSS metric'); +assert.equal(payload.summary?.capBehaviorRate, 1, 'expected capped-output benchmark behavior to match golden expectations'); + +console.log('context pack risk benchmark test passed'); diff --git a/tests/perf/helpers/graph-bench-fixture.js b/tests/perf/helpers/graph-bench-fixture.js index 8cc4873cc..219314d89 100644 --- a/tests/perf/helpers/graph-bench-fixture.js +++ b/tests/perf/helpers/graph-bench-fixture.js @@ -1,10 +1,11 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); @@ -157,7 +158,8 @@ export const runGraphBenchCompare = ({ iterations = 6, depth = 2 }) => { - const result = spawnSync(process.execPath, [ + const env = applyTestEnv({ syncProcess: false }); + const result = runNode([ benchScript, '--mode', 'compare', @@ -169,7 +171,7 @@ export const runGraphBenchCompare = ({ String(iterations), '--depth', String(depth) - ], { cwd: root, env: process.env, encoding: 'utf8' }); + ], 'graph bench compare', root, env, { stdio: 'pipe', encoding: 'utf8', allowFailure: true }); if (result.status !== 0) { const stdout = result.stdout || ''; diff --git a/tests/perf/indexing/artifacts/file-meta-streaming-memory.test.js b/tests/perf/indexing/artifacts/file-meta-streaming-memory.test.js index b912de1e9..aacffd9b8 100644 --- a/tests/perf/indexing/artifacts/file-meta-streaming-memory.test.js +++ b/tests/perf/indexing/artifacts/file-meta-streaming-memory.test.js @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { loadFileMetaRows } from '../../../../src/shared/artifact-io.js'; import { buildFileMetaColumnar } from '../../../../src/index/build/artifacts/file-meta.js'; -import { writeJsonLinesFile } from '../../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../../src/shared/json-stream/jsonl-write.js'; import { writePiecesManifest } from '../../../helpers/artifact-io-fixture.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; diff --git a/tests/perf/indexing/artifacts/file-meta-streaming-roundtrip.test.js b/tests/perf/indexing/artifacts/file-meta-streaming-roundtrip.test.js index 36e03c0e4..8ce33e16c 100644 --- a/tests/perf/indexing/artifacts/file-meta-streaming-roundtrip.test.js +++ b/tests/perf/indexing/artifacts/file-meta-streaming-roundtrip.test.js @@ -4,7 +4,7 @@ import { loadFileMetaRows, loadJsonArrayArtifact } from '../../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../../src/shared/json-stream/jsonl-write.js'; import { writePiecesManifest } from '../../../helpers/artifact-io-fixture.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; diff --git a/tests/perf/indexing/embeddings/scheduler-backpressure.test.js b/tests/perf/indexing/embeddings/scheduler-backpressure.test.js index 6a708ba43..e669abfe7 100644 --- a/tests/perf/indexing/embeddings/scheduler-backpressure.test.js +++ b/tests/perf/indexing/embeddings/scheduler-backpressure.test.js @@ -2,7 +2,6 @@ import fsSync from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { parseBuildEmbeddingsArgs } from '../../../../tools/build/embeddings/cli.js'; import { runBuildEmbeddingsWithConfig } from '../../../../tools/build/embeddings/runner.js'; import { SCHEDULER_QUEUE_NAMES } from '../../../../src/index/build/runtime/scheduler.js'; @@ -12,8 +11,10 @@ import { loadUserConfig } from '../../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { createFastIndexingTestConfig } from '../../../helpers/fast-indexing-config.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'embeddings-scheduler-backpressure'); @@ -26,7 +27,7 @@ await fsPromises.writeFile(path.join(repoRoot, 'index.js'), 'export const answer const testEnv = applyTestEnv({ cacheRoot: tempRoot, embeddings: 'stub', - testConfig: { + testConfig: createFastIndexingTestConfig({ indexing: { scheduler: { enabled: true, @@ -52,7 +53,7 @@ const testEnv = applyTestEnv({ riskAnalysis: false, riskAnalysisCrossFile: false } - }, + }), extraEnv: { PAIROFCLEATS_SCHEDULER: '1', PAIROFCLEATS_SCHEDULER_CPU: '1', @@ -61,10 +62,12 @@ const testEnv = applyTestEnv({ } }); -const buildResult = spawnSync( - process.execPath, +const buildResult = runNode( [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env: testEnv, stdio: 'inherit' } + 'embeddings scheduler backpressure build', + repoRoot, + testEnv, + { stdio: 'inherit', allowFailure: true } ); if (buildResult.status !== 0) { console.error('embeddings scheduler backpressure test failed: build_index failed'); diff --git a/tests/perf/indexing/postings/postings-heap-plateau.test.js b/tests/perf/indexing/postings/heap-plateau.test.js similarity index 100% rename from tests/perf/indexing/postings/postings-heap-plateau.test.js rename to tests/perf/indexing/postings/heap-plateau.test.js diff --git a/tests/perf/indexing/postings/stage1-memory-budget.test.js b/tests/perf/indexing/postings/stage1-memory-budget.test.js index 11723fe5a..f69c9e22c 100644 --- a/tests/perf/indexing/postings/stage1-memory-budget.test.js +++ b/tests/perf/indexing/postings/stage1-memory-budget.test.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; import { getRepoId } from '../../../../tools/shared/dict-utils.js'; import { resolveVersionedCacheRoot } from '../../../../src/shared/cache-roots.js'; @@ -66,8 +66,7 @@ const testEnv = applyTestEnv({ }); const buildIndexPath = path.join(root, 'build_index.js'); -const result = spawnSync( - process.execPath, +const result = runNode( [ buildIndexPath, '--mode', @@ -85,7 +84,10 @@ const result = spawnSync( '--progress', 'off' ], - { cwd: repoRoot, env: testEnv, encoding: 'utf8' } + 'stage1 memory budget build', + repoRoot, + testEnv, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (result.status !== 0) { diff --git a/tests/perf/indexing/relations/relations-streaming-build.test.js b/tests/perf/indexing/relations/streaming-build.test.js similarity index 100% rename from tests/perf/indexing/relations/relations-streaming-build.test.js rename to tests/perf/indexing/relations/streaming-build.test.js diff --git a/tests/perf/indexing/runtime/scheduler-deterministic.test.js b/tests/perf/indexing/runtime/scheduler-deterministic.test.js index b5db6bb1f..50130ff53 100644 --- a/tests/perf/indexing/runtime/scheduler-deterministic.test.js +++ b/tests/perf/indexing/runtime/scheduler-deterministic.test.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { createBuildScheduler } from '../../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../../src/shared/concurrency/scheduler-core.js'; const scheduler = createBuildScheduler({ cpuTokens: 0, diff --git a/tests/perf/indexing/runtime/scheduler-no-output-regression.test.js b/tests/perf/indexing/runtime/scheduler-no-output-regression.test.js index 6f6d1cb5f..b1b78fbe1 100644 --- a/tests/perf/indexing/runtime/scheduler-no-output-regression.test.js +++ b/tests/perf/indexing/runtime/scheduler-no-output-regression.test.js @@ -1,12 +1,13 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { loadChunkMeta, MAX_JSON_BYTES } from '../../../../src/shared/artifact-io.js'; import { getIndexDir, loadUserConfig } from '../../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { createFastIndexingTestConfig } from '../../../helpers/fast-indexing-config.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'scheduler-output-regression'); @@ -17,7 +18,7 @@ await fsPromises.mkdir(repoRoot, { recursive: true }); await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'export const alpha = 1;\n'); await fsPromises.writeFile(path.join(repoRoot, 'beta.js'), 'export const beta = 2;\n'); -const baseConfig = { +const baseConfig = createFastIndexingTestConfig({ indexing: { scheduler: { enabled: true, @@ -38,7 +39,7 @@ const baseConfig = { riskAnalysis: false, riskAnalysisCrossFile: false } -}; +}); const runBuild = async (label, schedulerEnabled) => { const cacheRoot = path.join(tempRoot, label); @@ -60,10 +61,12 @@ const runBuild = async (label, schedulerEnabled) => { } }); - const result = spawnSync( - process.execPath, + const result = runNode( [path.join(root, 'build_index.js'), '--repo', repoRoot, '--stub-embeddings', '--scm-provider', 'none'], - { cwd: repoRoot, env: testEnv, stdio: 'inherit' } + `scheduler output regression ${label}`, + repoRoot, + testEnv, + { stdio: 'inherit', allowFailure: true } ); if (result.status !== 0) { console.error(`scheduler output regression test failed: build_index ${label} failed`); diff --git a/tests/perf/indexing/runtime/scheduler-stage-wiring.test.js b/tests/perf/indexing/runtime/scheduler-stage-wiring.test.js index 92ed5311b..67e995850 100644 --- a/tests/perf/indexing/runtime/scheduler-stage-wiring.test.js +++ b/tests/perf/indexing/runtime/scheduler-stage-wiring.test.js @@ -8,6 +8,7 @@ import { SCHEDULER_QUEUE_NAMES } from '../../../../src/index/build/runtime/sched import { applyTestEnv } from '../../../helpers/test-env.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { createFastIndexingTestConfig } from '../../../helpers/fast-indexing-config.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'scheduler-stage-wiring'); @@ -20,7 +21,7 @@ await fsPromises.writeFile(path.join(repoRoot, 'index.js'), 'export const answer applyTestEnv({ cacheRoot: tempRoot, embeddings: 'off', - testConfig: { + testConfig: createFastIndexingTestConfig({ indexing: { scheduler: { enabled: true, @@ -43,7 +44,7 @@ applyTestEnv({ riskAnalysis: false, riskAnalysisCrossFile: false } - } + }) }); const defaults = parseBuildArgs([]).argv; diff --git a/tests/perf/indexing/runtime/scheduler-telemetry.test.js b/tests/perf/indexing/runtime/scheduler-telemetry.test.js index 7dc4c72e5..deb229b58 100644 --- a/tests/perf/indexing/runtime/scheduler-telemetry.test.js +++ b/tests/perf/indexing/runtime/scheduler-telemetry.test.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { createBuildScheduler } from '../../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../../src/shared/concurrency/scheduler-core.js'; const scheduler = createBuildScheduler({ cpuTokens: 1, diff --git a/tests/perf/indexing/validate/stage-usage-checklist.test.js b/tests/perf/indexing/validate/stage-usage-checklist.test.js index 4613bd7c1..e2469a365 100644 --- a/tests/perf/indexing/validate/stage-usage-checklist.test.js +++ b/tests/perf/indexing/validate/stage-usage-checklist.test.js @@ -2,11 +2,12 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; import { getIndexDir, resolveRepoConfig } from '../../../../tools/shared/dict-utils.js'; import { MAX_JSON_BYTES, loadChunkMeta, loadPiecesManifest } from '../../../../src/shared/artifact-io.js'; +import { readJsonFile } from '../../../../src/shared/json-file.js'; import { buildCodeMap } from '../../../../src/map/build-map.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; @@ -41,10 +42,12 @@ const env = applyTestEnv({ }); const buildIndexPath = path.join(root, 'build_index.js'); -const buildResult = spawnSync( - process.execPath, +const buildResult = runNode( [buildIndexPath, '--stub-embeddings', '--sqlite', '--mode', 'code', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } + 'stage usage checklist build index', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } ); if (buildResult.status !== 0) { @@ -57,7 +60,7 @@ const indexDir = getIndexDir(repoRoot, 'code', userConfig, {}); assert.ok(indexDir, 'expected code indexDir'); const buildRoot = path.dirname(indexDir); const repoCacheRoot = path.dirname(path.dirname(buildRoot)); -const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8')); +const readJson = (filePath) => readJsonFile(filePath); const buildState = await readJson(path.join(buildRoot, 'build_state.json')); assert.equal(buildState.stage, 'stage4', 'expected Stage4 completion in build_state'); @@ -155,10 +158,12 @@ assert.ok(Array.isArray(mapModel?.nodes) && mapModel.nodes.length > 0, 'expected const searchPath = path.join(root, 'search.js'); const searchArgs = ['alpha', '--mode', 'code', '--json', '--no-ann', '--repo', repoRoot]; -const memoryResult = spawnSync( - process.execPath, +const memoryResult = runNode( [searchPath, ...searchArgs, '--backend', 'memory'], - { cwd: repoRoot, env, encoding: 'utf8' } + 'stage usage checklist memory search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (memoryResult.status !== 0) { console.error(memoryResult.stdout || ''); @@ -169,10 +174,12 @@ const memoryEnvelope = JSON.parse(String(memoryResult.stdout || '{}')); assert.equal(memoryEnvelope.backend, 'memory'); assert.ok(Array.isArray(memoryEnvelope.code) && memoryEnvelope.code.length > 0, 'expected memory code hits'); -const sqliteResult = spawnSync( - process.execPath, +const sqliteResult = runNode( [searchPath, ...searchArgs, '--backend', 'sqlite'], - { cwd: repoRoot, env, encoding: 'utf8' } + 'stage usage checklist sqlite search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (sqliteResult.status !== 0) { console.error(sqliteResult.stdout || ''); diff --git a/tests/perf/scheduler-core.test.js b/tests/perf/scheduler-core.test.js index b586b0b5c..64b9c1864 100644 --- a/tests/perf/scheduler-core.test.js +++ b/tests/perf/scheduler-core.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/perf/scheduler-fairness.test.js b/tests/perf/scheduler-fairness.test.js index 614c26bcf..c3546db59 100644 --- a/tests/perf/scheduler-fairness.test.js +++ b/tests/perf/scheduler-fairness.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../src/shared/concurrency/scheduler-core.js'; import { treeSitterSchedulerPlannerInternals } from '../../src/index/build/tree-sitter-scheduler/plan.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/perf/scheduler-starvation-detection.test.js b/tests/perf/scheduler-starvation-detection.test.js index c0ccff366..5a620e4c6 100644 --- a/tests/perf/scheduler-starvation-detection.test.js +++ b/tests/perf/scheduler-starvation-detection.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/perf/sqlite-p95-latency.test.js b/tests/perf/sqlite-p95-latency.test.js index 88638a5a1..45866c258 100644 --- a/tests/perf/sqlite-p95-latency.test.js +++ b/tests/perf/sqlite-p95-latency.test.js @@ -1,41 +1,59 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { applyTestEnv, resolveSilentStdio } from '../helpers/test-env.js'; +import { buildIndex } from '../../src/integrations/core/index.js'; +import { runSearchCli } from '../../src/retrieval/cli.js'; +import { applyTestEnv } from '../helpers/test-env.js'; import { rmDirRecursive } from '../helpers/temp.js'; import { runSqliteBuild } from '../helpers/sqlite-builder.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { createFastIndexingTestConfig } from '../helpers/fast-indexing-config.js'; const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); const tempRoot = resolveTestCachePath(root, 'sqlite-p95-latency'); const repoRoot = path.join(tempRoot, 'repo'); const cacheRoot = path.join(tempRoot, 'cache'); await rmDirRecursive(tempRoot, { retries: 8, delayMs: 150 }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'index.js'), + [ + 'export function greet(name) {', + ' return `hello ${name}`;', + '}', + 'export const answer = 42;' + ].join('\n') +); +await fsPromises.writeFile(path.join(repoRoot, 'queries.txt'), 'greet\nanswer\n'); const env = applyTestEnv({ cacheRoot, embeddings: 'stub', + testConfig: createFastIndexingTestConfig(), extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } }); -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], 'build index'); -await runSqliteBuild(repoRoot); +try { + await buildIndex(repoRoot, { + mode: 'code', + stage: 'stage2', + sqlite: false, + 'stub-embeddings': true, + progress: 'off', + log: () => {}, + warn: () => {}, + logError: (message) => console.error(message) + }); +} catch (err) { + console.error('Failed: build index'); + console.error(err?.stack || err?.message || String(err)); + process.exit(1); +} +await runSqliteBuild(repoRoot, { mode: 'code', env, emitOutput: false }); const queriesPath = path.join(repoRoot, 'queries.txt'); const rawQueries = await fsPromises.readFile(queriesPath, 'utf8'); @@ -43,7 +61,7 @@ const queries = rawQueries .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')) - .slice(0, 8); + .slice(0, 2); if (!queries.length) { console.error('No queries found for latency test.'); @@ -51,37 +69,45 @@ if (!queries.length) { } const durations = []; -const searchPath = path.join(root, 'search.js'); -const runSearch = (query) => { +const indexCache = new Map(); +const sqliteCache = new Map(); +const runSearch = async (query) => { const args = [ - searchPath, query, '--backend', 'sqlite', '--no-ann', '--json', + '--mode', + 'code', '--repo', repoRoot ]; const start = process.hrtime.bigint(); - const result = spawnSync( - process.execPath, - args, - { cwd: repoRoot, env, stdio: resolveSilentStdio('ignore') } - ); + let payload = null; + try { + payload = await runSearchCli(args, { + emitOutput: false, + exitOnError: false, + indexCache, + sqliteCache + }); + } catch (err) { + console.error(`Search failed for query "${query}".`); + console.error(err?.stack || err?.message || String(err)); + process.exit(1); + } const end = process.hrtime.bigint(); - if (result.status !== 0) { + if (!payload || payload.ok === false) { console.error(`Search failed for query "${query}".`); - process.exit(result.status ?? 1); + process.exit(1); } return Number(end - start) / 1e6; }; for (const query of queries) { - runSearch(query); - for (let i = 0; i < 2; i += 1) { - durations.push(runSearch(query)); - } + await runSearch(query); + durations.push(await runSearch(query)); } durations.sort((a, b) => a - b); diff --git a/tests/perf/tooling/bench/ab-sweep-contract.test.js b/tests/perf/tooling/bench/ab-sweep-contract.test.js new file mode 100644 index 000000000..4d299f85d --- /dev/null +++ b/tests/perf/tooling/bench/ab-sweep-contract.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const testEnv = applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-ab-sweep-contract'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const fixtureScript = path.join(tempRoot, 'fixture.js'); +await fs.writeFile( + fixtureScript, + [ + '#!/usr/bin/env node', + "console.log('[bench] baseline duration=10.0ms throughput=100.0/s amount=1000');", + "console.log('[bench] current duration=8.0ms throughput=125.0/s amount=1000');", + "console.log('[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000');", + `console.log(JSON.stringify(${JSON.stringify({ + timings: { + durationMs: 8, + stages: { + parse: { durationMs: 5 }, + infer: { durationMs: 4 }, + write: { durationMs: 3 } + }, + artifacts: [ + { path: 'chunk_meta.json', durationMs: 4, queueDelayMs: 2 } + ], + scheduler: { + trace: [ + { atMs: 1, utilization: { overall: 0.7 } }, + { atMs: 2, utilization: { overall: 0.8 } } + ] + } + } + })}));`, + '' + ].join('\n'), + 'utf8' +); + +const scriptPath = path.join(root, 'tools', 'bench', 'ab-sweep.js'); +const result = runNode( + [ + scriptPath, + '--scripts', + fixtureScript, + '--cpu-tokens', + '4,6', + '--worker-counts', + '1,2', + '--write-concurrency', + '2,3' + ], + 'bench ab sweep contract', + root, + testEnv, + { stdio: 'pipe', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); +} + +const report = JSON.parse(String(result.stdout || '{}')); +assert.equal(report.schemaVersion, 1); +assert.ok(report.matrix?.runCount >= 4, 'expected sweep matrix runs'); +assert.ok(Array.isArray(report.runs) && report.runs.length === report.matrix.runCount, 'expected run rows'); +assert.ok(report.recommendation?.bestRunId, 'expected recommendation output'); +assert.ok(report.recommendation?.bestConfig, 'expected recommended config'); +assert.equal(typeof report.recommendation.score, 'number'); + +console.log('bench ab sweep contract test passed'); diff --git a/tests/perf/tooling/bench/bench-ab-sweep-contract.test.js b/tests/perf/tooling/bench/bench-ab-sweep-contract.test.js deleted file mode 100644 index 44d878fd5..000000000 --- a/tests/perf/tooling/bench/bench-ab-sweep-contract.test.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-ab-sweep-contract'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const fixtureScript = path.join(tempRoot, 'fixture.js'); -await fs.writeFile( - fixtureScript, - [ - '#!/usr/bin/env node', - "console.log('[bench] baseline duration=10.0ms throughput=100.0/s amount=1000');", - "console.log('[bench] current duration=8.0ms throughput=125.0/s amount=1000');", - "console.log('[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000');", - `console.log(JSON.stringify(${JSON.stringify({ - timings: { - durationMs: 8, - stages: { - parse: { durationMs: 5 }, - infer: { durationMs: 4 }, - write: { durationMs: 3 } - }, - artifacts: [ - { path: 'chunk_meta.json', durationMs: 4, queueDelayMs: 2 } - ], - scheduler: { - trace: [ - { atMs: 1, utilization: { overall: 0.7 } }, - { atMs: 2, utilization: { overall: 0.8 } } - ] - } - } - })}));`, - '' - ].join('\n'), - 'utf8' -); - -const scriptPath = path.join(root, 'tools', 'bench', 'ab-sweep.js'); -const result = spawnSync( - process.execPath, - [ - scriptPath, - '--scripts', - fixtureScript, - '--cpu-tokens', - '4,6', - '--worker-counts', - '1,2', - '--write-concurrency', - '2,3' - ], - { cwd: root, env: testEnv, encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); -} - -const report = JSON.parse(String(result.stdout || '{}')); -assert.equal(report.schemaVersion, 1); -assert.ok(report.matrix?.runCount >= 4, 'expected sweep matrix runs'); -assert.ok(Array.isArray(report.runs) && report.runs.length === report.matrix.runCount, 'expected run rows'); -assert.ok(report.recommendation?.bestRunId, 'expected recommendation output'); -assert.ok(report.recommendation?.bestConfig, 'expected recommended config'); -assert.equal(typeof report.recommendation.score, 'number'); - -console.log('bench ab sweep contract test passed'); diff --git a/tests/perf/tooling/bench/bench-guardrails-contract.test.js b/tests/perf/tooling/bench/bench-guardrails-contract.test.js deleted file mode 100644 index c82731a63..000000000 --- a/tests/perf/tooling/bench/bench-guardrails-contract.test.js +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-guardrails-contract'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const reportPath = path.join(tempRoot, 'report.json'); -await fs.writeFile( - reportPath, - `${JSON.stringify({ - schemaVersion: 1, - generatedAt: new Date().toISOString(), - summary: { - ok: 1, - error: 0, - timeout: 0, - artifactStallDurationMs: { count: 2, p95: 20, p99: 25, max: 30 }, - stageOverlap: { count: 1, avgPct: 12, p50Pct: 12, p95Pct: 12, maxPct: 12, rows: [] }, - perCoreUtilization: { - sampleCount: 2, - avgPct: 82, - minPct: 80, - maxPct: 84, - p50Pct: 82, - p95Pct: 84, - timeline: [] - }, - criticalPath: { - scripts: [{ script: 'fixture', durationMs: 100 }], - artifacts: [], - reconstructedTail: [] - }, - triageHints: [] - }, - results: [] - }, null, 2)}\n`, - 'utf8' -); - -const scriptPath = path.join(root, 'tools', 'bench', 'check-guardrails.js'); -const passing = spawnSync( - process.execPath, - [ - scriptPath, - '--report', - reportPath, - '--max-stage-duration-ms', - '120', - '--max-artifact-stall-p95-ms', - '25', - '--min-utilization-pct', - '75', - '--min-stage-overlap-pct', - '10', - '--json' - ], - { cwd: root, env: testEnv, encoding: 'utf8' } -); -if (passing.status !== 0) { - console.error(passing.stdout || ''); - console.error(passing.stderr || ''); - process.exit(passing.status ?? 1); -} -const passPayload = JSON.parse(String(passing.stdout || '{}')); -assert.equal(passPayload.ok, true, 'expected guardrails pass'); - -const failing = spawnSync( - process.execPath, - [ - scriptPath, - '--report', - reportPath, - '--max-stage-duration-ms', - '80', - '--json' - ], - { cwd: root, env: testEnv, encoding: 'utf8' } -); -assert.equal(failing.status, 1, 'expected guardrails failure exit code'); -const failPayload = JSON.parse(String(failing.stdout || '{}')); -assert.equal(failPayload.ok, false, 'expected failure payload'); -assert.ok(Array.isArray(failPayload.failedChecks) && failPayload.failedChecks.length > 0, 'expected failing checks'); - -console.log('bench guardrails contract test passed'); diff --git a/tests/perf/tooling/bench/bench-output-schema.test.js b/tests/perf/tooling/bench/bench-output-schema.test.js deleted file mode 100644 index 526912f72..000000000 --- a/tests/perf/tooling/bench/bench-output-schema.test.js +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import Ajv from 'ajv'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-output-schema'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const benchRunner = path.join(root, 'tools', 'bench', 'bench-runner.js'); -const schemaPath = path.join(root, 'docs', 'schemas', 'bench-runner-report.schema.json'); -const schema = JSON.parse(await fs.readFile(schemaPath, 'utf8')); - -const ajv = new Ajv({ allErrors: true, strict: false }); -const validate = ajv.compile(schema); - -const runCase = async ({ name, lines, useJsonFile }) => { - const fixtureScript = path.join(tempRoot, `${name}.fixture.js`); - await fs.writeFile( - fixtureScript, - [ - '#!/usr/bin/env node', - ...lines.map((line) => `console.log(${JSON.stringify(line)});`), - '' - ].join('\n'), - 'utf8' - ); - - const outPath = path.join(tempRoot, `${name}.report.json`); - const args = [benchRunner, '--scripts', fixtureScript, '--timeout-ms', '2000']; - if (useJsonFile) { - args.push('--json', outPath, '--quiet'); - } - - const result = spawnSync( - process.execPath, - args, - { cwd: root, env: testEnv, encoding: 'utf8' } - ); - - if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); - } - - const report = useJsonFile - ? JSON.parse(await fs.readFile(outPath, 'utf8')) - : JSON.parse(String(result.stdout || '{}')); - const ok = validate(report); - assert.ok(ok, ajv.errorsText(validate.errors, { separator: '\n' })); -}; - -const matrix = [ - { - name: 'stdout-canonical', - useJsonFile: false, - lines: [ - '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', - '[bench] current duration=8.0ms throughput=125.0/s amount=1000', - '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' - ] - }, - { - name: 'file-noisy', - useJsonFile: true, - lines: [ - 'noise before', - '[bench] run-a baseline duration=10.0ms throughput=100.0/s amount=1000', - '[bench] run-a current duration=8.0ms throughput=125.0/s amount=1000', - '[bench] run-a delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000', - 'noise after' - ] - } -]; - -for (const testCase of matrix) { - await runCase(testCase); -} - -console.log('bench output schema test passed'); - diff --git a/tests/perf/tooling/bench/bench-runner-contract.test.js b/tests/perf/tooling/bench/bench-runner-contract.test.js deleted file mode 100644 index fb0aa4eeb..000000000 --- a/tests/perf/tooling/bench/bench-runner-contract.test.js +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-runner-contract'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const benchRunner = path.join(root, 'tools', 'bench', 'bench-runner.js'); -const runFixture = async (name, lines) => { - const fixtureScript = path.join(tempRoot, `${name}.fixture.js`); - await fs.writeFile( - fixtureScript, - [ - '#!/usr/bin/env node', - ...lines.map((line) => `console.log(${JSON.stringify(line)});`), - '' - ].join('\n'), - 'utf8' - ); - - const result = spawnSync( - process.execPath, - [benchRunner, '--scripts', fixtureScript, '--timeout-ms', '2000'], - { cwd: root, env: testEnv, encoding: 'utf8' } - ); - - if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); - } - - const report = JSON.parse(String(result.stdout || '{}')); - assert.equal(report.schemaVersion, 1); - assert.equal(typeof report?.runner?.configHash, 'string', 'expected configHash reproducibility metadata'); - assert.ok(report?.runner?.storagePath, 'expected storagePath reproducibility metadata'); - assert.ok(report?.runner?.storageTier, 'expected storageTier reproducibility metadata'); - assert.ok(report?.runner?.antivirusState, 'expected antivirusState reproducibility metadata'); - assert.ok(report?.runner?.cpuGovernor, 'expected cpuGovernor reproducibility metadata'); - assert.ok(report?.summary?.stageOverlap, 'expected stage overlap summary'); - assert.ok(report?.summary?.perCoreUtilization, 'expected per-core utilization summary'); - assert.ok(report?.summary?.criticalPath, 'expected critical path summary'); - assert.ok(Array.isArray(report?.summary?.triageHints), 'expected triage hints array'); - assert.ok(Array.isArray(report.results) && report.results.length === 1, 'expected single result'); - return report; -}; - -const baseExpect = { - baselineDuration: 10, - currentDuration: 8, - deltaDuration: -2, - baselineThroughput: 100, - currentThroughput: 125, - deltaThroughput: 25 -}; - -const cases = [ - { - name: 'canonical', - lines: [ - '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', - '[bench] current duration=8.0ms throughput=125.0/s amount=1000', - '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' - ] - }, - { - name: 'classified-prefix', - lines: [ - '[bench] run-a baseline duration=10.0ms throughput=100.0/s amount=1000', - '[bench] run-a current duration=8.0ms throughput=125.0/s amount=1000', - '[bench] run-a delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' - ] - }, - { - name: 'reordered', - lines: [ - '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000', - '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', - '[bench] current duration=8.0ms throughput=125.0/s amount=1000' - ] - } -]; - -for (const testCase of cases) { - const canonicalReport = await runFixture(`${testCase.name}-canonical`, testCase.lines); - const canonicalEntry = canonicalReport.results[0]; - assert.equal(canonicalEntry.ok, true); - assert.equal(canonicalEntry.parsed?.baseline?.metrics?.duration, baseExpect.baselineDuration); - assert.equal(canonicalEntry.parsed?.current?.metrics?.duration, baseExpect.currentDuration); - assert.equal(canonicalEntry.parsed?.delta?.metrics?.duration, baseExpect.deltaDuration); - assert.equal(canonicalEntry.parsed?.baseline?.metrics?.throughput, baseExpect.baselineThroughput); - assert.equal(canonicalEntry.parsed?.current?.metrics?.throughput, baseExpect.currentThroughput); - assert.equal(canonicalEntry.parsed?.delta?.metrics?.throughput, baseExpect.deltaThroughput); - - // Metamorphic relation: non-bench noise and whitespace should not change parsed bench metrics. - const noisyLines = [ - 'unrelated preface line', - ...testCase.lines.map((line) => ` ${line} `), - 'unrelated trailer line' - ]; - const noisyReport = await runFixture(`${testCase.name}-noisy`, noisyLines); - const noisyEntry = noisyReport.results[0]; - assert.deepEqual(noisyEntry.parsed, canonicalEntry.parsed); - assert.equal(typeof canonicalReport.summary.perCoreUtilization.avgPct, 'number'); - assert.equal(typeof canonicalReport.summary.stageOverlap.avgPct, 'number'); -} - -console.log('bench runner contract test passed'); - diff --git a/tests/perf/tooling/bench/bench-runner-fixture.js b/tests/perf/tooling/bench/bench-runner-fixture.js new file mode 100644 index 000000000..82a1afe0d --- /dev/null +++ b/tests/perf/tooling/bench/bench-runner-fixture.js @@ -0,0 +1,59 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +export const consoleLogFixtureSource = (lines) => [ + '#!/usr/bin/env node', + ...lines.map((line) => `console.log(${JSON.stringify(line)});`), + '' +]; + +export const createBenchRunnerFixture = async (cacheName) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, cacheName); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + + const benchRunner = path.join(root, 'tools', 'bench', 'bench-runner.js'); + const testEnv = applyTestEnv({ syncProcess: false }); + + const writeFixture = async (name, sourceLines) => { + const fixtureScript = path.join(tempRoot, `${name}.fixture.js`); + await fs.writeFile(fixtureScript, sourceLines.join('\n'), 'utf8'); + return fixtureScript; + }; + + const runFixtureScript = (fixtureScript, { timeoutMs = 2000 } = {}) => { + const result = runNode( + [benchRunner, '--scripts', fixtureScript, '--timeout-ms', String(timeoutMs)], + `bench runner fixture ${path.basename(fixtureScript)}`, + root, + testEnv, + { stdio: 'pipe', allowFailure: true } + ); + + if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); + } + + return JSON.parse(String(result.stdout || '{}')); + }; + + const runFixture = async (name, sourceLines, options) => { + const fixtureScript = await writeFixture(name, sourceLines); + return runFixtureScript(fixtureScript, options); + }; + + return { + root, + tempRoot, + writeFixture, + runFixtureScript, + runFixture + }; +}; diff --git a/tests/perf/tooling/bench/bench-runner-utilization-cap.test.js b/tests/perf/tooling/bench/bench-runner-utilization-cap.test.js deleted file mode 100644 index 8c1bc3dcd..000000000 --- a/tests/perf/tooling/bench/bench-runner-utilization-cap.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'bench-runner-utilization-cap'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const fixtureScript = path.join(tempRoot, 'trace.fixture.js'); -await fs.writeFile( - fixtureScript, - [ - '#!/usr/bin/env node', - 'const trace = Array.from({ length: 3000 }, (_, i) => ({ atMs: i, utilizationPct: (i % 100) }));', - 'console.log(JSON.stringify({', - ' timings: {', - ' scheduler: { trace }', - ' }', - '}));', - '' - ].join('\n'), - 'utf8' -); - -const benchRunner = path.join(root, 'tools', 'bench', 'bench-runner.js'); -const result = spawnSync( - process.execPath, - [benchRunner, '--scripts', fixtureScript, '--timeout-ms', '2000'], - { cwd: root, env: testEnv, encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); -} - -const report = JSON.parse(String(result.stdout || '{}')); -const sampleCount = Number(report?.summary?.perCoreUtilization?.sampleCount || 0); -assert.equal(sampleCount, 2048, `expected utilization sample cap of 2048, got ${sampleCount}`); -assert.ok( - Array.isArray(report?.summary?.perCoreUtilization?.timeline) - && report.summary.perCoreUtilization.timeline.length <= 512, - 'expected utilization timeline to remain bounded to 512 entries' -); - -console.log('bench runner utilization cap test passed'); diff --git a/tests/perf/tooling/bench/guardrails-contract.test.js b/tests/perf/tooling/bench/guardrails-contract.test.js new file mode 100644 index 000000000..c00323ae2 --- /dev/null +++ b/tests/perf/tooling/bench/guardrails-contract.test.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const testEnv = applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-guardrails-contract'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const reportPath = path.join(tempRoot, 'report.json'); +await fs.writeFile( + reportPath, + `${JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + summary: { + ok: 1, + error: 0, + timeout: 0, + artifactStallDurationMs: { count: 2, p95: 20, p99: 25, max: 30 }, + stageOverlap: { count: 1, avgPct: 12, p50Pct: 12, p95Pct: 12, maxPct: 12, rows: [] }, + perCoreUtilization: { + sampleCount: 2, + avgPct: 82, + minPct: 80, + maxPct: 84, + p50Pct: 82, + p95Pct: 84, + timeline: [] + }, + criticalPath: { + scripts: [{ script: 'fixture', durationMs: 100 }], + artifacts: [], + reconstructedTail: [] + }, + triageHints: [] + }, + results: [] + }, null, 2)}\n`, + 'utf8' +); + +const scriptPath = path.join(root, 'tools', 'bench', 'check-guardrails.js'); +const passing = runNode( + [ + scriptPath, + '--report', + reportPath, + '--max-stage-duration-ms', + '120', + '--max-artifact-stall-p95-ms', + '25', + '--min-utilization-pct', + '75', + '--min-stage-overlap-pct', + '10', + '--json' + ], + 'bench guardrails passing contract', + root, + testEnv, + { stdio: 'pipe', allowFailure: true } +); +if (passing.status !== 0) { + console.error(passing.stdout || ''); + console.error(passing.stderr || ''); + process.exit(passing.status ?? 1); +} +const passPayload = JSON.parse(String(passing.stdout || '{}')); +assert.equal(passPayload.ok, true, 'expected guardrails pass'); + +const failing = runNode( + [ + scriptPath, + '--report', + reportPath, + '--max-stage-duration-ms', + '80', + '--json' + ], + 'bench guardrails failing contract', + root, + testEnv, + { stdio: 'pipe', allowFailure: true } +); +assert.equal(failing.status, 1, 'expected guardrails failure exit code'); +const failPayload = JSON.parse(String(failing.stdout || '{}')); +assert.equal(failPayload.ok, false, 'expected failure payload'); +assert.ok(Array.isArray(failPayload.failedChecks) && failPayload.failedChecks.length > 0, 'expected failing checks'); + +console.log('bench guardrails contract test passed'); diff --git a/tests/perf/tooling/bench/output-schema.test.js b/tests/perf/tooling/bench/output-schema.test.js new file mode 100644 index 000000000..859c2845a --- /dev/null +++ b/tests/perf/tooling/bench/output-schema.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import Ajv from 'ajv'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const testEnv = applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bench-output-schema'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const benchRunner = path.join(root, 'tools', 'bench', 'bench-runner.js'); +const schemaPath = path.join(root, 'docs', 'schemas', 'bench-runner-report.schema.json'); +const schema = JSON.parse(await fs.readFile(schemaPath, 'utf8')); + +const ajv = new Ajv({ allErrors: true, strict: false }); +const validate = ajv.compile(schema); + +const runCase = async ({ name, lines, useJsonFile }) => { + const fixtureScript = path.join(tempRoot, `${name}.fixture.js`); + await fs.writeFile( + fixtureScript, + [ + '#!/usr/bin/env node', + ...lines.map((line) => `console.log(${JSON.stringify(line)});`), + '' + ].join('\n'), + 'utf8' + ); + + const outPath = path.join(tempRoot, `${name}.report.json`); + const args = [benchRunner, '--scripts', fixtureScript, '--timeout-ms', '2000']; + if (useJsonFile) { + args.push('--json', outPath, '--quiet'); + } + + const result = runNode( + args, + `bench output schema ${name}`, + root, + testEnv, + { stdio: 'pipe', allowFailure: true } + ); + + if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); + } + + const report = useJsonFile + ? JSON.parse(await fs.readFile(outPath, 'utf8')) + : JSON.parse(String(result.stdout || '{}')); + const ok = validate(report); + assert.ok(ok, ajv.errorsText(validate.errors, { separator: '\n' })); +}; + +const matrix = [ + { + name: 'stdout-canonical', + useJsonFile: false, + lines: [ + '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', + '[bench] current duration=8.0ms throughput=125.0/s amount=1000', + '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' + ] + }, + { + name: 'file-noisy', + useJsonFile: true, + lines: [ + 'noise before', + '[bench] run-a baseline duration=10.0ms throughput=100.0/s amount=1000', + '[bench] run-a current duration=8.0ms throughput=125.0/s amount=1000', + '[bench] run-a delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000', + 'noise after' + ] + } +]; + +for (const testCase of matrix) { + await runCase(testCase); +} + +console.log('bench output schema test passed'); + diff --git a/tests/perf/tooling/bench/per-bench-output-schema.test.js b/tests/perf/tooling/bench/per-bench-output-schema.test.js deleted file mode 100644 index 3c1b1714c..000000000 --- a/tests/perf/tooling/bench/per-bench-output-schema.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import Ajv from 'ajv'; - -import { applyTestEnv } from '../../../helpers/test-env.js'; - -const testEnv = applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const ajv = new Ajv({ allErrors: true, strict: false }); - -const parseTrailingJson = (text) => { - const raw = String(text || '').trim(); - if (!raw) return null; - if (raw.startsWith('{') || raw.startsWith('[')) { - return JSON.parse(raw); - } - const match = raw.match(/\{[\s\S]*\}\s*$/); - return match ? JSON.parse(match[0]) : null; -}; - -const matrix = [ - { - id: 'vfs-parallel-manifest', - script: path.join(root, 'tools', 'bench', 'vfs', 'parallel-manifest-build.js'), - args: ['--segments', '16', '--segmentBytes', '64', '--concurrency', '1,2', '--samples', '1', '--json'], - schemaPath: path.join(root, 'docs', 'schemas', 'bench-vfs-parallel-manifest.schema.json') - }, - { - id: 'tree-sitter-load', - script: path.join(root, 'tools', 'bench', 'index', 'tree-sitter-load.js'), - args: ['--languages', 'javascript', '--filesPerLanguage', '2', '--repeats', '1', '--json'], - schemaPath: path.join(root, 'docs', 'schemas', 'bench-tree-sitter-load.schema.json') - } -]; - -for (const entry of matrix) { - const result = spawnSync( - process.execPath, - [entry.script, ...entry.args], - { cwd: root, env: testEnv, encoding: 'utf8' } - ); - if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); - } - - const report = parseTrailingJson(result.stdout); - assert.ok(report && typeof report === 'object', `expected JSON output for ${entry.id}`); - - const schema = JSON.parse(await fs.readFile(entry.schemaPath, 'utf8')); - const validate = ajv.compile(schema); - const ok = validate(report); - assert.ok(ok, `${entry.id}: ${ajv.errorsText(validate.errors, { separator: '\n' })}`); -} - -console.log('per-bench output schema test passed'); diff --git a/tests/perf/tooling/bench/per-output-schema.test.js b/tests/perf/tooling/bench/per-output-schema.test.js new file mode 100644 index 000000000..99f50ae6e --- /dev/null +++ b/tests/perf/tooling/bench/per-output-schema.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import Ajv from 'ajv'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +const testEnv = applyTestEnv({ testing: '1' }); + +const root = process.cwd(); +const ajv = new Ajv({ allErrors: true, strict: false }); + +const parseTrailingJson = (text) => { + const raw = String(text || '').trim(); + if (!raw) return null; + if (raw.startsWith('{') || raw.startsWith('[')) { + return JSON.parse(raw); + } + const match = raw.match(/\{[\s\S]*\}\s*$/); + return match ? JSON.parse(match[0]) : null; +}; + +const matrix = [ + { + id: 'vfs-parallel-manifest', + script: path.join(root, 'tools', 'bench', 'vfs', 'parallel-manifest-build.js'), + args: ['--segments', '16', '--segmentBytes', '64', '--concurrency', '1,2', '--samples', '1', '--json'], + schemaPath: path.join(root, 'docs', 'schemas', 'bench-vfs-parallel-manifest.schema.json') + }, + { + id: 'tree-sitter-load', + script: path.join(root, 'tools', 'bench', 'index', 'tree-sitter-load.js'), + args: ['--languages', 'javascript', '--filesPerLanguage', '2', '--repeats', '1', '--json'], + schemaPath: path.join(root, 'docs', 'schemas', 'bench-tree-sitter-load.schema.json') + } +]; + +for (const entry of matrix) { + const result = runNode( + [entry.script, ...entry.args], + `per-bench output schema ${entry.id}`, + root, + testEnv, + { stdio: 'pipe', allowFailure: true } + ); + if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); + } + + const report = parseTrailingJson(result.stdout); + assert.ok(report && typeof report === 'object', `expected JSON output for ${entry.id}`); + + const schema = JSON.parse(await fs.readFile(entry.schemaPath, 'utf8')); + const validate = ajv.compile(schema); + const ok = validate(report); + assert.ok(ok, `${entry.id}: ${ajv.errorsText(validate.errors, { separator: '\n' })}`); +} + +console.log('per-bench output schema test passed'); diff --git a/tests/perf/tooling/bench/runner-contract.test.js b/tests/perf/tooling/bench/runner-contract.test.js new file mode 100644 index 000000000..dae01b820 --- /dev/null +++ b/tests/perf/tooling/bench/runner-contract.test.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + consoleLogFixtureSource, + createBenchRunnerFixture +} from './bench-runner-fixture.js'; + +const benchFixture = await createBenchRunnerFixture('bench-runner-contract'); +const runFixture = async (name, lines) => { + const report = await benchFixture.runFixture(name, consoleLogFixtureSource(lines)); + assert.equal(report.schemaVersion, 1); + assert.equal(typeof report?.runner?.configHash, 'string', 'expected configHash reproducibility metadata'); + assert.ok(report?.runner?.storagePath, 'expected storagePath reproducibility metadata'); + assert.ok(report?.runner?.storageTier, 'expected storageTier reproducibility metadata'); + assert.ok(report?.runner?.antivirusState, 'expected antivirusState reproducibility metadata'); + assert.ok(report?.runner?.cpuGovernor, 'expected cpuGovernor reproducibility metadata'); + assert.ok(report?.summary?.stageOverlap, 'expected stage overlap summary'); + assert.ok(report?.summary?.perCoreUtilization, 'expected per-core utilization summary'); + assert.ok(report?.summary?.criticalPath, 'expected critical path summary'); + assert.ok(Array.isArray(report?.summary?.triageHints), 'expected triage hints array'); + assert.ok(Array.isArray(report?.summary?.regressionSignals), 'expected structured regression signals'); + assert.ok(Array.isArray(report.results) && report.results.length === 1, 'expected single result'); + return report; +}; + +const baseExpect = { + baselineDuration: 10, + currentDuration: 8, + deltaDuration: -2, + baselineThroughput: 100, + currentThroughput: 125, + deltaThroughput: 25 +}; + +const cases = [ + { + name: 'canonical', + lines: [ + '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', + '[bench] current duration=8.0ms throughput=125.0/s amount=1000', + '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' + ] + }, + { + name: 'classified-prefix', + lines: [ + '[bench] run-a baseline duration=10.0ms throughput=100.0/s amount=1000', + '[bench] run-a current duration=8.0ms throughput=125.0/s amount=1000', + '[bench] run-a delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000' + ] + }, + { + name: 'reordered', + lines: [ + '[bench] delta duration=-2.0ms (-20.0%) throughput=25.0/s (25.0%) amount=1000', + '[bench] baseline duration=10.0ms throughput=100.0/s amount=1000', + '[bench] current duration=8.0ms throughput=125.0/s amount=1000' + ] + } +]; + +for (const testCase of cases) { + const canonicalReport = await runFixture(`${testCase.name}-canonical`, testCase.lines); + const canonicalEntry = canonicalReport.results[0]; + assert.ok(canonicalEntry.id?.endsWith('.fixture.js'), 'expected runner result id'); + assert.deepEqual(canonicalEntry.args, [], 'expected script runner args to be recorded'); + assert.equal(canonicalEntry.expect, null, 'expected no suite expectation for direct script runs'); + assert.equal(canonicalEntry.ok, true); + assert.equal(canonicalEntry.skipped, false); + assert.equal(canonicalEntry.skipReason, null); + assert.equal(canonicalEntry.parsedOk, true); + assert.deepEqual(canonicalEntry.errors, []); + assert.equal(canonicalEntry.parsed?.baseline?.metrics?.duration, baseExpect.baselineDuration); + assert.equal(canonicalEntry.parsed?.current?.metrics?.duration, baseExpect.currentDuration); + assert.equal(canonicalEntry.parsed?.delta?.metrics?.duration, baseExpect.deltaDuration); + assert.equal(canonicalEntry.parsed?.baseline?.metrics?.throughput, baseExpect.baselineThroughput); + assert.equal(canonicalEntry.parsed?.current?.metrics?.throughput, baseExpect.currentThroughput); + assert.equal(canonicalEntry.parsed?.delta?.metrics?.throughput, baseExpect.deltaThroughput); + + // Metamorphic relation: non-bench noise and whitespace should not change parsed bench metrics. + const noisyLines = [ + 'unrelated preface line', + ...testCase.lines.map((line) => ` ${line} `), + 'unrelated trailer line' + ]; + const noisyReport = await runFixture(`${testCase.name}-noisy`, noisyLines); + const noisyEntry = noisyReport.results[0]; + assert.deepEqual(noisyEntry.parsed, canonicalEntry.parsed); + assert.equal(typeof canonicalReport.summary.perCoreUtilization.avgPct, 'number'); + assert.equal(typeof canonicalReport.summary.stageOverlap.avgPct, 'number'); +} + +const improvedWithAbsoluteDuration = await runFixture( + 'improved-with-absolute-duration', + [ + '[bench] baseline rows=50000 ms=98.2 rowsPerSec=509272.8', + '[bench] current rows=50000 ms=73.5 rowsPerSec=680493.4', + '[bench] delta ms=-24.7 (-25.2%) rowsPerSec=171220.5 duration=73.5ms' + ] +); +assert.equal( + improvedWithAbsoluteDuration.summary.triageHints.some((hint) => hint.includes('Regression signal')), + false, + 'expected absolute duration on a delta line not to override negative ms delta' +); +assert.equal( + improvedWithAbsoluteDuration.results[0].parsed?.current?.metrics?.rowsPerSec, + 680493.4, + 'expected numeric metrics with plain decimal values' +); + +const inlineCurrentDeltaReport = await runFixture( + 'inline-current-delta', + [ + '[bench] baseline ms=1286.9 bytes=2554949', + '[bench] current ms=335.5 bytes=2849724 delta=-951.4ms (-73.9%)' + ] +); +assert.equal( + inlineCurrentDeltaReport.results[0].parsed?.delta, + null, + 'expected current lines with inline delta metrics not to be classified as delta lines' +); +assert.equal( + inlineCurrentDeltaReport.summary.triageHints.some((hint) => hint.includes('Regression signal')), + false, + 'expected missing delta line not to create a false regression signal from current duration' +); + +const hashMetricReport = await runFixture( + 'hash-metric', + [ + '[bench] baseline queueHash=6feaf895 total=10.0ms', + '[bench] current queueHash=b4c54270 total=8.0ms', + '[bench] delta ms=-2.0 (-20.0%)' + ] +); +assert.equal( + hashMetricReport.results[0].parsed?.baseline?.metrics?.queueHash, + '6feaf895', + 'expected alphanumeric metric values to stay strings' +); + +const regressionReport = await runFixture( + 'positive-ms-regression', + [ + '[bench] baseline algo=rolling-hash ms=14.0 vocab=10000', + '[bench] current algo=rolling-hash ms=17.9 vocab=10000 delta=3.9ms (28.1%)', + '[bench] delta ms=3.9 (28.1%)' + ] +); +assert.equal(regressionReport.summary.regressionSignals.length, 1); +assert.equal(regressionReport.summary.regressionSignals[0].deltaMs, 3.9); +assert.equal( + regressionReport.summary.triageHints.some((hint) => hint.includes('positive delta duration=3.9ms')), + true, + 'expected positive ms delta to produce a regression hint' +); + +console.log('bench runner contract test passed'); + diff --git a/tests/perf/tooling/bench/runner-utilization-cap.test.js b/tests/perf/tooling/bench/runner-utilization-cap.test.js new file mode 100644 index 000000000..e6ca9d8ed --- /dev/null +++ b/tests/perf/tooling/bench/runner-utilization-cap.test.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createBenchRunnerFixture } from './bench-runner-fixture.js'; + +const { runFixture } = await createBenchRunnerFixture('bench-runner-utilization-cap'); +const report = await runFixture( + 'trace', + [ + '#!/usr/bin/env node', + 'const trace = Array.from({ length: 3000 }, (_, i) => ({ atMs: i, utilizationPct: (i % 100) }));', + 'console.log(JSON.stringify({', + ' timings: {', + ' scheduler: { trace }', + ' }', + '}));', + '' + ] +); +const sampleCount = Number(report?.summary?.perCoreUtilization?.sampleCount || 0); +assert.equal(sampleCount, 2048, `expected utilization sample cap of 2048, got ${sampleCount}`); +assert.ok( + Array.isArray(report?.summary?.perCoreUtilization?.timeline) + && report.summary.perCoreUtilization.timeline.length <= 512, + 'expected utilization timeline to remain bounded to 512 entries' +); + +console.log('bench runner utilization cap test passed'); diff --git a/tests/perf/vfs-bench-contract.test.js b/tests/perf/vfs-bench-contract.test.js index e3d0c4fa4..1d27bb332 100644 --- a/tests/perf/vfs-bench-contract.test.js +++ b/tests/perf/vfs-bench-contract.test.js @@ -1,19 +1,21 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { applyTestEnv } from '../helpers/test-env.js'; +import { runNode } from '../helpers/run-node.js'; const testEnv = applyTestEnv({ testing: '1' }); const root = process.cwd(); const runJsonBench = (scriptPath, args) => { - const result = spawnSync( - process.execPath, + const result = runNode( [path.join(root, scriptPath), ...args, '--json'], - { cwd: root, env: testEnv, encoding: 'utf8' } + `vfs bench ${scriptPath}`, + root, + testEnv, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (result.status !== 0) { console.error(result.stdout || ''); diff --git a/tests/retrieval/ann/ann-backend-normalization-parity.test.js b/tests/retrieval/ann/ann-backend-normalization-parity.test.js deleted file mode 100644 index 00bea7bc6..000000000 --- a/tests/retrieval/ann/ann-backend-normalization-parity.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - ANN_BACKEND_CHOICES, - normalizeAnnBackend, - resolveAnnOrder -} from '../../../src/retrieval/ann/normalize-backend.js'; - -assert.deepEqual(ANN_BACKEND_CHOICES, ['auto', 'lancedb', 'sqlite-vector', 'hnsw', 'js']); - -assert.equal(normalizeAnnBackend('sqlite'), 'sqlite-vector'); -assert.equal(normalizeAnnBackend('sqlite-extension'), 'sqlite-vector'); -assert.equal(normalizeAnnBackend('vector-extension'), 'sqlite-vector'); -assert.equal(normalizeAnnBackend('dense'), 'js'); -assert.equal(normalizeAnnBackend('js'), 'js'); -assert.equal(normalizeAnnBackend('auto'), 'auto'); -assert.equal(normalizeAnnBackend('unknown'), 'lancedb'); -assert.equal(normalizeAnnBackend('unknown', { strict: true, defaultBackend: null }), null); - -assert.deepEqual(resolveAnnOrder('lancedb'), ['lancedb']); -assert.deepEqual(resolveAnnOrder('sqlite-vector'), ['sqlite-vector']); -assert.deepEqual(resolveAnnOrder('hnsw'), ['hnsw']); -assert.deepEqual(resolveAnnOrder('dense'), ['js']); -assert.deepEqual(resolveAnnOrder('auto'), ['lancedb', 'sqlite-vector', 'hnsw', 'js']); - -console.log('ann backend normalization parity test passed'); diff --git a/tests/retrieval/ann/ann-backend-selection.test.js b/tests/retrieval/ann/ann-backend-selection.test.js deleted file mode 100644 index c721cd442..000000000 --- a/tests/retrieval/ann/ann-backend-selection.test.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveLanceDbTarget } from '../../../src/shared/lancedb.js'; -import { resolveHnswTarget } from '../../../src/shared/hnsw.js'; - -assert.equal(resolveLanceDbTarget('code', 'auto'), 'code'); -assert.equal(resolveLanceDbTarget('prose', 'auto'), 'doc'); -assert.equal(resolveLanceDbTarget('extracted-prose', 'auto'), 'doc'); -assert.equal(resolveLanceDbTarget('records', 'auto'), 'merged'); -assert.equal(resolveLanceDbTarget('code', 'doc'), 'doc'); -assert.equal(resolveLanceDbTarget('prose', 'code'), 'code'); -assert.equal(resolveLanceDbTarget('code', 'merged'), 'merged'); - -assert.equal(resolveHnswTarget('code', 'auto'), 'code'); -assert.equal(resolveHnswTarget('prose', 'auto'), 'doc'); -assert.equal(resolveHnswTarget('extracted-prose', 'auto'), 'doc'); -assert.equal(resolveHnswTarget('records', 'auto'), 'merged'); -assert.equal(resolveHnswTarget('code', 'doc'), 'doc'); -assert.equal(resolveHnswTarget('prose', 'code'), 'code'); -assert.equal(resolveHnswTarget('code', 'merged'), 'merged'); -assert.equal(resolveHnswTarget('code', ''), 'merged'); - -console.log('ann backend selection tests passed'); diff --git a/tests/retrieval/ann/ann-candidate-set-contract.test.js b/tests/retrieval/ann/ann-candidate-set-contract.test.js deleted file mode 100644 index 76f450d14..000000000 --- a/tests/retrieval/ann/ann-candidate-set-contract.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { canRunAnnQuery, isCandidateSetEmpty, isEmbeddingReady } from '../../../src/retrieval/ann/utils.js'; - -assert.equal(isEmbeddingReady([0.1]), true); -assert.equal(isEmbeddingReady(new Float32Array([0.1, 0.2])), true); -assert.equal(isEmbeddingReady([]), false); -assert.equal(isEmbeddingReady(null), false); - -assert.equal(isCandidateSetEmpty(null), false); -assert.equal(isCandidateSetEmpty(new Set()), true); -assert.equal(isCandidateSetEmpty(new Set([1])), false); -assert.equal(isCandidateSetEmpty({ size: () => 0 }), true); -assert.equal(isCandidateSetEmpty({ size: () => 2 }), false); -assert.equal(isCandidateSetEmpty({ getSize: () => 0 }), true); -assert.equal(isCandidateSetEmpty([]), true); -assert.equal(isCandidateSetEmpty([1]), false); - -const embedding = [0.1, 0.2]; -assert.equal( - canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: true, enabled: true }), - true -); -assert.equal( - canRunAnnQuery({ signal: { aborted: true }, embedding, candidateSet: null, backendReady: true, enabled: true }), - false -); -assert.equal( - canRunAnnQuery({ signal: null, embedding, candidateSet: new Set(), backendReady: true, enabled: true }), - false -); -assert.equal( - canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: false, enabled: true }), - false -); -assert.equal( - canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: true, enabled: false }), - false -); - -console.log('ann candidate set contract test passed'); diff --git a/tests/retrieval/ann/ann-parity.test.js b/tests/retrieval/ann/ann-parity.test.js deleted file mode 100644 index f9f65e2e8..000000000 --- a/tests/retrieval/ann/ann-parity.test.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { requireHnswLib, requireLanceDb } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -await requireLanceDb({ reason: 'lancedb not available; skipping ann parity test.' }); -requireHnswLib({ reason: 'hnswlib-node not available; skipping ann parity test.' }); - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'ann-parity'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); -await fs.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot: cacheRoot, - embeddings: 'stub' -}); - -function runNode(args, label) { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -} - -runNode([path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], 'build index'); -runNode( - [path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], - 'build embeddings (code)' -); -runNode( - [path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'prose', '--repo', repoRoot], - 'build embeddings (prose)' -); - -function runSearch(backend) { - const result = spawnSync( - process.execPath, - [ - path.join(root, 'search.js'), - 'index', - '--backend', - 'memory', - '--ann', - '--ann-backend', - backend, - '--dense-vector-mode', - 'merged', - '--json', - '--stats', - '-n', - '5', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, encoding: 'utf8' } - ); - if (result.status !== 0) { - console.error(`Search failed for ANN backend=${backend}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return JSON.parse(result.stdout || '{}'); -} - -const densePayload = runSearch('dense'); -const hnswPayload = runSearch('hnsw'); -const lancePayload = runSearch('lancedb'); - -const expectedBackend = { - dense: 'js', - hnsw: 'hnsw', - lancedb: 'lancedb' -}; -const ensureBackend = (payload, backend, label) => { - const actual = payload?.stats?.annBackend; - if (actual !== backend) { - console.error(`Expected annBackend=${backend} for ${label}, got ${actual || 'unset'}`); - process.exit(1); - } -}; -ensureBackend(densePayload, expectedBackend.dense, 'dense'); -ensureBackend(hnswPayload, expectedBackend.hnsw, 'hnsw'); -ensureBackend(lancePayload, expectedBackend.lancedb, 'lancedb'); - -const hitKey = (hit, index) => { - if (hit && (hit.id || hit.id === 0)) return String(hit.id); - if (hit && hit.file) { - const start = hit.startLine ?? hit.start ?? 0; - const end = hit.endLine ?? hit.end ?? 0; - return `${hit.file}:${start}:${end}:${hit.kind || ''}:${hit.name || ''}`; - } - return String(index); -}; - -const topKeys = (payload, mode) => { - const hits = Array.isArray(payload?.[mode]) ? payload[mode] : []; - return hits.slice(0, 5).map((hit, index) => hitKey(hit, index)); -}; - -const compareHits = (baseKeys, otherKeys, label) => { - if (!baseKeys.length && !otherKeys.length) return; - if (!baseKeys.length || !otherKeys.length) { - console.error(`ANN parity failed for ${label}: one backend returned no hits.`); - process.exit(1); - } - const otherSet = new Set(otherKeys); - const overlap = baseKeys.filter((key) => otherSet.has(key)); - const overlapRatio = overlap.length / Math.min(baseKeys.length, otherKeys.length); - if (baseKeys[0] !== otherKeys[0]) { - console.error(`ANN parity failed for ${label}: top hit mismatch.`); - process.exit(1); - } - if (overlapRatio < 0.6) { - console.error(`ANN parity failed for ${label}: overlap ${overlapRatio.toFixed(2)} < 0.6.`); - process.exit(1); - } -}; - -for (const mode of ['code', 'prose']) { - const baseKeys = topKeys(densePayload, mode); - compareHits(baseKeys, topKeys(hnswPayload, mode), `${mode} (dense vs hnsw)`); - compareHits(baseKeys, topKeys(lancePayload, mode), `${mode} (dense vs lancedb)`); -} - -console.log('ANN parity test passed'); diff --git a/tests/retrieval/ann/ann-provider-gating-parity.test.js b/tests/retrieval/ann/ann-provider-gating-parity.test.js deleted file mode 100644 index 0b094f7db..000000000 --- a/tests/retrieval/ann/ann-provider-gating-parity.test.js +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createDenseAnnProvider } from '../../../src/retrieval/ann/providers/dense.js'; -import { createHnswAnnProvider } from '../../../src/retrieval/ann/providers/hnsw.js'; -import { createLanceDbAnnProvider } from '../../../src/retrieval/ann/providers/lancedb.js'; -import { createSqliteVectorAnnProvider } from '../../../src/retrieval/ann/providers/sqlite-vec.js'; - -const embedding = [0.42, 0.13]; -const abortedSignal = { aborted: true }; - -const dense = createDenseAnnProvider(); -assert.equal(dense.isAvailable({ idx: { denseVec: { vectors: [[0.1, 0.2]] } }, embedding }), true); -assert.equal(dense.isAvailable({ idx: { denseVec: { vectors: [[0.1, 0.2]] } }, embedding: [] }), false); -assert.deepEqual( - dense.query({ - idx: { denseVec: { vectors: [[0.1, 0.2]] } }, - embedding, - topN: 5, - candidateSet: null, - signal: abortedSignal - }), - [] -); -assert.deepEqual( - dense.query({ - idx: { denseVec: { vectors: [[0.1, 0.2]] } }, - embedding, - topN: 5, - candidateSet: new Set(), - signal: null - }), - [] -); - -const hnsw = createHnswAnnProvider({ hnswAnnState: { code: { available: true } }, hnswAnnUsed: { code: false } }); -assert.equal(hnsw.isAvailable({ idx: { hnsw: { available: true } }, mode: 'code', embedding }), true); -assert.equal(hnsw.isAvailable({ idx: { hnsw: { available: true } }, mode: 'code', embedding: null }), false); -assert.deepEqual( - hnsw.query({ - idx: { hnsw: { available: true } }, - mode: 'code', - embedding, - topN: 5, - candidateSet: new Set(), - signal: null - }), - [] -); -const hnswEfCalls = []; -const hnswHits = hnsw.query({ - idx: { - hnsw: { - available: true, - space: 'cosine', - index: { - setEf: (value) => hnswEfCalls.push(value), - getCurrentCount: () => 2, - searchKnn: () => ({ - neighbors: [1], - distances: [0.1] - }) - } - } - }, - mode: 'code', - embedding, - topN: 2, - candidateSet: null, - signal: null, - budget: { - hnswEfSearch: 77 - } -}); -assert.equal(hnswEfCalls[0], 77, 'expected hnsw provider to apply per-query efSearch budget'); -assert.equal(hnswHits.length, 1, 'expected hnsw hits from fake index'); -assert.equal(hnswHits[0].idx, 1, 'expected fake hnsw hit idx'); - -const lancedb = createLanceDbAnnProvider({ - lancedbConfig: { enabled: true }, - lanceAnnState: { code: { available: true } }, - lanceAnnUsed: { code: false } -}); -assert.equal(lancedb.isAvailable({ idx: { lancedb: { available: true } }, mode: 'code', embedding }), true); -assert.equal( - lancedb.isAvailable({ idx: { lancedb: { available: true } }, mode: 'code', embedding: new Float32Array([]) }), - false -); -assert.deepEqual( - await lancedb.query({ - idx: { lancedb: { available: true } }, - mode: 'code', - embedding, - topN: 5, - candidateSet: new Set(), - signal: null - }), - [] -); - -let sqliteCalls = 0; -const sqlite = createSqliteVectorAnnProvider({ - rankVectorAnnSqlite: (mode, queryEmbedding, topN, candidateSet) => { - sqliteCalls += 1; - return [{ idx: 1, sim: 0.9, mode, size: queryEmbedding.length, topN, candidateSetSize: candidateSet?.size || 0 }]; - }, - vectorAnnState: { code: { available: true } }, - vectorAnnUsed: { code: false } -}); -assert.equal(sqlite.isAvailable({ mode: 'code', embedding }), true); -assert.equal(sqlite.isAvailable({ mode: 'code', embedding: null }), false); -assert.deepEqual( - sqlite.query({ mode: 'code', embedding, topN: 5, candidateSet: new Set(), signal: null }), - [] -); -const sqliteHits = sqlite.query({ mode: 'code', embedding, topN: 3, candidateSet: null, signal: null }); -assert.equal(sqliteCalls, 1, 'sqlite provider should run once for valid query'); -assert.equal(sqliteHits.length, 1); -assert.equal(sqliteHits[0].topN, 3); - -console.log('ann provider gating parity test passed'); diff --git a/tests/retrieval/ann/backend-contract-matrix.test.js b/tests/retrieval/ann/backend-contract-matrix.test.js new file mode 100644 index 000000000..7f9d76dd5 --- /dev/null +++ b/tests/retrieval/ann/backend-contract-matrix.test.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveLanceDbTarget } from '../../../src/shared/lancedb.js'; +import { resolveHnswTarget } from '../../../src/shared/hnsw.js'; +import { + ANN_BACKEND_CHOICES, + normalizeAnnBackend, + resolveAnnOrder +} from '../../../src/retrieval/ann/normalize-backend.js'; +import { createDenseAnnProvider } from '../../../src/retrieval/ann/providers/dense.js'; +import { createHnswAnnProvider } from '../../../src/retrieval/ann/providers/hnsw.js'; +import { createLanceDbAnnProvider } from '../../../src/retrieval/ann/providers/lancedb.js'; +import { createSqliteVectorAnnProvider } from '../../../src/retrieval/ann/providers/sqlite-vec.js'; + +assert.equal(resolveLanceDbTarget('code', 'auto'), 'code'); +assert.equal(resolveLanceDbTarget('prose', 'auto'), 'doc'); +assert.equal(resolveLanceDbTarget('extracted-prose', 'auto'), 'doc'); +assert.equal(resolveLanceDbTarget('records', 'auto'), 'merged'); +assert.equal(resolveLanceDbTarget('code', 'doc'), 'doc'); +assert.equal(resolveLanceDbTarget('prose', 'code'), 'code'); +assert.equal(resolveLanceDbTarget('code', 'merged'), 'merged'); + +assert.equal(resolveHnswTarget('code', 'auto'), 'code'); +assert.equal(resolveHnswTarget('prose', 'auto'), 'doc'); +assert.equal(resolveHnswTarget('extracted-prose', 'auto'), 'doc'); +assert.equal(resolveHnswTarget('records', 'auto'), 'merged'); +assert.equal(resolveHnswTarget('code', 'doc'), 'doc'); +assert.equal(resolveHnswTarget('prose', 'code'), 'code'); +assert.equal(resolveHnswTarget('code', 'merged'), 'merged'); +assert.equal(resolveHnswTarget('code', ''), 'merged'); + +assert.deepEqual(ANN_BACKEND_CHOICES, ['auto', 'lancedb', 'sqlite-vector', 'hnsw', 'js']); +assert.equal(normalizeAnnBackend('sqlite'), 'sqlite-vector'); +assert.equal(normalizeAnnBackend('sqlite-extension'), 'sqlite-vector'); +assert.equal(normalizeAnnBackend('vector-extension'), 'sqlite-vector'); +assert.equal(normalizeAnnBackend('dense'), 'js'); +assert.equal(normalizeAnnBackend('js'), 'js'); +assert.equal(normalizeAnnBackend('auto'), 'auto'); +assert.equal(normalizeAnnBackend('unknown'), 'lancedb'); +assert.equal(normalizeAnnBackend('unknown', { strict: true, defaultBackend: null }), null); + +assert.deepEqual(resolveAnnOrder('lancedb'), ['lancedb']); +assert.deepEqual(resolveAnnOrder('sqlite-vector'), ['sqlite-vector']); +assert.deepEqual(resolveAnnOrder('hnsw'), ['hnsw']); +assert.deepEqual(resolveAnnOrder('dense'), ['js']); +assert.deepEqual(resolveAnnOrder('auto'), ['lancedb', 'sqlite-vector', 'hnsw', 'js']); + +{ + const embedding = [0.42, 0.13]; + const abortedSignal = { aborted: true }; + + const dense = createDenseAnnProvider(); + assert.equal(dense.isAvailable({ idx: { denseVec: { vectors: [[0.1, 0.2]] } }, embedding }), true); + assert.equal(dense.isAvailable({ idx: { denseVec: { vectors: [[0.1, 0.2]] } }, embedding: [] }), false); + assert.deepEqual( + dense.query({ + idx: { denseVec: { vectors: [[0.1, 0.2]] } }, + embedding, + topN: 5, + candidateSet: null, + signal: abortedSignal + }), + [] + ); + assert.deepEqual( + dense.query({ + idx: { denseVec: { vectors: [[0.1, 0.2]] } }, + embedding, + topN: 5, + candidateSet: new Set(), + signal: null + }), + [] + ); + + const hnsw = createHnswAnnProvider({ + hnswAnnState: { code: { available: true } }, + hnswAnnUsed: { code: false } + }); + assert.equal(hnsw.isAvailable({ idx: { hnsw: { available: true } }, mode: 'code', embedding }), true); + assert.equal(hnsw.isAvailable({ idx: { hnsw: { available: true } }, mode: 'code', embedding: null }), false); + assert.deepEqual( + hnsw.query({ + idx: { hnsw: { available: true } }, + mode: 'code', + embedding, + topN: 5, + candidateSet: new Set(), + signal: null + }), + [] + ); + const hnswEfCalls = []; + const hnswHits = hnsw.query({ + idx: { + hnsw: { + available: true, + space: 'cosine', + index: { + setEf: (value) => hnswEfCalls.push(value), + getCurrentCount: () => 2, + searchKnn: () => ({ + neighbors: [1], + distances: [0.1] + }) + } + } + }, + mode: 'code', + embedding, + topN: 2, + candidateSet: null, + signal: null, + budget: { + hnswEfSearch: 77 + } + }); + assert.equal(hnswEfCalls[0], 77); + assert.equal(hnswHits.length, 1); + assert.equal(hnswHits[0].idx, 1); + + const lancedb = createLanceDbAnnProvider({ + lancedbConfig: { enabled: true }, + lanceAnnState: { code: { available: true } }, + lanceAnnUsed: { code: false } + }); + assert.equal(lancedb.isAvailable({ idx: { lancedb: { available: true } }, mode: 'code', embedding }), true); + assert.equal( + lancedb.isAvailable({ + idx: { lancedb: { available: true } }, + mode: 'code', + embedding: new Float32Array([]) + }), + false + ); + assert.deepEqual( + await lancedb.query({ + idx: { lancedb: { available: true } }, + mode: 'code', + embedding, + topN: 5, + candidateSet: new Set(), + signal: null + }), + [] + ); + + let sqliteCalls = 0; + const sqlite = createSqliteVectorAnnProvider({ + rankVectorAnnSqlite: (mode, queryEmbedding, topN, candidateSet) => { + sqliteCalls += 1; + return [ + { + idx: 1, + sim: 0.9, + mode, + size: queryEmbedding.length, + topN, + candidateSetSize: candidateSet?.size || 0 + } + ]; + }, + vectorAnnState: { code: { available: true } }, + vectorAnnUsed: { code: false } + }); + assert.equal(sqlite.isAvailable({ mode: 'code', embedding }), true); + assert.equal(sqlite.isAvailable({ mode: 'code', embedding: null }), false); + assert.deepEqual( + sqlite.query({ mode: 'code', embedding, topN: 5, candidateSet: new Set(), signal: null }), + [] + ); + const sqliteHits = sqlite.query({ mode: 'code', embedding, topN: 3, candidateSet: null, signal: null }); + assert.equal(sqliteCalls, 1); + assert.equal(sqliteHits.length, 1); + assert.equal(sqliteHits[0].topN, 3); +} + +console.log('ann backend contract matrix test passed'); diff --git a/tests/retrieval/ann/dense-provider-lazy-load.test.js b/tests/retrieval/ann/dense-provider-lazy-load.test.js deleted file mode 100644 index 6bcfda2b4..000000000 --- a/tests/retrieval/ann/dense-provider-lazy-load.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { createDenseAnnProvider } from '../../../src/retrieval/ann/providers/dense.js'; -import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; - -const { context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([ - [ANN_PROVIDER_IDS.DENSE, createDenseAnnProvider()] - ]) -}); -context.annBackend = 'dense'; -idx.denseVec = { dims: 2, minVal: -1, maxVal: 1, levels: 256, scale: 1, vectors: null }; - -let loadCalls = 0; -idx.loadDenseVectors = async () => { - loadCalls += 1; - idx.denseVec = { - dims: 2, - minVal: -1, - maxVal: 1, - levels: 256, - scale: 1, - vectors: [ - [0.1, 0.2], - [0.2, 0.1] - ] - }; - return idx.denseVec; -}; - -const pipeline = createSearchPipeline(context); - -const run1 = await pipeline(idx, 'code', [0.1, 0.2]); -const run2 = await pipeline(idx, 'code', [0.1, 0.2]); - -assert.ok(Array.isArray(run1) && run1.length > 0, 'expected ANN results after lazy dense load'); -assert.ok(Array.isArray(run2) && run2.length > 0, 'expected ANN results on subsequent run'); -assert.equal(loadCalls, 1, 'expected dense vectors to load on-demand once'); -assert.ok(run1.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE), 'expected dense ANN source'); -assert.ok(run2.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE), 'expected dense ANN source'); - -console.log('dense provider lazy load test passed'); diff --git a/tests/retrieval/ann/dense-vector-mode.test.js b/tests/retrieval/ann/dense-vector-mode.test.js deleted file mode 100644 index caf58f3ea..000000000 --- a/tests/retrieval/ann/dense-vector-mode.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveDenseVector } from '../../../src/retrieval/cli/index-loader.js'; -import { resolveIntentVectorMode } from '../../../src/retrieval/query-intent.js'; - -const makeIdx = () => ({ - denseVec: { label: 'merged' }, - denseVecDoc: { label: 'doc' }, - denseVecCode: { label: 'code' } -}); - -const idx = makeIdx(); -assert.equal(resolveDenseVector(idx, 'code', 'code')?.label, 'code'); -assert.equal(resolveDenseVector(idx, 'prose', 'doc')?.label, 'doc'); -assert.equal(resolveDenseVector(idx, 'code', 'merged')?.label, 'merged'); -assert.equal(resolveDenseVector(idx, 'code', 'auto')?.label, 'code'); -assert.equal(resolveDenseVector(idx, 'prose', 'auto')?.label, 'doc'); - -const fallbackIdx = { denseVec: { label: 'merged' } }; -assert.equal(resolveDenseVector(fallbackIdx, 'code', 'code')?.label, 'merged'); -assert.equal(resolveDenseVector(fallbackIdx, 'prose', 'doc')?.label, 'merged'); - -assert.equal(resolveIntentVectorMode('auto', { vectorMode: 'doc' }), 'doc'); -assert.equal(resolveIntentVectorMode('auto', { vectorMode: null }), 'auto'); -assert.equal(resolveIntentVectorMode('code', { vectorMode: 'doc' }), 'code'); - -console.log('dense vector mode tests passed'); diff --git a/tests/retrieval/ann/embedding-dims-normalization.test.js b/tests/retrieval/ann/embedding-dims-normalization.test.js deleted file mode 100644 index d8585a2f3..000000000 --- a/tests/retrieval/ann/embedding-dims-normalization.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { normalizeEmbeddingDims } from '../../../src/retrieval/ann/dims.js'; - -const clipped = normalizeEmbeddingDims([1, 2, 3, 4], 2); -assert.equal(clipped.adjusted, true, 'expected clip adjustment'); -assert.equal(clipped.queryDims, 4, 'expected original query dims'); -assert.equal(clipped.expectedDims, 2, 'expected target dims'); -assert.deepEqual(clipped.embedding, [1, 2], 'expected clipped embedding'); - -const padded = normalizeEmbeddingDims(new Float32Array([3, 4]), 4); -assert.equal(padded.adjusted, true, 'expected pad adjustment'); -assert.equal(padded.queryDims, 2, 'expected original typed-array dims'); -assert.equal(padded.expectedDims, 4, 'expected target dims'); -assert.deepEqual(padded.embedding, [3, 4, 0, 0], 'expected zero-padded embedding'); - -const unchanged = normalizeEmbeddingDims([7, 8, 9], 3); -assert.equal(unchanged.adjusted, false, 'expected unchanged dimensions'); -assert.deepEqual(unchanged.embedding, [7, 8, 9], 'expected unchanged embedding'); - -console.log('embedding dims normalization test passed'); diff --git a/tests/retrieval/ann/hnsw-ann.test.js b/tests/retrieval/ann/hnsw-ann.test.js deleted file mode 100644 index 9d794ac95..000000000 --- a/tests/retrieval/ann/hnsw-ann.test.js +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { normalizeHnswConfig, rankHnswIndex } from '../../../src/shared/hnsw.js'; -import { requireHnswLib } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'hnsw-ann'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -const fakeIndex = { - searchKnn: (_vec, _limit, filter) => { - const neighbors = [3, 1, 2]; - const distances = [0.2, 0.1, 0.1]; - if (!filter) return { neighbors, distances }; - const filtered = []; - const filteredDistances = []; - for (let i = 0; i < neighbors.length; i += 1) { - if (filter(neighbors[i])) { - filtered.push(neighbors[i]); - filteredDistances.push(distances[i]); - } - } - return { neighbors: filtered, distances: filteredDistances }; - } -}; -const fakeHits = rankHnswIndex( - { index: fakeIndex, space: 'cosine' }, - [0.1, 0.2], - 3, - new Set([1, 2]) -); -if (fakeHits.length !== 2 || fakeHits[0].idx !== 1 || fakeHits[1].idx !== 2) { - console.error('Expected candidate-set filtering and deterministic tie-breaks in HNSW ranking.'); - process.exit(1); -} - -requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw-ann test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -function run(args, label) { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -} - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], 'build index'); -run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'build embeddings (code)'); -run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'prose', '--repo', repoRoot], 'build embeddings (prose)'); - -const userConfig = loadUserConfig(repoRoot); -const hnswConfig = normalizeHnswConfig(userConfig.indexing?.embeddings?.hnsw || {}); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -const proseDir = getIndexDir(repoRoot, 'prose', userConfig); -const codeIndex = path.join(codeDir, 'dense_vectors_hnsw.bin'); -const codeMeta = path.join(codeDir, 'dense_vectors_hnsw.meta.json'); -const codeDocIndex = path.join(codeDir, 'dense_vectors_doc_hnsw.bin'); -const codeDocMeta = path.join(codeDir, 'dense_vectors_doc_hnsw.meta.json'); -const codeCodeIndex = path.join(codeDir, 'dense_vectors_code_hnsw.bin'); -const codeCodeMeta = path.join(codeDir, 'dense_vectors_code_hnsw.meta.json'); -const proseIndex = path.join(proseDir, 'dense_vectors_hnsw.bin'); -const proseMeta = path.join(proseDir, 'dense_vectors_hnsw.meta.json'); -const proseDocIndex = path.join(proseDir, 'dense_vectors_doc_hnsw.bin'); -const proseDocMeta = path.join(proseDir, 'dense_vectors_doc_hnsw.meta.json'); -const proseCodeIndex = path.join(proseDir, 'dense_vectors_code_hnsw.bin'); -const proseCodeMeta = path.join(proseDir, 'dense_vectors_code_hnsw.meta.json'); - -if (!fs.existsSync(codeIndex) || !fs.existsSync(codeMeta)) { - console.error('HNSW index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(codeDocIndex) || !fs.existsSync(codeDocMeta)) { - console.error('HNSW doc index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(codeCodeIndex) || !fs.existsSync(codeCodeMeta)) { - console.error('HNSW code index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(proseIndex) || !fs.existsSync(proseMeta)) { - console.error('HNSW index missing for prose mode.'); - process.exit(1); -} -if (!fs.existsSync(proseDocIndex) || !fs.existsSync(proseDocMeta)) { - console.error('HNSW doc index missing for prose mode.'); - process.exit(1); -} -if (!fs.existsSync(proseCodeIndex) || !fs.existsSync(proseCodeMeta)) { - console.error('HNSW code index missing for prose mode.'); - process.exit(1); -} - -const codeState = JSON.parse(fs.readFileSync(path.join(codeDir, 'index_state.json'), 'utf8')); -const proseState = JSON.parse(fs.readFileSync(path.join(proseDir, 'index_state.json'), 'utf8')); -if (codeState?.embeddings?.embeddingIdentity?.normalize !== true) { - console.error('Expected code embeddingIdentity.normalize=true in index_state.json.'); - process.exit(1); -} -if (proseState?.embeddings?.embeddingIdentity?.normalize !== true) { - console.error('Expected prose embeddingIdentity.normalize=true in index_state.json.'); - process.exit(1); -} - -const codeMetaPayload = JSON.parse(fs.readFileSync(codeMeta, 'utf8')); -const proseMetaPayload = JSON.parse(fs.readFileSync(proseMeta, 'utf8')); -if (codeMetaPayload.space !== hnswConfig.space) { - console.error(`Expected HNSW code space=${hnswConfig.space}, got ${codeMetaPayload.space}`); - process.exit(1); -} -if (proseMetaPayload.space !== hnswConfig.space) { - console.error(`Expected HNSW prose space=${hnswConfig.space}, got ${proseMetaPayload.space}`); - process.exit(1); -} - -const searchResult = spawnSync( - process.execPath, - [ - path.join(root, 'search.js'), - 'index', - '--backend', - 'memory', - '--json', - '--stats', - '--ann', - '--ann-backend', - 'hnsw', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('search.js failed for HNSW ANN test.'); - if (searchResult.stderr) console.error(searchResult.stderr.trim()); - process.exit(searchResult.status ?? 1); -} - -const payload = JSON.parse(searchResult.stdout || '{}'); -const stats = payload.stats || {}; -if (stats.annBackend !== 'hnsw') { - console.error(`Expected annBackend=hnsw, got ${stats.annBackend}`); - process.exit(1); -} -if (!stats.annHnsw?.available?.code || !stats.annHnsw?.available?.prose) { - console.error('Expected HNSW availability for code and prose.'); - process.exit(1); -} - -console.log('HNSW ANN test passed'); - diff --git a/tests/retrieval/ann/hnsw-atomic.test.js b/tests/retrieval/ann/hnsw-atomic.test.js index 5ca4f2503..6e176bcbe 100644 --- a/tests/retrieval/ann/hnsw-atomic.test.js +++ b/tests/retrieval/ann/hnsw-atomic.test.js @@ -2,43 +2,78 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; import { loadHnswIndex, normalizeHnswConfig, resolveHnswPaths } from '../../../src/shared/hnsw.js'; import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; import { requireHnswLib } from '../../helpers/optional-deps.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'hnsw-atomic'); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('hnsw-atomic', { root }); const repoRoot = path.join(tempRoot, 'repo'); const cacheRoot = path.join(tempRoot, 'cache'); requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw atomic test.' }); -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'main.js'), + 'export function indexItem(value) { return value + 1; }\n', + 'utf8' +); +await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# HNSW Atomic Fixture\n\nThis fixture keeps the ANN atomicity contract minimal.\n', + 'utf8' +); -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: true, + testConfig: { + indexing: { + scm: { provider: 'none' }, + embeddings: { + hnsw: { + enabled: true, + isolate: false + } + }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); -const buildIndex = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } +runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'hnsw atomic build index', + repoRoot, + env, + { stdio: 'inherit' } ); -if (buildIndex.status !== 0) { - console.error('hnsw atomic test failed: build_index failed'); - process.exit(buildIndex.status ?? 1); -} const userConfig = loadUserConfig(repoRoot); const codeIndexDir = getIndexDir(repoRoot, 'code', userConfig); @@ -47,20 +82,14 @@ const { indexPath: hnswIndexPath, metaPath: hnswMetaPath } = resolveHnswPaths(co await fsPromises.writeFile(hnswIndexPath, 'stub-index'); await fsPromises.writeFile(hnswMetaPath, JSON.stringify({ version: 1, dims: 1, count: 0 })); -const buildEmbeddings = spawnSync( - process.execPath, - [path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--repo', repoRoot], - { cwd: repoRoot, env, stdio: 'inherit' } +runNode( + [path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], + 'hnsw atomic build embeddings', + repoRoot, + env, + { stdio: 'inherit' } ); -if (buildEmbeddings.status !== 0) { - console.error('hnsw atomic test failed: build-embeddings failed'); - process.exit(buildEmbeddings.status ?? 1); -} -if (!fs.existsSync(`${hnswIndexPath}.bak`)) { - console.error('hnsw atomic test failed: expected .bak for HNSW index after replace'); - process.exit(1); -} await fsPromises.copyFile(hnswIndexPath, `${hnswIndexPath}.bak`); const chunkMeta = await loadChunkMeta(codeIndexDir); diff --git a/tests/retrieval/ann/hnsw-candidate-set.test.js b/tests/retrieval/ann/hnsw-candidate-set.test.js deleted file mode 100644 index 58fd8a50e..000000000 --- a/tests/retrieval/ann/hnsw-candidate-set.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from 'node:module'; -import { rankHnswIndex } from '../../../src/shared/hnsw.js'; -import { requireHnswLib } from '../../helpers/optional-deps.js'; - -requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw candidate set test.' }); - -const require = createRequire(import.meta.url); -const hnswlib = require('hnswlib-node'); - -const HNSW = hnswlib?.HierarchicalNSW || hnswlib?.default?.HierarchicalNSW || hnswlib?.default; -if (!HNSW) { - console.log('hnsw candidate set test skipped: HNSW constructor missing'); - process.exit(0); -} - -const index = new HNSW('l2', 2); -index.initIndex({ - maxElements: 3, - m: 16, - efConstruction: 200, - randomSeed: 42, - allowReplaceDeleted: false -}); -index.addPoint([0, 0], 0); -index.addPoint([1, 0], 1); -index.addPoint([0, 1], 2); - -const query = new Float32Array([0, 0]); - -const emptyHits = rankHnswIndex({ index, space: 'l2' }, query, 2, new Set()); -if (emptyHits.length !== 0) { - console.error('hnsw candidate set test failed: expected empty hits for empty candidate set'); - process.exit(1); -} - -const candidates = new Set([1, 2]); -const hits = rankHnswIndex({ index, space: 'l2' }, query, 2, candidates); -if (!hits.length) { - console.error('hnsw candidate set test failed: expected hits with candidate set'); - process.exit(1); -} -if (hits.some((hit) => !candidates.has(hit.idx))) { - console.error('hnsw candidate set test failed: hits include non-candidate ids'); - process.exit(1); -} -if (hits.length > 1 && hits[0].sim < hits[1].sim) { - console.error('hnsw candidate set test failed: results not sorted by similarity'); - process.exit(1); -} -if (hits.length > 1 && hits[0].sim === hits[1].sim && hits[0].idx > hits[1].idx) { - console.error('hnsw candidate set test failed: tie-break not stable'); - process.exit(1); -} -if (hits[0].sim > 0) { - console.error('hnsw candidate set test failed: expected negative sim for L2 distance'); - process.exit(1); -} - -const largeCandidates = new Set(Array.from({ length: 1000 }, (_, i) => i)); -const largeHits = rankHnswIndex({ index, space: 'l2' }, query, 2, largeCandidates); -if (!largeHits.length) { - console.error('hnsw candidate set test failed: expected hits for large candidate set'); - process.exit(1); -} -if (largeHits.some((hit) => !largeCandidates.has(hit.idx))) { - console.error('hnsw candidate set test failed: large candidate set filtering failed'); - process.exit(1); -} -if (largeHits.length > 1 && largeHits[0].sim < largeHits[1].sim) { - console.error('hnsw candidate set test failed: large set results not sorted by similarity'); - process.exit(1); -} - -console.log('hnsw candidate set test passed'); diff --git a/tests/retrieval/ann/hnsw-distance-metrics.test.js b/tests/retrieval/ann/hnsw-distance-metrics.test.js deleted file mode 100644 index 1de9d91f9..000000000 --- a/tests/retrieval/ann/hnsw-distance-metrics.test.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from 'node:module'; -import { rankHnswIndex } from '../../../src/shared/hnsw.js'; -import { requireHnswLib } from '../../helpers/optional-deps.js'; - -requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw distance metrics test.' }); - -const require = createRequire(import.meta.url); -const hnswlib = require('hnswlib-node'); - -const HNSW = hnswlib?.HierarchicalNSW || hnswlib?.default?.HierarchicalNSW || hnswlib?.default; -if (!HNSW) { - console.log('hnsw distance metrics test skipped: HNSW constructor missing'); - process.exit(0); -} - -const runCase = ({ space, vectors, query, expectedTop }) => { - const index = new HNSW(space, 2); - index.initIndex({ - maxElements: vectors.length, - m: 16, - efConstruction: 200, - randomSeed: 42, - allowReplaceDeleted: false - }); - vectors.forEach((vec, idx) => index.addPoint(vec, idx)); - const hits = rankHnswIndex({ index, space }, new Float32Array(query), 2, null); - if (!hits.length) { - throw new Error(`expected hits for space=${space}`); - } - if (hits[0].idx !== expectedTop) { - throw new Error(`space=${space}: expected top=${expectedTop}, got ${hits[0].idx}`); - } - if (hits.length > 1 && hits[0].sim < hits[1].sim) { - throw new Error(`space=${space}: results not sorted by similarity`); - } -}; - -try { - runCase({ - space: 'l2', - vectors: [[0, 0], [1, 0]], - query: [0, 0], - expectedTop: 0 - }); - runCase({ - space: 'cosine', - vectors: [[1, 0], [0, 1]], - query: [1, 0], - expectedTop: 0 - }); - runCase({ - space: 'ip', - vectors: [[1, 0], [0.5, 0.5]], - query: [1, 0], - expectedTop: 0 - }); -} catch (err) { - console.error(`hnsw distance metrics test failed: ${err?.message || err}`); - process.exit(1); -} - -console.log('hnsw distance metrics test passed'); diff --git a/tests/retrieval/ann/hnsw-insert-failures.test.js b/tests/retrieval/ann/hnsw-insert-failures.test.js deleted file mode 100644 index bb7b895d9..000000000 --- a/tests/retrieval/ann/hnsw-insert-failures.test.js +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { normalizeHnswConfig } from '../../../src/shared/hnsw.js'; -import { requireHnswLib } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'hnsw-insert-failures'); -const indexPath = path.join(tempRoot, 'dense_vectors_hnsw.bin'); -const metaPath = path.join(tempRoot, 'dense_vectors_hnsw.meta.json'); - -requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw insert failure test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const require = createRequire(import.meta.url); -const modulePath = require.resolve('hnswlib-node'); -const originalExports = require(modulePath); - -class FakeHNSW { - constructor(space, dims) { - this.space = space; - this.dims = dims; - } - initIndex() {} - addPoint(_vec, label) { - if (label === 1) { - throw new Error('simulated insert failure'); - } - } - writeIndexSync() {} -} - -try { - const cached = require.cache[modulePath]; - if (cached) { - cached.exports = { HierarchicalNSW: FakeHNSW, default: FakeHNSW }; - } - const { createHnswBuilder } = await import('../../../tools/build/embeddings/hnsw.js'); - const builder = createHnswBuilder({ - enabled: true, - config: normalizeHnswConfig({}), - totalChunks: 2, - mode: 'code', - logger: null - }); - builder.addVector(0, [0, 0]); - builder.addVector(1, [1, 1]); - - let threw = false; - try { - await builder.writeIndex({ - indexPath, - metaPath, - modelId: 'test-model', - dims: 2, - quantization: { minVal: -1, maxVal: 1, levels: 256 }, - scale: 1 - }); - } catch { - threw = true; - } - assert.ok(threw, 'expected writeIndex to throw on insert failures'); - - const reportPath = metaPath.replace(/\.meta\.json$/i, '.failures.json'); - assert.ok(fs.existsSync(reportPath), 'expected failure report to be written'); - const report = JSON.parse(await fsPromises.readFile(reportPath, 'utf8')); - assert.equal(report.failed, 1); - assert.ok(Array.isArray(report.failedChunks)); -} finally { - const cached = require.cache[modulePath]; - if (cached) { - cached.exports = originalExports; - } - await fsPromises.rm(tempRoot, { recursive: true, force: true }); -} - -console.log('hnsw insert failure test passed'); diff --git a/tests/retrieval/ann/hnsw-load-signature.test.js b/tests/retrieval/ann/hnsw-load-signature.test.js deleted file mode 100644 index 72f0d4c7d..000000000 --- a/tests/retrieval/ann/hnsw-load-signature.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { loadHnswIndex, normalizeHnswConfig } from '../../../src/shared/hnsw.js'; -import { requireHnswLib } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'hnsw-load-signature'); -const indexPath = path.join(tempRoot, 'dense_vectors_hnsw.bin'); - -requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw load signature test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.writeFile(indexPath, 'stub-index'); - -const require = createRequire(import.meta.url); -const modulePath = require.resolve('hnswlib-node'); -const originalExports = require(modulePath); - -class FakeHNSW { - constructor(space, dims) { - this.space = space; - this.dims = dims; - } - readIndexSync(filePath) { - FakeHNSW.lastArgs = [filePath]; - } - setEf() {} -} -FakeHNSW.lastArgs = null; - -try { - const cached = require.cache[modulePath]; - if (cached) { - cached.exports = { HierarchicalNSW: FakeHNSW, default: FakeHNSW }; - } - const meta = { dims: 2, space: 'cosine' }; - const hnswConfig = normalizeHnswConfig({}); - const index = loadHnswIndex({ - indexPath, - dims: 2, - config: hnswConfig, - meta - }); - assert.ok(index, 'expected HNSW index to load with patched signature'); - assert.ok(FakeHNSW.lastArgs, 'expected readIndexSync to be called'); - assert.equal(FakeHNSW.lastArgs.length, 1, 'expected readIndexSync to receive a single argument'); -} finally { - const cached = require.cache[modulePath]; - if (cached) { - cached.exports = originalExports; - } - await fsPromises.rm(tempRoot, { recursive: true, force: true }); -} - -console.log('hnsw load signature test passed'); diff --git a/tests/retrieval/ann/hnsw-runtime-contract-matrix.test.js b/tests/retrieval/ann/hnsw-runtime-contract-matrix.test.js new file mode 100644 index 000000000..fd7bd5f70 --- /dev/null +++ b/tests/retrieval/ann/hnsw-runtime-contract-matrix.test.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +import { rankHnswIndex, loadHnswIndex, normalizeHnswConfig } from '../../../src/shared/hnsw.js'; +import { requireHnswLib } from '../../helpers/optional-deps.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +requireHnswLib({ reason: 'hnswlib-node not available; skipping HNSW runtime matrix.' }); + +const require = createRequire(import.meta.url); +const hnswlib = require('hnswlib-node'); +const HNSW = hnswlib?.HierarchicalNSW || hnswlib?.default?.HierarchicalNSW || hnswlib?.default; +if (!HNSW) { + console.log('hnsw runtime contract matrix skipped: HNSW constructor missing'); + process.exit(0); +} + +const root = process.cwd(); + +const cases = [ + { + name: 'candidate sets constrain hits without breaking ordering', + run() { + const index = new HNSW('l2', 2); + index.initIndex({ + maxElements: 3, + m: 16, + efConstruction: 200, + randomSeed: 42, + allowReplaceDeleted: false + }); + index.addPoint([0, 0], 0); + index.addPoint([1, 0], 1); + index.addPoint([0, 1], 2); + + const query = new Float32Array([0, 0]); + assert.deepEqual(rankHnswIndex({ index, space: 'l2' }, query, 2, new Set()), []); + + const candidates = new Set([1, 2]); + const hits = rankHnswIndex({ index, space: 'l2' }, query, 2, candidates); + assert.ok(hits.length > 0); + assert.equal(hits.every((hit) => candidates.has(hit.idx)), true); + if (hits.length > 1) { + assert.ok(hits[0].sim >= hits[1].sim); + if (hits[0].sim === hits[1].sim) { + assert.ok(hits[0].idx <= hits[1].idx); + } + } + assert.ok(hits[0].sim <= 0); + + const largeCandidates = new Set(Array.from({ length: 1000 }, (_, i) => i)); + const largeHits = rankHnswIndex({ index, space: 'l2' }, query, 2, largeCandidates); + assert.ok(largeHits.length > 0); + assert.equal(largeHits.every((hit) => largeCandidates.has(hit.idx)), true); + } + }, + { + name: 'distance spaces produce the expected nearest neighbors', + run() { + const runCase = ({ space, vectors, query, expectedTop }) => { + const index = new HNSW(space, 2); + index.initIndex({ + maxElements: vectors.length, + m: 16, + efConstruction: 200, + randomSeed: 42, + allowReplaceDeleted: false + }); + vectors.forEach((vec, idx) => index.addPoint(vec, idx)); + const hits = rankHnswIndex({ index, space }, new Float32Array(query), 2, null); + assert.ok(hits.length > 0); + assert.equal(hits[0].idx, expectedTop); + if (hits.length > 1) assert.ok(hits[0].sim >= hits[1].sim); + }; + + runCase({ space: 'l2', vectors: [[0, 0], [1, 0]], query: [0, 0], expectedTop: 0 }); + runCase({ space: 'cosine', vectors: [[1, 0], [0, 1]], query: [1, 0], expectedTop: 0 }); + runCase({ space: 'ip', vectors: [[1, 0], [0.5, 0.5]], query: [1, 0], expectedTop: 0 }); + } + }, + { + name: 'insert failures write failure reports during index build', + async run() { + const tempRoot = resolveTestCachePath(root, 'hnsw-runtime-matrix-insert-failures'); + const indexPath = path.join(tempRoot, 'dense_vectors_hnsw.bin'); + const metaPath = path.join(tempRoot, 'dense_vectors_hnsw.meta.json'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + const modulePath = require.resolve('hnswlib-node'); + const originalExports = require(modulePath); + + class FakeHNSW { + initIndex() {} + addPoint(_vec, label) { + if (label === 1) throw new Error('simulated insert failure'); + } + writeIndexSync() {} + } + + try { + const cached = require.cache[modulePath]; + if (cached) cached.exports = { HierarchicalNSW: FakeHNSW, default: FakeHNSW }; + const { createHnswBuilder } = await import('../../../tools/build/embeddings/hnsw.js'); + const builder = createHnswBuilder({ + enabled: true, + config: normalizeHnswConfig({}), + totalChunks: 2, + mode: 'code', + logger: null + }); + builder.addVector(0, [0, 0]); + builder.addVector(1, [1, 1]); + + let threw = false; + try { + await builder.writeIndex({ + indexPath, + metaPath, + modelId: 'test-model', + dims: 2, + quantization: { minVal: -1, maxVal: 1, levels: 256 }, + scale: 1 + }); + } catch { + threw = true; + } + assert.equal(threw, true); + + const reportPath = metaPath.replace(/\.meta\.json$/i, '.failures.json'); + assert.equal(fs.existsSync(reportPath), true); + const report = JSON.parse(await fsPromises.readFile(reportPath, 'utf8')); + assert.equal(report.failed, 1); + assert.ok(Array.isArray(report.failedChunks)); + } finally { + const cached = require.cache[modulePath]; + if (cached) cached.exports = originalExports; + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + } + }, + { + name: 'load signatures keep the patched single-argument readIndexSync contract', + async run() { + const tempRoot = resolveTestCachePath(root, 'hnsw-runtime-matrix-load-signature'); + const indexPath = path.join(tempRoot, 'dense_vectors_hnsw.bin'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + await fsPromises.writeFile(indexPath, 'stub-index'); + + const modulePath = require.resolve('hnswlib-node'); + const originalExports = require(modulePath); + + class FakeHNSWLoad { + constructor(space, dims) { + this.space = space; + this.dims = dims; + } + readIndexSync(filePath) { + FakeHNSWLoad.lastArgs = [filePath]; + } + setEf() {} + } + FakeHNSWLoad.lastArgs = null; + + try { + const cached = require.cache[modulePath]; + if (cached) cached.exports = { HierarchicalNSW: FakeHNSWLoad, default: FakeHNSWLoad }; + const index = loadHnswIndex({ + indexPath, + dims: 2, + config: normalizeHnswConfig({}), + meta: { dims: 2, space: 'cosine' } + }); + assert.ok(index); + assert.ok(FakeHNSWLoad.lastArgs); + assert.equal(FakeHNSWLoad.lastArgs.length, 1); + } finally { + const cached = require.cache[modulePath]; + if (cached) cached.exports = originalExports; + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('hnsw runtime contract matrix test passed'); diff --git a/tests/retrieval/ann/hnsw.test.js b/tests/retrieval/ann/hnsw.test.js new file mode 100644 index 000000000..956facb3e --- /dev/null +++ b/tests/retrieval/ann/hnsw.test.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { normalizeHnswConfig, rankHnswIndex } from '../../../src/shared/hnsw.js'; +import { requireHnswLib } from '../../helpers/optional-deps.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('hnsw-ann', { root }); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +const fakeIndex = { + searchKnn: (_vec, _limit, filter) => { + const neighbors = [3, 1, 2]; + const distances = [0.2, 0.1, 0.1]; + if (!filter) return { neighbors, distances }; + const filtered = []; + const filteredDistances = []; + for (let i = 0; i < neighbors.length; i += 1) { + if (filter(neighbors[i])) { + filtered.push(neighbors[i]); + filteredDistances.push(distances[i]); + } + } + return { neighbors: filtered, distances: filteredDistances }; + } +}; +const fakeHits = rankHnswIndex( + { index: fakeIndex, space: 'cosine' }, + [0.1, 0.2], + 3, + new Set([1, 2]) +); +if (fakeHits.length !== 2 || fakeHits[0].idx !== 1 || fakeHits[1].idx !== 2) { + console.error('Expected candidate-set filtering and deterministic tie-breaks in HNSW ranking.'); + process.exit(1); +} + +requireHnswLib({ reason: 'hnswlib-node not available; skipping hnsw-ann test.' }); + +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'main.js'), + 'export function indexItem(value) { return value + 1; }\n', + 'utf8' +); +await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# HNSW Fixture\n\nThis fixture provides prose chunks for ANN backend validation.\n', + 'utf8' +); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + embeddings: { + hnsw: { + enabled: true, + isolate: false + } + }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +function run(args, label) { + runNode(args, label, repoRoot, env, { stdio: 'inherit' }); +} + +run([path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--stage', 'stage1', '--repo', repoRoot], 'build index'); +run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'build embeddings (code)'); +run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'prose', '--repo', repoRoot], 'build embeddings (prose)'); + +const userConfig = loadUserConfig(repoRoot); +const hnswConfig = normalizeHnswConfig(userConfig.indexing?.embeddings?.hnsw || {}); +const codeDir = getIndexDir(repoRoot, 'code', userConfig); +const proseDir = getIndexDir(repoRoot, 'prose', userConfig); +const codeIndex = path.join(codeDir, 'dense_vectors_hnsw.bin'); +const codeMeta = path.join(codeDir, 'dense_vectors_hnsw.meta.json'); +const codeDocIndex = path.join(codeDir, 'dense_vectors_doc_hnsw.bin'); +const codeDocMeta = path.join(codeDir, 'dense_vectors_doc_hnsw.meta.json'); +const codeCodeIndex = path.join(codeDir, 'dense_vectors_code_hnsw.bin'); +const codeCodeMeta = path.join(codeDir, 'dense_vectors_code_hnsw.meta.json'); +const proseIndex = path.join(proseDir, 'dense_vectors_hnsw.bin'); +const proseMeta = path.join(proseDir, 'dense_vectors_hnsw.meta.json'); +const proseDocIndex = path.join(proseDir, 'dense_vectors_doc_hnsw.bin'); +const proseDocMeta = path.join(proseDir, 'dense_vectors_doc_hnsw.meta.json'); +const proseCodeIndex = path.join(proseDir, 'dense_vectors_code_hnsw.bin'); +const proseCodeMeta = path.join(proseDir, 'dense_vectors_code_hnsw.meta.json'); + +if (!fs.existsSync(codeIndex) || !fs.existsSync(codeMeta)) { + console.error('HNSW index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(codeDocIndex) || !fs.existsSync(codeDocMeta)) { + console.error('HNSW doc index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(codeCodeIndex) || !fs.existsSync(codeCodeMeta)) { + console.error('HNSW code index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(proseIndex) || !fs.existsSync(proseMeta)) { + console.error('HNSW index missing for prose mode.'); + process.exit(1); +} +if (!fs.existsSync(proseDocIndex) || !fs.existsSync(proseDocMeta)) { + console.error('HNSW doc index missing for prose mode.'); + process.exit(1); +} +if (!fs.existsSync(proseCodeIndex) || !fs.existsSync(proseCodeMeta)) { + console.error('HNSW code index missing for prose mode.'); + process.exit(1); +} + +const codeState = JSON.parse(fs.readFileSync(path.join(codeDir, 'index_state.json'), 'utf8')); +const proseState = JSON.parse(fs.readFileSync(path.join(proseDir, 'index_state.json'), 'utf8')); +if (codeState?.embeddings?.embeddingIdentity?.normalize !== true) { + console.error('Expected code embeddingIdentity.normalize=true in index_state.json.'); + process.exit(1); +} +if (proseState?.embeddings?.embeddingIdentity?.normalize !== true) { + console.error('Expected prose embeddingIdentity.normalize=true in index_state.json.'); + process.exit(1); +} + +const codeMetaPayload = JSON.parse(fs.readFileSync(codeMeta, 'utf8')); +const proseMetaPayload = JSON.parse(fs.readFileSync(proseMeta, 'utf8')); +if (codeMetaPayload.space !== hnswConfig.space) { + console.error(`Expected HNSW code space=${hnswConfig.space}, got ${codeMetaPayload.space}`); + process.exit(1); +} +if (proseMetaPayload.space !== hnswConfig.space) { + console.error(`Expected HNSW prose space=${hnswConfig.space}, got ${proseMetaPayload.space}`); + process.exit(1); +} + +const searchResult = runNode( + [ + path.join(root, 'search.js'), + 'index', + '--backend', + 'memory', + '--json', + '--stats', + '--ann', + '--ann-backend', + 'hnsw', + '--repo', + repoRoot + ], + 'hnsw ann search', + repoRoot, + env, + { stdio: 'pipe' } +); + +const payload = JSON.parse(searchResult.stdout || '{}'); +const stats = payload.stats || {}; +if (stats.annBackend !== 'hnsw') { + console.error(`Expected annBackend=hnsw, got ${stats.annBackend}`); + process.exit(1); +} +if (!stats.annHnsw?.available?.code || !stats.annHnsw?.available?.prose) { + console.error('Expected HNSW availability for code and prose.'); + process.exit(1); +} + +console.log('HNSW ANN test passed'); + diff --git a/tests/retrieval/ann/lancedb-ann.test.js b/tests/retrieval/ann/lancedb-ann.test.js deleted file mode 100644 index b5ea7ff5f..000000000 --- a/tests/retrieval/ann/lancedb-ann.test.js +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { normalizeLanceDbConfig } from '../../../src/shared/lancedb.js'; -import { requireLanceDb } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'lancedb-ann'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await requireLanceDb({ reason: 'lancedb not available; skipping lancedb-ann test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot: cacheRoot, - embeddings: 'stub' -}); - -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], 'build index'); - -const userConfig = loadUserConfig(repoRoot); -const lanceConfig = normalizeLanceDbConfig(userConfig.indexing?.embeddings?.lancedb || {}); -const codeDir = getIndexDir(repoRoot, 'code', userConfig); -const proseDir = getIndexDir(repoRoot, 'prose', userConfig); -const codeDb = path.join(codeDir, 'dense_vectors.lancedb'); -const codeDocDb = path.join(codeDir, 'dense_vectors_doc.lancedb'); -const codeCodeDb = path.join(codeDir, 'dense_vectors_code.lancedb'); -const proseDb = path.join(proseDir, 'dense_vectors.lancedb'); -const proseDocDb = path.join(proseDir, 'dense_vectors_doc.lancedb'); -const proseCodeDb = path.join(proseDir, 'dense_vectors_code.lancedb'); -const codeMeta = path.join(codeDir, 'dense_vectors.lancedb.meta.json'); -const codeDocMeta = path.join(codeDir, 'dense_vectors_doc.lancedb.meta.json'); -const codeCodeMeta = path.join(codeDir, 'dense_vectors_code.lancedb.meta.json'); -const proseMeta = path.join(proseDir, 'dense_vectors.lancedb.meta.json'); -const proseDocMeta = path.join(proseDir, 'dense_vectors_doc.lancedb.meta.json'); -const proseCodeMeta = path.join(proseDir, 'dense_vectors_code.lancedb.meta.json'); - -if (!fs.existsSync(codeDb) || !fs.existsSync(codeMeta)) { - console.error('LanceDB index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(codeDocDb) || !fs.existsSync(codeDocMeta)) { - console.error('LanceDB doc index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(codeCodeDb) || !fs.existsSync(codeCodeMeta)) { - console.error('LanceDB code index missing for code mode.'); - process.exit(1); -} -if (!fs.existsSync(proseDb) || !fs.existsSync(proseMeta)) { - console.error('LanceDB index missing for prose mode.'); - process.exit(1); -} -if (!fs.existsSync(proseDocDb) || !fs.existsSync(proseDocMeta)) { - console.error('LanceDB doc index missing for prose mode.'); - process.exit(1); -} -if (!fs.existsSync(proseCodeDb) || !fs.existsSync(proseCodeMeta)) { - console.error('LanceDB code index missing for prose mode.'); - process.exit(1); -} - -const codeState = JSON.parse(fs.readFileSync(path.join(codeDir, 'index_state.json'), 'utf8')); -const proseState = JSON.parse(fs.readFileSync(path.join(proseDir, 'index_state.json'), 'utf8')); -if (codeState?.embeddings?.embeddingIdentity?.normalize !== true) { - console.error('Expected code embeddingIdentity.normalize=true in index_state.json.'); - process.exit(1); -} -if (proseState?.embeddings?.embeddingIdentity?.normalize !== true) { - console.error('Expected prose embeddingIdentity.normalize=true in index_state.json.'); - process.exit(1); -} - -const codeMetaPayload = JSON.parse(fs.readFileSync(codeMeta, 'utf8')); -const codeDocMetaPayload = JSON.parse(fs.readFileSync(codeDocMeta, 'utf8')); -const codeCodeMetaPayload = JSON.parse(fs.readFileSync(codeCodeMeta, 'utf8')); -const proseMetaPayload = JSON.parse(fs.readFileSync(proseMeta, 'utf8')); -const proseDocMetaPayload = JSON.parse(fs.readFileSync(proseDocMeta, 'utf8')); -const proseCodeMetaPayload = JSON.parse(fs.readFileSync(proseCodeMeta, 'utf8')); -if (codeMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB code metric=${lanceConfig.metric}, got ${codeMetaPayload.metric}`); - process.exit(1); -} -if (codeDocMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB code/doc metric=${lanceConfig.metric}, got ${codeDocMetaPayload.metric}`); - process.exit(1); -} -if (codeCodeMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB code/code metric=${lanceConfig.metric}, got ${codeCodeMetaPayload.metric}`); - process.exit(1); -} -if (proseMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB prose metric=${lanceConfig.metric}, got ${proseMetaPayload.metric}`); - process.exit(1); -} -if (proseDocMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB prose/doc metric=${lanceConfig.metric}, got ${proseDocMetaPayload.metric}`); - process.exit(1); -} -if (proseCodeMetaPayload.metric !== lanceConfig.metric) { - console.error(`Expected LanceDB prose/code metric=${lanceConfig.metric}, got ${proseCodeMetaPayload.metric}`); - process.exit(1); -} - -const searchResult = spawnSync( - process.execPath, - [ - path.join(root, 'search.js'), - 'index', - '--backend', - 'memory', - '--json', - '--stats', - '--ann', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('search.js failed for LanceDB ANN test.'); - if (searchResult.stderr) console.error(searchResult.stderr.trim()); - process.exit(searchResult.status ?? 1); -} - -const payload = JSON.parse(searchResult.stdout || '{}'); -const stats = payload.stats || {}; -if (stats.annBackend !== 'lancedb') { - console.error(`Expected annBackend=lancedb, got ${stats.annBackend}`); - process.exit(1); -} -if (!stats.annLance?.available?.code || !stats.annLance?.available?.prose) { - console.error('Expected LanceDB availability for code and prose.'); - process.exit(1); -} - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('LanceDB ANN test passed'); - diff --git a/tests/retrieval/ann/lancedb-candidate-filtering.test.js b/tests/retrieval/ann/lancedb-candidate-filtering.test.js deleted file mode 100644 index f4aee9f95..000000000 --- a/tests/retrieval/ann/lancedb-candidate-filtering.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { rankLanceDb } from '../../../src/retrieval/lancedb.js'; -import { requireLanceDb } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lancedb-candidate-filtering'); - -await requireLanceDb({ reason: 'lancedb not available; skipping lancedb candidate filtering test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const module = await import('@lancedb/lancedb'); -const lancedb = module?.default || module; -const db = await lancedb.connect(tempRoot); -const rows = Array.from({ length: 50 }, (_, i) => ({ - id: i, - vector: [i, 0, 0] -})); -await db.createTable('vectors', rows, { mode: 'overwrite' }); - -const candidateSet = new Set([25]); -for (let i = 1000; i < 1600; i += 1) { - candidateSet.add(i); -} - -const hits = await rankLanceDb({ - lancedbInfo: { - available: true, - dir: tempRoot, - meta: { - table: 'vectors', - idColumn: 'id', - embeddingColumn: 'vector', - metric: 'l2', - dims: 3 - } - }, - queryEmbedding: [0, 0, 0], - topN: 1, - candidateSet, - config: {} -}); - -assert.equal(hits.length, 1); -assert.equal(hits[0].idx, 25); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('lancedb candidate filtering test passed'); diff --git a/tests/retrieval/ann/lancedb-connection-cache.test.js b/tests/retrieval/ann/lancedb-connection-cache.test.js deleted file mode 100644 index f9318aa21..000000000 --- a/tests/retrieval/ann/lancedb-connection-cache.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { rankLanceDb } from '../../../src/retrieval/lancedb.js'; -import { requireLanceDb } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lancedb-connection-cache'); - -await requireLanceDb({ reason: 'lancedb not available; skipping lancedb connection cache test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const module = await import('@lancedb/lancedb'); -const lancedb = module?.default || module; -const db = await lancedb.connect(tempRoot); -const rows = Array.from({ length: 10 }, (_, i) => ({ - id: i, - vector: [i, 0, 0] -})); -await db.createTable('vectors', rows, { mode: 'overwrite' }); - -const lancedbInfo = { - available: true, - dir: tempRoot, - meta: { - table: 'vectors', - idColumn: 'id', - embeddingColumn: 'vector', - metric: 'l2', - dims: 3 - } -}; - -const tasks = Array.from({ length: 4 }, () => rankLanceDb({ - lancedbInfo, - queryEmbedding: [0, 0, 0], - topN: 3, - candidateSet: null, - config: {} -})); - -const results = await Promise.all(tasks); -for (const hits of results) { - assert.ok(Array.isArray(hits)); - assert.ok(hits.length > 0); -} - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('lancedb connection cache test passed'); diff --git a/tests/retrieval/ann/lancedb-filter-pushdown.test.js b/tests/retrieval/ann/lancedb-filter-pushdown.test.js deleted file mode 100644 index f99a84ef6..000000000 --- a/tests/retrieval/ann/lancedb-filter-pushdown.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { rankLanceDb } from '../../../src/retrieval/lancedb.js'; -import { requireLanceDb } from '../../helpers/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lancedb-filter-pushdown'); - -await requireLanceDb({ reason: 'lancedb not available; skipping lancedb filter pushdown test.' }); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const module = await import('@lancedb/lancedb'); -const lancedb = module?.default || module; -const db = await lancedb.connect(tempRoot); -const rows = Array.from({ length: 15 }, (_, i) => ({ - id: i, - vector: [i, 0, 0] -})); -await db.createTable('vectors', rows, { mode: 'overwrite' }); - -const candidateSet = new Set([2, 4, 6]); -const hits = await rankLanceDb({ - lancedbInfo: { - available: true, - dir: tempRoot, - meta: { - table: 'vectors', - idColumn: 'id', - embeddingColumn: 'vector', - metric: 'l2', - dims: 3 - } - }, - queryEmbedding: [0, 0, 0], - topN: 3, - candidateSet, - config: {} -}); - -assert.deepEqual(hits.map((hit) => hit.idx), [2, 4, 6]); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('lancedb filter pushdown test passed'); diff --git a/tests/retrieval/ann/lancedb-runtime-contract-matrix.test.js b/tests/retrieval/ann/lancedb-runtime-contract-matrix.test.js new file mode 100644 index 000000000..430d71d90 --- /dev/null +++ b/tests/retrieval/ann/lancedb-runtime-contract-matrix.test.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { rankLanceDb } from '../../../src/retrieval/lancedb.js'; +import { requireLanceDb } from '../../helpers/optional-deps.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const buildDb = async (suffix, count) => { + const tempRoot = resolveTestCachePath(root, suffix); + await requireLanceDb({ reason: 'lancedb not available; skipping LanceDB runtime matrix.' }); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + const module = await import('@lancedb/lancedb'); + const lancedb = module?.default || module; + const db = await lancedb.connect(tempRoot); + const rows = Array.from({ length: count }, (_, i) => ({ + id: i, + vector: [i, 0, 0] + })); + await db.createTable('vectors', rows, { mode: 'overwrite' }); + return { + tempRoot, + lancedbInfo: { + available: true, + dir: tempRoot, + meta: { + table: 'vectors', + idColumn: 'id', + embeddingColumn: 'vector', + metric: 'l2', + dims: 3 + } + } + }; +}; + +const cases = [ + { + name: 'candidate filtering preserves only allowed ids', + async run() { + const fixture = await buildDb('lancedb-runtime-matrix-candidate-filtering', 50); + try { + const candidateSet = new Set([25]); + for (let i = 1000; i < 1600; i += 1) candidateSet.add(i); + + const hits = await rankLanceDb({ + lancedbInfo: fixture.lancedbInfo, + queryEmbedding: [0, 0, 0], + topN: 1, + candidateSet, + config: {} + }); + + assert.equal(hits.length, 1); + assert.equal(hits[0].idx, 25); + } finally { + await fsPromises.rm(fixture.tempRoot, { recursive: true, force: true }); + } + } + }, + { + name: 'connection cache tolerates concurrent query reuse', + async run() { + const fixture = await buildDb('lancedb-runtime-matrix-connection-cache', 10); + try { + const tasks = Array.from({ length: 4 }, () => rankLanceDb({ + lancedbInfo: fixture.lancedbInfo, + queryEmbedding: [0, 0, 0], + topN: 3, + candidateSet: null, + config: {} + })); + const results = await Promise.all(tasks); + for (const hits of results) { + assert.ok(Array.isArray(hits)); + assert.ok(hits.length > 0); + } + } finally { + await fsPromises.rm(fixture.tempRoot, { recursive: true, force: true }); + } + } + }, + { + name: 'filter pushdown respects ordered candidate sets', + async run() { + const fixture = await buildDb('lancedb-runtime-matrix-filter-pushdown', 15); + try { + const hits = await rankLanceDb({ + lancedbInfo: fixture.lancedbInfo, + queryEmbedding: [0, 0, 0], + topN: 3, + candidateSet: new Set([2, 4, 6]), + config: {} + }); + assert.deepEqual(hits.map((hit) => hit.idx), [2, 4, 6]); + } finally { + await fsPromises.rm(fixture.tempRoot, { recursive: true, force: true }); + } + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('lancedb runtime contract matrix test passed'); diff --git a/tests/retrieval/ann/lancedb.test.js b/tests/retrieval/ann/lancedb.test.js new file mode 100644 index 000000000..21394f8e8 --- /dev/null +++ b/tests/retrieval/ann/lancedb.test.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { normalizeLanceDbConfig } from '../../../src/shared/lancedb.js'; +import { requireLanceDb } from '../../helpers/optional-deps.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('lancedb-ann', { root }); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await requireLanceDb({ reason: 'lancedb not available; skipping lancedb-ann test.' }); + +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'main.js'), + 'export function indexItem(value) { return value + 1; }\n', + 'utf8' +); +await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# LanceDB Fixture\n\nThis fixture provides prose chunks for ANN backend validation.\n', + 'utf8' +); + +const env = applyTestEnv({ + cacheRoot: cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + embeddings: { + lancedb: { + enabled: true, + isolate: false + } + }, + typeInference: false, + typeInferenceCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +const run = (args, label) => { + runNode(args, label, repoRoot, env, { stdio: 'inherit' }); +}; + +run( + [path.join(root, 'build_index.js'), '--stub-embeddings', '--scm-provider', 'none', '--stage', 'stage1', '--repo', repoRoot], + 'build index' +); +run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'build embeddings (code)'); +run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'prose', '--repo', repoRoot], 'build embeddings (prose)'); + +const userConfig = loadUserConfig(repoRoot); +const lanceConfig = normalizeLanceDbConfig(userConfig.indexing?.embeddings?.lancedb || {}); +const codeDir = getIndexDir(repoRoot, 'code', userConfig); +const proseDir = getIndexDir(repoRoot, 'prose', userConfig); +const codeDb = path.join(codeDir, 'dense_vectors.lancedb'); +const codeDocDb = path.join(codeDir, 'dense_vectors_doc.lancedb'); +const codeCodeDb = path.join(codeDir, 'dense_vectors_code.lancedb'); +const proseDb = path.join(proseDir, 'dense_vectors.lancedb'); +const proseDocDb = path.join(proseDir, 'dense_vectors_doc.lancedb'); +const proseCodeDb = path.join(proseDir, 'dense_vectors_code.lancedb'); +const codeMeta = path.join(codeDir, 'dense_vectors.lancedb.meta.json'); +const codeDocMeta = path.join(codeDir, 'dense_vectors_doc.lancedb.meta.json'); +const codeCodeMeta = path.join(codeDir, 'dense_vectors_code.lancedb.meta.json'); +const proseMeta = path.join(proseDir, 'dense_vectors.lancedb.meta.json'); +const proseDocMeta = path.join(proseDir, 'dense_vectors_doc.lancedb.meta.json'); +const proseCodeMeta = path.join(proseDir, 'dense_vectors_code.lancedb.meta.json'); + +if (!fs.existsSync(codeDb) || !fs.existsSync(codeMeta)) { + console.error('LanceDB index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(codeDocDb) || !fs.existsSync(codeDocMeta)) { + console.error('LanceDB doc index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(codeCodeDb) || !fs.existsSync(codeCodeMeta)) { + console.error('LanceDB code index missing for code mode.'); + process.exit(1); +} +if (!fs.existsSync(proseDb) || !fs.existsSync(proseMeta)) { + console.error('LanceDB index missing for prose mode.'); + process.exit(1); +} +if (!fs.existsSync(proseDocDb) || !fs.existsSync(proseDocMeta)) { + console.error('LanceDB doc index missing for prose mode.'); + process.exit(1); +} +if (!fs.existsSync(proseCodeDb) || !fs.existsSync(proseCodeMeta)) { + console.error('LanceDB code index missing for prose mode.'); + process.exit(1); +} + +const codeState = JSON.parse(fs.readFileSync(path.join(codeDir, 'index_state.json'), 'utf8')); +const proseState = JSON.parse(fs.readFileSync(path.join(proseDir, 'index_state.json'), 'utf8')); +if (codeState?.embeddings?.embeddingIdentity?.normalize !== true) { + console.error('Expected code embeddingIdentity.normalize=true in index_state.json.'); + process.exit(1); +} +if (proseState?.embeddings?.embeddingIdentity?.normalize !== true) { + console.error('Expected prose embeddingIdentity.normalize=true in index_state.json.'); + process.exit(1); +} + +const codeMetaPayload = JSON.parse(fs.readFileSync(codeMeta, 'utf8')); +const codeDocMetaPayload = JSON.parse(fs.readFileSync(codeDocMeta, 'utf8')); +const codeCodeMetaPayload = JSON.parse(fs.readFileSync(codeCodeMeta, 'utf8')); +const proseMetaPayload = JSON.parse(fs.readFileSync(proseMeta, 'utf8')); +const proseDocMetaPayload = JSON.parse(fs.readFileSync(proseDocMeta, 'utf8')); +const proseCodeMetaPayload = JSON.parse(fs.readFileSync(proseCodeMeta, 'utf8')); +if (codeMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB code metric=${lanceConfig.metric}, got ${codeMetaPayload.metric}`); + process.exit(1); +} +if (codeDocMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB code/doc metric=${lanceConfig.metric}, got ${codeDocMetaPayload.metric}`); + process.exit(1); +} +if (codeCodeMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB code/code metric=${lanceConfig.metric}, got ${codeCodeMetaPayload.metric}`); + process.exit(1); +} +if (proseMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB prose metric=${lanceConfig.metric}, got ${proseMetaPayload.metric}`); + process.exit(1); +} +if (proseDocMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB prose/doc metric=${lanceConfig.metric}, got ${proseDocMetaPayload.metric}`); + process.exit(1); +} +if (proseCodeMetaPayload.metric !== lanceConfig.metric) { + console.error(`Expected LanceDB prose/code metric=${lanceConfig.metric}, got ${proseCodeMetaPayload.metric}`); + process.exit(1); +} + +const searchResult = runNode( + [ + path.join(root, 'search.js'), + 'index', + '--backend', + 'memory', + '--json', + '--stats', + '--ann', + '--repo', + repoRoot + ], + 'lancedb ann search', + repoRoot, + env, + { stdio: 'pipe' } +); + +const payload = JSON.parse(searchResult.stdout || '{}'); +const stats = payload.stats || {}; +if (stats.annBackend !== 'lancedb') { + console.error(`Expected annBackend=lancedb, got ${stats.annBackend}`); + process.exit(1); +} +if (!stats.annLance?.available?.code || !stats.annLance?.available?.prose) { + console.error('Expected LanceDB availability for code and prose.'); + process.exit(1); +} + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +console.log('LanceDB ANN test passed'); + diff --git a/tests/retrieval/ann/parity.test.js b/tests/retrieval/ann/parity.test.js new file mode 100644 index 000000000..5db514290 --- /dev/null +++ b/tests/retrieval/ann/parity.test.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { requireHnswLib, requireLanceDb } from '../../helpers/optional-deps.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +await requireLanceDb({ reason: 'lancedb not available; skipping ann parity test.' }); +requireHnswLib({ reason: 'hnswlib-node not available; skipping ann parity test.' }); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'ann-parity'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.writeFile( + path.join(repoRoot, 'src', 'index.js'), + [ + 'export function searchIndex(items, needle) {', + ' return items.filter((item) => item.includes(needle));', + '}', + '', + 'export function rankIndexHit(score) {', + ' return score + 1;', + '}', + '', + 'export const indexToken = "index";', + '' + ].join('\n'), + 'utf8' +); + +const env = applyTestEnv({ + cacheRoot: cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + treeSitter: { enabled: false }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: true, + hnsw: { + enabled: true, + isolate: false + }, + lancedb: { + enabled: true + } + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +function runChildNode(args, label) { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +} + +runChildNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage2', + '--mode', + 'code', + '--scm-provider', + 'none', + '--repo', + repoRoot + ], + 'build index' +); +runChildNode( + [path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], + 'build embeddings (code)' +); + +function runSearch(backend) { + const result = runNode( + [ + path.join(root, 'search.js'), + 'index', + '--backend', + 'memory', + '--mode', + 'code', + '--ann', + '--ann-backend', + backend, + '--dense-vector-mode', + 'merged', + '--json', + '--stats', + '-n', + '3', + '--repo', + repoRoot + ], + `ANN parity search ${backend}`, + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + if (result.status !== 0) { + console.error(`Search failed for ANN backend=${backend}`); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); + } + return JSON.parse(result.stdout || '{}'); +} + +const densePayload = runSearch('dense'); +const hnswPayload = runSearch('hnsw'); +const lancePayload = runSearch('lancedb'); + +const expectedBackend = { + dense: 'js', + hnsw: 'hnsw', + lancedb: 'lancedb' +}; +const ensureBackend = (payload, backend, label) => { + const actual = payload?.stats?.annBackend; + if (actual !== backend) { + console.error(`Expected annBackend=${backend} for ${label}, got ${actual || 'unset'}`); + process.exit(1); + } +}; +ensureBackend(densePayload, expectedBackend.dense, 'dense'); +ensureBackend(hnswPayload, expectedBackend.hnsw, 'hnsw'); +ensureBackend(lancePayload, expectedBackend.lancedb, 'lancedb'); + +const hitKey = (hit, index) => { + if (hit && (hit.id || hit.id === 0)) return String(hit.id); + if (hit && hit.file) { + const start = hit.startLine ?? hit.start ?? 0; + const end = hit.endLine ?? hit.end ?? 0; + return `${hit.file}:${start}:${end}:${hit.kind || ''}:${hit.name || ''}`; + } + return String(index); +}; + +const topKeys = (payload, mode) => { + const hits = Array.isArray(payload?.[mode]) ? payload[mode] : []; + return hits.slice(0, 5).map((hit, index) => hitKey(hit, index)); +}; + +const compareHits = (baseKeys, otherKeys, label) => { + if (!baseKeys.length && !otherKeys.length) return; + if (!baseKeys.length || !otherKeys.length) { + console.error(`ANN parity failed for ${label}: one backend returned no hits.`); + process.exit(1); + } + const otherSet = new Set(otherKeys); + const overlap = baseKeys.filter((key) => otherSet.has(key)); + const overlapRatio = overlap.length / Math.min(baseKeys.length, otherKeys.length); + if (baseKeys[0] !== otherKeys[0]) { + console.error(`ANN parity failed for ${label}: top hit mismatch.`); + process.exit(1); + } + if (overlapRatio < 0.6) { + console.error(`ANN parity failed for ${label}: overlap ${overlapRatio.toFixed(2)} < 0.6.`); + process.exit(1); + } +}; + +for (const mode of ['code']) { + const baseKeys = topKeys(densePayload, mode); + compareHits(baseKeys, topKeys(hnswPayload, mode), `${mode} (dense vs hnsw)`); + compareHits(baseKeys, topKeys(lancePayload, mode), `${mode} (dense vs lancedb)`); +} + +console.log('ANN parity test passed'); diff --git a/tests/retrieval/ann/similarity-metric-contract.test.js b/tests/retrieval/ann/similarity-metric-contract.test.js deleted file mode 100644 index 4ae46d882..000000000 --- a/tests/retrieval/ann/similarity-metric-contract.test.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { distanceToSimilarity } from '../../../src/shared/ann-similarity.js'; -import { rankHnswIndex } from '../../../src/shared/hnsw.js'; - -assert.equal(distanceToSimilarity(0.25, 'cosine'), 0.75); -assert.equal(distanceToSimilarity(4, 'l2'), -4); -assert.equal(distanceToSimilarity(1.5, 'ip'), -1.5); -assert.equal(distanceToSimilarity(Number.NaN, 'l2'), null); - -const fakeIndex = { - getCurrentCount: () => 2, - searchKnn: () => ({ - neighbors: [7, 3], - distances: [0.2, 0.8] - }) -}; - -const cosineHits = rankHnswIndex({ index: fakeIndex, space: 'cosine' }, [0.1], 2, null); -assert.deepEqual(cosineHits, [{ idx: 7, sim: 0.8 }, { idx: 3, sim: 0.19999999999999996 }]); - -const ipHits = rankHnswIndex({ index: fakeIndex, space: 'ip' }, [0.1], 2, null); -assert.deepEqual(ipHits, [{ idx: 7, sim: -0.2 }, { idx: 3, sim: -0.8 }]); - -console.log('similarity metric contract test passed'); diff --git a/tests/retrieval/ann/utility-contract-matrix.test.js b/tests/retrieval/ann/utility-contract-matrix.test.js new file mode 100644 index 000000000..9acb44c9f --- /dev/null +++ b/tests/retrieval/ann/utility-contract-matrix.test.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { resolveDenseVector } from '../../../src/retrieval/cli/index-loader.js'; +import { normalizeEmbeddingDims } from '../../../src/retrieval/ann/dims.js'; +import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { createDenseAnnProvider } from '../../../src/retrieval/ann/providers/dense.js'; +import { resolveIntentVectorMode } from '../../../src/retrieval/query-intent.js'; +import { rankHnswIndex } from '../../../src/shared/hnsw.js'; +import { distanceToSimilarity } from '../../../src/shared/ann-similarity.js'; +import { canRunAnnQuery, isCandidateSetEmpty, isEmbeddingReady } from '../../../src/retrieval/ann/utils.js'; +import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; + +const cases = [ + { + name: 'embedding readiness and candidate-set emptiness guards stay stable', + run() { + assert.equal(isEmbeddingReady([0.1]), true); + assert.equal(isEmbeddingReady(new Float32Array([0.1, 0.2])), true); + assert.equal(isEmbeddingReady([]), false); + assert.equal(isEmbeddingReady(null), false); + + assert.equal(isCandidateSetEmpty(null), false); + assert.equal(isCandidateSetEmpty(new Set()), true); + assert.equal(isCandidateSetEmpty(new Set([1])), false); + assert.equal(isCandidateSetEmpty({ size: () => 0 }), true); + assert.equal(isCandidateSetEmpty({ size: () => 2 }), false); + assert.equal(isCandidateSetEmpty({ getSize: () => 0 }), true); + assert.equal(isCandidateSetEmpty([]), true); + assert.equal(isCandidateSetEmpty([1]), false); + + const embedding = [0.1, 0.2]; + assert.equal(canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: true, enabled: true }), true); + assert.equal(canRunAnnQuery({ signal: { aborted: true }, embedding, candidateSet: null, backendReady: true, enabled: true }), false); + assert.equal(canRunAnnQuery({ signal: null, embedding, candidateSet: new Set(), backendReady: true, enabled: true }), false); + assert.equal(canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: false, enabled: true }), false); + assert.equal(canRunAnnQuery({ signal: null, embedding, candidateSet: null, backendReady: true, enabled: false }), false); + } + }, + { + name: 'dense vector mode and dimension normalization choose the right representation', + run() { + const idx = { + denseVec: { label: 'merged' }, + denseVecDoc: { label: 'doc' }, + denseVecCode: { label: 'code' } + }; + assert.equal(resolveDenseVector(idx, 'code', 'code')?.label, 'code'); + assert.equal(resolveDenseVector(idx, 'prose', 'doc')?.label, 'doc'); + assert.equal(resolveDenseVector(idx, 'code', 'merged')?.label, 'merged'); + assert.equal(resolveDenseVector(idx, 'code', 'auto')?.label, 'code'); + assert.equal(resolveDenseVector(idx, 'prose', 'auto')?.label, 'doc'); + + const fallbackIdx = { denseVec: { label: 'merged' } }; + assert.equal(resolveDenseVector(fallbackIdx, 'code', 'code')?.label, 'merged'); + assert.equal(resolveDenseVector(fallbackIdx, 'prose', 'doc')?.label, 'merged'); + + assert.equal(resolveIntentVectorMode('auto', { vectorMode: 'doc' }), 'doc'); + assert.equal(resolveIntentVectorMode('auto', { vectorMode: null }), 'auto'); + assert.equal(resolveIntentVectorMode('code', { vectorMode: 'doc' }), 'code'); + + const clipped = normalizeEmbeddingDims([1, 2, 3, 4], 2); + assert.equal(clipped.adjusted, true); + assert.equal(clipped.queryDims, 4); + assert.equal(clipped.expectedDims, 2); + assert.deepEqual(clipped.embedding, [1, 2]); + + const padded = normalizeEmbeddingDims(new Float32Array([3, 4]), 4); + assert.equal(padded.adjusted, true); + assert.equal(padded.queryDims, 2); + assert.equal(padded.expectedDims, 4); + assert.deepEqual(padded.embedding, [3, 4, 0, 0]); + + const unchanged = normalizeEmbeddingDims([7, 8, 9], 3); + assert.equal(unchanged.adjusted, false); + assert.deepEqual(unchanged.embedding, [7, 8, 9]); + } + }, + { + name: 'similarity conversion and HNSW ranking preserve score ordering by metric', + run() { + assert.equal(distanceToSimilarity(0.25, 'cosine'), 0.75); + assert.equal(distanceToSimilarity(4, 'l2'), -4); + assert.equal(distanceToSimilarity(1.5, 'ip'), -1.5); + assert.equal(distanceToSimilarity(Number.NaN, 'l2'), null); + + const fakeIndex = { + getCurrentCount: () => 2, + searchKnn: () => ({ + neighbors: [7, 3], + distances: [0.2, 0.8] + }) + }; + + const cosineHits = rankHnswIndex({ index: fakeIndex, space: 'cosine' }, [0.1], 2, null); + assert.deepEqual(cosineHits, [{ idx: 7, sim: 0.8 }, { idx: 3, sim: 0.19999999999999996 }]); + + const ipHits = rankHnswIndex({ index: fakeIndex, space: 'ip' }, [0.1], 2, null); + assert.deepEqual(ipHits, [{ idx: 7, sim: -0.2 }, { idx: 3, sim: -0.8 }]); + } + }, + { + name: 'dense ANN providers lazy-load vectors only once across repeated pipeline runs', + async run() { + const { context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([ + [ANN_PROVIDER_IDS.DENSE, createDenseAnnProvider()] + ]) + }); + context.annBackend = 'dense'; + idx.denseVec = { dims: 2, minVal: -1, maxVal: 1, levels: 256, scale: 1, vectors: null }; + + let loadCalls = 0; + idx.loadDenseVectors = async () => { + loadCalls += 1; + idx.denseVec = { + dims: 2, + minVal: -1, + maxVal: 1, + levels: 256, + scale: 1, + vectors: [ + [0.1, 0.2], + [0.2, 0.1] + ] + }; + return idx.denseVec; + }; + + const pipeline = createSearchPipeline(context); + const run1 = await pipeline(idx, 'code', [0.1, 0.2]); + const run2 = await pipeline(idx, 'code', [0.1, 0.2]); + + assert.ok(run1.length > 0); + assert.ok(run2.length > 0); + assert.equal(loadCalls, 1); + assert.ok(run1.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE)); + assert.ok(run2.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE)); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('ann utility contract matrix test passed'); diff --git a/tests/retrieval/backend/backend-contract-matrix.test.js b/tests/retrieval/backend/backend-contract-matrix.test.js new file mode 100644 index 000000000..f2d8068ac --- /dev/null +++ b/tests/retrieval/backend/backend-contract-matrix.test.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { evaluateAutoSqliteThresholds } from '../../../src/retrieval/cli/auto-sqlite.js'; +import { resolveBackendSelection } from '../../../src/retrieval/cli/policy.js'; +import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const backendMatrixTestConfig = { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } +}; + +const runNodeScript = (env, args, label, options = {}) => { + const result = runNode(args, label, root, env, { stdio: 'pipe', ...options }); + return result.stdout || ''; +}; + +const baseBackendSelection = { + sqliteScoreModeConfig: false, + sqliteConfigured: true, + sqliteAvailable: true, + sqliteCodeAvailable: true, + sqliteProseAvailable: true, + sqliteCodePath: 'code.db', + sqliteProsePath: 'prose.db', + lmdbConfigured: true, + lmdbAvailable: true, + lmdbCodeAvailable: true, + lmdbProseAvailable: true, + lmdbCodePath: 'lmdb-code', + lmdbProsePath: 'lmdb-prose', + sqliteAutoChunkThreshold: 0, + sqliteAutoArtifactBytes: 0, + needsSqlite: true, + needsCode: true, + needsProse: false, + root: process.cwd(), + userConfig: {} +}; + +const cases = [ + { + name: 'auto sqlite thresholds reject missing stats and allow satisfied thresholds', + run() { + const disabled = evaluateAutoSqliteThresholds({ + stats: [{ chunkCount: null, artifactBytes: null }], + chunkThreshold: 0, + artifactThreshold: 0 + }); + assert.equal(disabled.allowed, true); + + const missingChunks = evaluateAutoSqliteThresholds({ + stats: [{ chunkCount: null, artifactBytes: 1200 }], + chunkThreshold: 10, + artifactThreshold: 0 + }); + assert.equal(missingChunks.allowed, false); + assert.match(missingChunks.reason || '', /chunk stats are unavailable/i); + + const missingBytes = evaluateAutoSqliteThresholds({ + stats: [{ chunkCount: 12, artifactBytes: null }], + chunkThreshold: 0, + artifactThreshold: 5000 + }); + assert.equal(missingBytes.allowed, false); + assert.match(missingBytes.reason || '', /artifact bytes are unavailable/i); + + const tooSmall = evaluateAutoSqliteThresholds({ + stats: [{ chunkCount: 5, artifactBytes: 100 }], + chunkThreshold: 10, + artifactThreshold: 1000 + }); + assert.equal(tooSmall.allowed, false); + assert.match(tooSmall.reason || '', /auto sqlite thresholds not met/i); + + const meetsBytes = evaluateAutoSqliteThresholds({ + stats: [{ chunkCount: 5, artifactBytes: 1500 }], + chunkThreshold: 0, + artifactThreshold: 1000 + }); + assert.equal(meetsBytes.allowed, true); + } + }, + { + name: 'backend policy selects sqlite, lmdb fallback, and forced modes consistently', + async run() { + const autoResult = await resolveBackendSelection({ + ...baseBackendSelection, + backendArg: '' + }); + assert.equal(autoResult.useSqlite, true); + assert.equal(autoResult.useLmdb, false); + + const lmdbFallback = await resolveBackendSelection({ + ...baseBackendSelection, + backendArg: '', + sqliteAvailable: false, + sqliteCodeAvailable: false, + lmdbAvailable: true + }); + assert.equal(lmdbFallback.useSqlite, false); + assert.equal(lmdbFallback.useLmdb, true); + + const forcedSqlite = await resolveBackendSelection({ + ...baseBackendSelection, + backendArg: 'sqlite', + sqliteAvailable: false, + sqliteCodeAvailable: false + }); + assert.ok(forcedSqlite.error); + assert.match(forcedSqlite.error.message, /SQLite backend requested/); + assert.match(forcedSqlite.error.message, /code=code\.db/); + + const forcedLmdb = await resolveBackendSelection({ + ...baseBackendSelection, + backendArg: 'lmdb', + lmdbAvailable: false, + lmdbCodeAvailable: false + }); + assert.ok(forcedLmdb.error); + assert.match(forcedLmdb.error.message, /LMDB backend requested/); + assert.match(forcedLmdb.error.message, /code=lmdb-code/); + + const forcedTantivy = await resolveBackendSelection({ + ...baseBackendSelection, + backendArg: 'tantivy' + }); + assert.equal(forcedTantivy.useSqlite, false); + assert.equal(forcedTantivy.useLmdb, false); + assert.equal(forcedTantivy.backendPolicy.backendLabel, 'tantivy'); + assert.equal(forcedTantivy.backendForcedTantivy, true); + } + }, + { + name: 'sqlite fts remains eligible when only internal filters are active', + async run() { + let sqliteCalls = 0; + const rankSqliteFts = () => { + sqliteCalls += 1; + return [{ idx: 0, score: 1 }]; + }; + const emptyAnnState = { + code: { available: false }, + prose: { available: false }, + records: { available: false }, + 'extracted-prose': { available: false } + }; + const emptyAnnUsed = { + code: false, + prose: false, + records: false, + 'extracted-prose': false + }; + + const pipeline = createSearchPipeline({ + useSqlite: true, + sqliteFtsRequested: true, + sqliteFtsNormalize: false, + sqliteFtsProfile: null, + sqliteFtsWeights: [], + bm25K1: 1.2, + bm25B: 0.75, + fieldWeights: null, + postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, + queryTokens: ['hello'], + queryAst: null, + phraseNgramSet: null, + phraseRange: null, + explain: false, + symbolBoost: null, + filters: { filePrefilter: { enabled: true } }, + filtersActive: undefined, + topN: 5, + annEnabled: false, + annBackend: 'auto', + scoreBlend: null, + minhashMaxDocs: null, + sparseBackend: 'auto', + vectorAnnState: emptyAnnState, + vectorAnnUsed: emptyAnnUsed, + hnswAnnState: emptyAnnState, + hnswAnnUsed: emptyAnnUsed, + lanceAnnState: emptyAnnState, + lanceAnnUsed: emptyAnnUsed, + lancedbConfig: {}, + buildCandidateSetSqlite: () => new Set(), + getTokenIndexForQuery: () => null, + rankSqliteFts, + rankVectorAnnSqlite: () => [], + sqliteHasFts: () => true, + signal: null, + rrf: { enabled: false } + }); + + const hits = await pipeline({ + chunkMeta: [{ id: 0, file: 'foo.js', tokens: [] }], + fileRelations: null, + filterIndex: null, + phraseNgrams: null, + minhash: null, + denseVec: null + }, 'code', null); + + assert.equal(sqliteCalls, 1); + assert.equal(hits.length, 1); + assert.equal(hits[0].file, 'foo.js'); + } + }, + { + name: 'strict and non-strict searches both fail once manifest embeddings are missing after cutover', + async run() { + const tempRoot = resolveTestCachePath(root, 'retrieval-backend-contract-matrix'); + const fixtureRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: backendMatrixTestConfig + }); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(path.join(fixtureRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile( + path.join(fixtureRoot, 'src', 'token.js'), + [ + 'export function tokenMarker() {', + ' return "token manifest backend";', + '}', + '' + ].join('\n') + ); + + runNodeScript(env, [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--repo', + fixtureRoot, + '--stage', + 'stage1', + '--mode', + 'code' + ], 'build index'); + runNodeScript(env, [ + path.join(root, 'tools', 'build', 'embeddings.js'), + '--stub-embeddings', + '--repo', + fixtureRoot, + '--mode', + 'code' + ], 'build embeddings'); + + const userConfig = loadUserConfig(fixtureRoot); + const codeDir = getIndexDir(fixtureRoot, 'code', userConfig); + const manifestPath = path.join(codeDir, 'pieces', 'manifest.json'); + await fsPromises.rm(manifestPath, { force: true }); + await fsPromises.rm(`${manifestPath}.bak`, { force: true }); + + const searchArgs = [ + path.join(root, 'search.js'), + 'token', + '--mode', + 'code', + '--backend', + 'memory', + '--json', + '--repo', + fixtureRoot + ]; + + const strictResult = runNode(searchArgs, 'strict search missing manifest', root, env, { + stdio: 'pipe', + allowFailure: true + }); + assert.notEqual(strictResult.status, 0); + const strictMessage = (() => { + try { return JSON.parse(strictResult.stdout || '').message || ''; } catch { return strictResult.stdout || strictResult.stderr || ''; } + })(); + assert.match(String(strictMessage), /manifest/i); + + const nonStrictResult = runNode( + [...searchArgs, '--non-strict'], + 'non-strict search missing manifest', + root, + env, + { + stdio: 'pipe', + allowFailure: true + } + ); + assert.notEqual(nonStrictResult.status, 0); + const nonStrictMessage = (() => { + try { return JSON.parse(nonStrictResult.stdout || '').message || ''; } catch { return nonStrictResult.stdout || nonStrictResult.stderr || ''; } + })(); + assert.match(String(nonStrictMessage), /manifest/i); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('backend contract matrix test passed'); diff --git a/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback-filtered.test.js b/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback-filtered.test.js index 95ed091c2..ba2408180 100644 --- a/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback-filtered.test.js +++ b/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback-filtered.test.js @@ -1,81 +1,20 @@ #!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { ensureFixtureIndex, ensureFixtureSqlite } from '../../helpers/fixture-index.js'; -import { runSearchCli } from '../../../src/retrieval/cli.js'; -applyTestEnv(); +import { + assertSparsePreflightFallback, + prepareSparsePreflightFallbackCase +} from './sparse-preflight-fallback-helper.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('cli sqlite sparse preflight allow fallback filtered test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const { fixtureRoot, env, userConfig } = await ensureFixtureIndex({ - fixtureName: 'sample', +const baseArgs = await prepareSparsePreflightFallbackCase({ + label: 'cli sqlite sparse preflight allow fallback filtered test', cacheName: 'cli-sqlite-sparse-preflight-allow-fallback-filtered', - cacheScope: 'shared' + extraArgs: ['--ext', '.js'] }); -const sqlitePaths = await ensureFixtureSqlite({ fixtureRoot, userConfig, env }); - -const db = new Database(sqlitePaths.codePath); -for (const tableName of [ - 'token_vocab', - 'token_postings', - 'doc_lengths', - 'token_stats', - 'phrase_vocab', - 'phrase_postings', - 'chargram_vocab', - 'chargram_postings' -]) { - db.exec(`DROP TABLE IF EXISTS ${tableName}`); -} -db.close(); - -const baseArgs = [ - 'rust_greet', - '--repo', - fixtureRoot, - '--mode', - 'code', - '--backend', - 'sqlite-fts', - '--no-ann', - '--ext', - '.js', - '--stats', - '--json', - '--compact' -]; -let baseFailed = false; -try { - await runSearchCli(baseArgs, { emitOutput: false, exitOnError: false }); -} catch (err) { - baseFailed = true; - const message = String(err?.message || err); - assert.ok( - /retrieval_sparse_unavailable/i.test(message), - 'expected sparse-unavailable error without fallback override' - ); -} -const payload = await runSearchCli( - [...baseArgs, '--allow-sparse-fallback'], - { emitOutput: false, exitOnError: false } -); - -assert.equal(baseFailed, true, 'expected filtered sparse-only sqlite-fts run to fail without fallback override'); -assert.ok(Array.isArray(payload?.code), 'expected CLI search payload to include code hits'); -assert.equal(payload?.stats?.annEnabled, true, 'expected --allow-sparse-fallback to enable ANN preflight for filtered sqlite-fts route with missing BM25 tables'); -assert.equal( - Array.isArray(payload?.stats?.pipeline) - && payload.stats.pipeline.some((entry) => entry?.stage === 'startup.backend.reinit'), - true, - 'expected backend context reinit when sparse preflight forces ANN fallback' -); +await assertSparsePreflightFallback({ + baseArgs, + failMessage: 'expected filtered sparse-only sqlite-fts run to fail without fallback override', + annMessage: 'expected --allow-sparse-fallback to enable ANN preflight for filtered sqlite-fts route with missing BM25 tables' +}); console.log('cli sqlite sparse preflight allow fallback filtered test passed'); diff --git a/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback.test.js b/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback.test.js index 2ee087e57..287d508d7 100644 --- a/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback.test.js +++ b/tests/retrieval/backend/cli-sqlite-sparse-preflight-allow-fallback.test.js @@ -1,80 +1,20 @@ #!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { ensureFixtureIndex, ensureFixtureSqlite } from '../../helpers/fixture-index.js'; -import { runSearchCli } from '../../../src/retrieval/cli.js'; -applyTestEnv(); +import { + assertSparsePreflightFallback, + prepareSparsePreflightFallbackCase +} from './sparse-preflight-fallback-helper.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('cli sqlite sparse preflight allow fallback test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const { fixtureRoot, env, userConfig } = await ensureFixtureIndex({ - fixtureName: 'sample', +const baseArgs = await prepareSparsePreflightFallbackCase({ + label: 'cli sqlite sparse preflight allow fallback test', cacheName: 'cli-sqlite-sparse-preflight-allow-fallback', - cacheScope: 'shared' + extraArgs: [] }); -const sqlitePaths = await ensureFixtureSqlite({ fixtureRoot, userConfig, env }); - -const db = new Database(sqlitePaths.codePath); -for (const tableName of [ - 'token_vocab', - 'token_postings', - 'doc_lengths', - 'token_stats', - 'phrase_vocab', - 'phrase_postings', - 'chargram_vocab', - 'chargram_postings' -]) { - db.exec(`DROP TABLE IF EXISTS ${tableName}`); -} -db.close(); - -const baseArgs = [ - 'rust_greet', - '--repo', - fixtureRoot, - '--mode', - 'code', - '--backend', - 'sqlite-fts', - '--no-ann', - '--stats', - '--json', - '--compact' -]; -let baseFailed = false; -try { - await runSearchCli(baseArgs, { emitOutput: false, exitOnError: false }); -} catch (err) { - baseFailed = true; - const message = String(err?.message || err); - assert.ok( - /retrieval_sparse_unavailable/i.test(message), - 'expected sparse-unavailable error without fallback override' - ); -} - -const payload = await runSearchCli( - [...baseArgs, '--allow-sparse-fallback'], - { emitOutput: false, exitOnError: false } -); - -assert.equal(baseFailed, true, 'expected sparse-only sqlite-fts run to fail without fallback override'); -assert.ok(Array.isArray(payload?.code), 'expected CLI search payload to include code hits'); -assert.equal(payload?.stats?.annEnabled, true, 'expected --allow-sparse-fallback to enable ANN preflight when BM25 fallback tables are missing'); -assert.equal( - Array.isArray(payload?.stats?.pipeline) - && payload.stats.pipeline.some((entry) => entry?.stage === 'startup.backend.reinit'), - true, - 'expected backend context reinit when sparse preflight forces ANN fallback' -); +await assertSparsePreflightFallback({ + baseArgs, + failMessage: 'expected sparse-only sqlite-fts run to fail without fallback override', + annMessage: 'expected --allow-sparse-fallback to enable ANN preflight when BM25 fallback tables are missing' +}); console.log('cli sqlite sparse preflight allow fallback test passed'); diff --git a/tests/retrieval/backend/fts-missing-table-fallback.test.js b/tests/retrieval/backend/fts-missing-table-fallback.test.js index 7c30875d9..99933695e 100644 --- a/tests/retrieval/backend/fts-missing-table-fallback.test.js +++ b/tests/retrieval/backend/fts-missing-table-fallback.test.js @@ -1,24 +1,14 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; - -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); +import { + createAlphaSearchIndex, + createAlphaTokenIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; let sqliteCalls = 0; -const pipeline = createSearchPipeline({ +const pipeline = createSearchPipelineFixture({ useSqlite: true, sqliteFtsRequested: true, sqliteFtsRoutingByMode: resolveSqliteFtsRoutingByMode({ @@ -30,76 +20,19 @@ const pipeline = createSearchPipeline({ runExtractedProse: false, runRecords: false }), - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, buildCandidateSetSqlite: () => new Set([0]), - getTokenIndexForQuery: () => ({ - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }), + getTokenIndexForQuery: () => createAlphaTokenIndex(), rankSqliteFts: () => { sqliteCalls += 1; return [{ idx: 0, score: 3 }]; }, - rankVectorAnnSqlite: () => [], sqliteHasFts: () => true, - sqliteHasTable: (_mode, tableName) => tableName !== 'chunks_fts', - signal: null, - rrf: { enabled: false } + sqliteHasTable: (_mode, tableName) => tableName !== 'chunks_fts' }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/prose.md', tokens: ['alpha'], weight: 1 }], - tokenIndex: { - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ + chunks: [{ id: 0, file: 'src/prose.md', tokens: ['alpha'], weight: 1 }] +}); const hits = await pipeline(idx, 'prose', null); diff --git a/tests/retrieval/backend/fts-preflight-does-not-require-bm25-tables.test.js b/tests/retrieval/backend/fts-preflight-does-not-require-bm25-tables.test.js index 28f51819e..06a0886a7 100644 --- a/tests/retrieval/backend/fts-preflight-does-not-require-bm25-tables.test.js +++ b/tests/retrieval/backend/fts-preflight-does-not-require-bm25-tables.test.js @@ -1,24 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; - -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; let sqliteCalls = 0; -const pipeline = createSearchPipeline({ +const pipeline = createSearchPipelineFixture({ useSqlite: true, sqliteFtsRequested: true, sqliteFtsRoutingByMode: resolveSqliteFtsRoutingByMode({ @@ -30,33 +19,6 @@ const pipeline = createSearchPipeline({ runExtractedProse: false, runRecords: false }), - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { code: { profileId: 'default', @@ -64,13 +26,6 @@ const pipeline = createSearchPipeline({ allowSparseFallback: false } }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, buildCandidateSetSqlite: () => { throw new Error('bm25 fallback should not run when sqlite-fts is healthy'); }, @@ -81,22 +36,11 @@ const pipeline = createSearchPipeline({ sqliteCalls += 1; return [{ idx: 0, score: 2 }]; }, - rankVectorAnnSqlite: () => [], sqliteHasFts: () => true, - sqliteHasTable: (_mode, tableName) => tableName === 'chunks' || tableName === 'chunks_fts', - signal: null, - rrf: { enabled: false } + sqliteHasTable: (_mode, tableName) => tableName === 'chunks' || tableName === 'chunks_fts' }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ tokenIndex: null }); const hits = await pipeline(idx, 'code', null); diff --git a/tests/retrieval/backend/mixed-profile-cohort-opt-in.test.js b/tests/retrieval/backend/mixed-profile-cohort-opt-in.test.js index e4651fcaa..1c85d6ec9 100644 --- a/tests/retrieval/backend/mixed-profile-cohort-opt-in.test.js +++ b/tests/retrieval/backend/mixed-profile-cohort-opt-in.test.js @@ -1,104 +1,25 @@ #!/usr/bin/env node import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; +import { + createMixedProfileFixture, + createMixedProfileLoadOptions +} from '../../helpers/index-compatibility-fixture.js'; applyTestEnv(); -const createIndex = async (rootDir, mode, { compatibilityKey, profileId }) => { - const indexDir = path.join(rootDir, `index-${mode}`); - await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - const chunkMeta = [{ id: 0, file: `src/${mode}.js`, start: 0, end: 1 }]; - const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 - }; - const indexState = { - generatedAt: new Date().toISOString(), - mode, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - profile: { - id: profileId, - schemaVersion: 1 - } - }; - const fileLists = { - generatedAt: new Date().toISOString(), - scanned: { count: 1, sample: [] }, - skipped: { count: 0, sample: [] } - }; - const pieces = [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } - ]; - const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces - }; - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2)); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); - await fs.writeFile(path.join(indexDir, '.filelists.json'), JSON.stringify(fileLists, null, 2)); - await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2)); -}; - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-mixed-profile-opt-in-')); -await createIndex(rootDir, 'code', { - compatibilityKey: 'compat-default-profile', - profileId: 'default' -}); -await createIndex(rootDir, 'prose', { - compatibilityKey: 'compat-vector-only-profile', - profileId: 'vector_only' -}); +const { rootDir } = await createMixedProfileFixture('poc-mixed-profile-opt-in-'); const warnings = []; const originalWarn = console.warn; console.warn = (message) => warnings.push(String(message || '')); try { - const loaded = await loadSearchIndexes({ - rootDir, - userConfig: {}, - searchMode: 'default', - runProse: true, - runExtractedProse: false, - loadExtractedProse: false, - runCode: true, - runRecords: false, - useSqlite: false, - useLmdb: false, + const loaded = await loadSearchIndexes(createMixedProfileLoadOptions(rootDir, { emitOutput: true, - exitOnError: false, - annActive: false, - filtersActive: false, - contextExpansionEnabled: false, - sqliteFtsRequested: false, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, - lancedbConfig: { enabled: false }, - tantivyConfig: { enabled: false }, - allowUnsafeMix: true, - loadIndexFromSqlite: () => ({}), - loadIndexFromLmdb: () => ({}), - resolvedDenseVectorMode: 'auto' - }); + allowUnsafeMix: true + })); assert.ok(loaded?.idxCode, 'expected code index to load with unsafe mix override'); assert.ok(loaded?.idxProse, 'expected prose index to load with unsafe mix override'); diff --git a/tests/retrieval/backend/rank-sqlite-fts-fixture.js b/tests/retrieval/backend/rank-sqlite-fts-fixture.js new file mode 100644 index 000000000..2719dbcce --- /dev/null +++ b/tests/retrieval/backend/rank-sqlite-fts-fixture.js @@ -0,0 +1,70 @@ +import { createSqliteHelpers } from '../../../src/retrieval/sqlite-helpers.js'; + +export const createRankSqliteFtsFixture = async ({ + skipLabel, + createFts = true, + rowCount = 20, + weightForId = () => 1 +}) => { + let Database; + try { + ({ default: Database } = await import('better-sqlite3')); + } catch { + console.log(`${skipLabel} skipped: better-sqlite3 not available`); + process.exit(0); + } + + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE chunks ( + id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + weight REAL + ); + `); + if (createFts) { + db.exec(` + CREATE VIRTUAL TABLE chunks_fts + USING fts5(file, name, signature, kind, headline, doc, tokens, content=''); + `); + } + + const insertChunk = db.prepare('INSERT INTO chunks (id, mode, weight) VALUES (?, ?, ?)'); + const insertFts = createFts + ? db.prepare(` + INSERT INTO chunks_fts (rowid, file, name, signature, kind, headline, doc, tokens) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + : null; + const seedRows = db.transaction(() => { + for (let id = 1; id <= rowCount; id += 1) { + insertChunk.run(id, 'code', weightForId(id)); + insertFts?.run(id, '', '', '', '', '', 'alpha', 'alpha'); + } + }); + if (rowCount > 0) { + seedRows(); + } + + const vectorAnnState = { + code: { available: false }, + prose: { available: false }, + records: { available: false }, + 'extracted-prose': { available: false } + }; + + const helpers = createSqliteHelpers({ + getDb: (mode) => (mode === 'code' ? db : null), + postingsConfig: {}, + sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], + maxCandidates: null, + vectorExtension: {}, + vectorAnnConfigByMode: null, + vectorAnnState, + queryVectorAnn: () => [], + modelIdDefault: 'test-model', + fileChargramN: 3 + }); + + return { db, helpers }; +}; diff --git a/tests/retrieval/backend/rankSqliteFts-allowedIds-correctness.test.js b/tests/retrieval/backend/rankSqliteFts-allowedIds-correctness.test.js index 57ae10674..1c23d63d1 100644 --- a/tests/retrieval/backend/rankSqliteFts-allowedIds-correctness.test.js +++ b/tests/retrieval/backend/rankSqliteFts-allowedIds-correctness.test.js @@ -1,53 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSqliteHelpers } from '../../../src/retrieval/sqlite-helpers.js'; +import { createRankSqliteFtsFixture } from './rank-sqlite-fts-fixture.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('rankSqliteFts allowedIds correctness test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const db = new Database(':memory:'); -db.exec(` - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY, - mode TEXT NOT NULL, - weight REAL - ); - CREATE VIRTUAL TABLE chunks_fts USING fts5(file, name, signature, kind, headline, doc, tokens, content=''); -`); - -const insertChunk = db.prepare('INSERT INTO chunks (id, mode, weight) VALUES (?, ?, ?)'); -const insertFts = db.prepare('INSERT INTO chunks_fts (rowid, file, name, signature, kind, headline, doc, tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); -const tx = db.transaction(() => { - for (let id = 1; id <= 1200; id += 1) { - insertChunk.run(id, 'code', 1); - insertFts.run(id, '', '', '', '', '', 'alpha', 'alpha'); - } -}); -tx(); - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; - -const helpers = createSqliteHelpers({ - getDb: (mode) => (mode === 'code' ? db : null), - postingsConfig: {}, - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - maxCandidates: null, - vectorExtension: {}, - vectorAnnConfigByMode: null, - vectorAnnState, - queryVectorAnn: () => [], - modelIdDefault: 'test-model', - fileChargramN: 3 +const { db, helpers } = await createRankSqliteFtsFixture({ + skipLabel: 'rankSqliteFts allowedIds correctness test', + rowCount: 1200 }); const allowedIds = new Set(); diff --git a/tests/retrieval/backend/rankSqliteFts-missing-table-is-controlled-error.test.js b/tests/retrieval/backend/rankSqliteFts-missing-table-is-controlled-error.test.js index 1bf0c8e47..e98318a92 100644 --- a/tests/retrieval/backend/rankSqliteFts-missing-table-is-controlled-error.test.js +++ b/tests/retrieval/backend/rankSqliteFts-missing-table-is-controlled-error.test.js @@ -1,42 +1,12 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSqliteHelpers, RETRIEVAL_FTS_UNAVAILABLE_CODE } from '../../../src/retrieval/sqlite-helpers.js'; +import { RETRIEVAL_FTS_UNAVAILABLE_CODE } from '../../../src/retrieval/sqlite-helpers.js'; +import { createRankSqliteFtsFixture } from './rank-sqlite-fts-fixture.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('rankSqliteFts missing-table controlled error test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const db = new Database(':memory:'); -db.exec(` - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY, - mode TEXT NOT NULL, - weight REAL - ); -`); - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; - -const helpers = createSqliteHelpers({ - getDb: (mode) => (mode === 'code' ? db : null), - postingsConfig: {}, - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - maxCandidates: null, - vectorExtension: {}, - vectorAnnConfigByMode: null, - vectorAnnState, - queryVectorAnn: () => [], - modelIdDefault: 'test-model', - fileChargramN: 3 +const { db, helpers } = await createRankSqliteFtsFixture({ + skipLabel: 'rankSqliteFts missing-table controlled error test', + createFts: false, + rowCount: 0 }); const diagnostics = []; diff --git a/tests/retrieval/backend/rankSqliteFts-overfetch-cap-budget.test.js b/tests/retrieval/backend/rankSqliteFts-overfetch-cap-budget.test.js index 6b5815b91..6ba488aac 100644 --- a/tests/retrieval/backend/rankSqliteFts-overfetch-cap-budget.test.js +++ b/tests/retrieval/backend/rankSqliteFts-overfetch-cap-budget.test.js @@ -1,50 +1,9 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSqliteHelpers } from '../../../src/retrieval/sqlite-helpers.js'; +import { createRankSqliteFtsFixture } from './rank-sqlite-fts-fixture.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('rankSqliteFts overfetch cap/budget test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const db = new Database(':memory:'); -db.exec(` - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY, - mode TEXT NOT NULL, - weight REAL - ); - CREATE VIRTUAL TABLE chunks_fts USING fts5(file, name, signature, kind, headline, doc, tokens, content=''); -`); - -const insertChunk = db.prepare('INSERT INTO chunks (id, mode, weight) VALUES (?, ?, ?)'); -const insertFts = db.prepare('INSERT INTO chunks_fts (rowid, file, name, signature, kind, headline, doc, tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); -for (let id = 1; id <= 20; id += 1) { - insertChunk.run(id, 'code', 1); - insertFts.run(id, '', '', '', '', '', 'alpha', 'alpha'); -} - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; - -const helpers = createSqliteHelpers({ - getDb: (mode) => (mode === 'code' ? db : null), - postingsConfig: {}, - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - maxCandidates: null, - vectorExtension: {}, - vectorAnnConfigByMode: null, - vectorAnnState, - queryVectorAnn: () => [], - modelIdDefault: 'test-model', - fileChargramN: 3 +const { db, helpers } = await createRankSqliteFtsFixture({ + skipLabel: 'rankSqliteFts overfetch cap/budget test' }); let statsSmall = null; diff --git a/tests/retrieval/backend/rankSqliteFts-pushdown-cache-arity.test.js b/tests/retrieval/backend/rankSqliteFts-pushdown-cache-arity.test.js index d53dd43ac..2e01e53f5 100644 --- a/tests/retrieval/backend/rankSqliteFts-pushdown-cache-arity.test.js +++ b/tests/retrieval/backend/rankSqliteFts-pushdown-cache-arity.test.js @@ -1,50 +1,9 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSqliteHelpers } from '../../../src/retrieval/sqlite-helpers.js'; +import { createRankSqliteFtsFixture } from './rank-sqlite-fts-fixture.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('rankSqliteFts pushdown cache arity test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const db = new Database(':memory:'); -db.exec(` - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY, - mode TEXT NOT NULL, - weight REAL - ); - CREATE VIRTUAL TABLE chunks_fts USING fts5(file, name, signature, kind, headline, doc, tokens, content=''); -`); - -const insertChunk = db.prepare('INSERT INTO chunks (id, mode, weight) VALUES (?, ?, ?)'); -const insertFts = db.prepare('INSERT INTO chunks_fts (rowid, file, name, signature, kind, headline, doc, tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); -for (let id = 1; id <= 20; id += 1) { - insertChunk.run(id, 'code', 1); - insertFts.run(id, '', '', '', '', '', 'alpha', 'alpha'); -} - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; - -const helpers = createSqliteHelpers({ - getDb: (mode) => (mode === 'code' ? db : null), - postingsConfig: {}, - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - maxCandidates: null, - vectorExtension: {}, - vectorAnnConfigByMode: null, - vectorAnnState, - queryVectorAnn: () => [], - modelIdDefault: 'test-model', - fileChargramN: 3 +const { db, helpers } = await createRankSqliteFtsFixture({ + skipLabel: 'rankSqliteFts pushdown cache arity test' }); const diagnostics = []; diff --git a/tests/retrieval/backend/rankSqliteFts-weight-before-limit.test.js b/tests/retrieval/backend/rankSqliteFts-weight-before-limit.test.js index dd3abfbf0..73c0e71d8 100644 --- a/tests/retrieval/backend/rankSqliteFts-weight-before-limit.test.js +++ b/tests/retrieval/backend/rankSqliteFts-weight-before-limit.test.js @@ -1,51 +1,11 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSqliteHelpers } from '../../../src/retrieval/sqlite-helpers.js'; +import { createRankSqliteFtsFixture } from './rank-sqlite-fts-fixture.js'; -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('rankSqliteFts weight-before-limit test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const db = new Database(':memory:'); -db.exec(` - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY, - mode TEXT NOT NULL, - weight REAL - ); - CREATE VIRTUAL TABLE chunks_fts USING fts5(file, name, signature, kind, headline, doc, tokens, content=''); -`); - -const insertChunk = db.prepare('INSERT INTO chunks (id, mode, weight) VALUES (?, ?, ?)'); -const insertFts = db.prepare('INSERT INTO chunks_fts (rowid, file, name, signature, kind, headline, doc, tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); - -insertChunk.run(1, 'code', 0.01); -insertChunk.run(2, 'code', 100); -insertFts.run(1, '', '', '', '', '', 'alpha', 'alpha'); -insertFts.run(2, '', '', '', '', '', 'alpha', 'alpha'); - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; - -const helpers = createSqliteHelpers({ - getDb: (mode) => (mode === 'code' ? db : null), - postingsConfig: {}, - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - maxCandidates: null, - vectorExtension: {}, - vectorAnnConfigByMode: null, - vectorAnnState, - queryVectorAnn: () => [], - modelIdDefault: 'test-model', - fileChargramN: 3 +const { db, helpers } = await createRankSqliteFtsFixture({ + skipLabel: 'rankSqliteFts weight-before-limit test', + rowCount: 2, + weightForId: (id) => (id === 1 ? 0.01 : 100) }); const hits = helpers.rankSqliteFts( diff --git a/tests/retrieval/backend/retrieval-auto-sqlite-thresholds.test.js b/tests/retrieval/backend/retrieval-auto-sqlite-thresholds.test.js deleted file mode 100644 index 11ecda818..000000000 --- a/tests/retrieval/backend/retrieval-auto-sqlite-thresholds.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { evaluateAutoSqliteThresholds } from '../../../src/retrieval/cli/auto-sqlite.js'; - -const disabled = evaluateAutoSqliteThresholds({ - stats: [{ chunkCount: null, artifactBytes: null }], - chunkThreshold: 0, - artifactThreshold: 0 -}); -assert.equal(disabled.allowed, true, 'expected disabled thresholds to allow sqlite'); - -const missingChunks = evaluateAutoSqliteThresholds({ - stats: [{ chunkCount: null, artifactBytes: 1200 }], - chunkThreshold: 10, - artifactThreshold: 0 -}); -assert.equal(missingChunks.allowed, false, 'expected missing chunk stats to reject sqlite'); -assert.ok( - missingChunks.reason && missingChunks.reason.includes('chunk stats are unavailable'), - 'expected missing chunk stats reason' -); - -const missingBytes = evaluateAutoSqliteThresholds({ - stats: [{ chunkCount: 12, artifactBytes: null }], - chunkThreshold: 0, - artifactThreshold: 5000 -}); -assert.equal(missingBytes.allowed, false, 'expected missing bytes stats to reject sqlite'); -assert.ok( - missingBytes.reason && missingBytes.reason.includes('artifact bytes are unavailable'), - 'expected missing artifact bytes reason' -); - -const tooSmall = evaluateAutoSqliteThresholds({ - stats: [{ chunkCount: 5, artifactBytes: 100 }], - chunkThreshold: 10, - artifactThreshold: 1000 -}); -assert.equal(tooSmall.allowed, false, 'expected thresholds not met to reject sqlite'); -assert.ok( - tooSmall.reason && tooSmall.reason.includes('auto sqlite thresholds not met'), - 'expected thresholds not met reason' -); - -const meetsBytes = evaluateAutoSqliteThresholds({ - stats: [{ chunkCount: 5, artifactBytes: 1500 }], - chunkThreshold: 0, - artifactThreshold: 1000 -}); -assert.equal(meetsBytes.allowed, true, 'expected bytes threshold to allow sqlite'); - -console.log('retrieval auto sqlite thresholds test passed'); diff --git a/tests/retrieval/backend/retrieval-backend-policy.test.js b/tests/retrieval/backend/retrieval-backend-policy.test.js deleted file mode 100644 index ad9b8e36c..000000000 --- a/tests/retrieval/backend/retrieval-backend-policy.test.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveBackendSelection } from '../../../src/retrieval/cli/policy.js'; - -const base = { - sqliteScoreModeConfig: false, - sqliteConfigured: true, - sqliteAvailable: true, - sqliteCodeAvailable: true, - sqliteProseAvailable: true, - sqliteCodePath: 'code.db', - sqliteProsePath: 'prose.db', - lmdbConfigured: true, - lmdbAvailable: true, - lmdbCodeAvailable: true, - lmdbProseAvailable: true, - lmdbCodePath: 'lmdb-code', - lmdbProsePath: 'lmdb-prose', - sqliteAutoChunkThreshold: 0, - sqliteAutoArtifactBytes: 0, - needsSqlite: true, - needsCode: true, - needsProse: false, - root: process.cwd(), - userConfig: {} -}; - -const autoResult = await resolveBackendSelection({ - ...base, - backendArg: '' -}); -assert.equal(autoResult.useSqlite, true, 'expected auto backend to select sqlite'); -assert.equal(autoResult.useLmdb, false, 'expected auto backend to avoid lmdb'); - -const lmdbFallback = await resolveBackendSelection({ - ...base, - backendArg: '', - sqliteAvailable: false, - sqliteCodeAvailable: false, - lmdbAvailable: true -}); -assert.equal(lmdbFallback.useSqlite, false, 'expected sqlite to be skipped when unavailable'); -assert.equal(lmdbFallback.useLmdb, true, 'expected lmdb to be selected when available'); - -const forcedSqlite = await resolveBackendSelection({ - ...base, - backendArg: 'sqlite', - sqliteAvailable: false, - sqliteCodeAvailable: false -}); -assert.ok(forcedSqlite.error, 'expected sqlite error when forced and missing'); -assert.ok(forcedSqlite.error.message.includes('SQLite backend requested'), 'expected sqlite error message'); -assert.ok(forcedSqlite.error.message.includes('code=code.db'), 'expected sqlite missing path in message'); - -const forcedLmdb = await resolveBackendSelection({ - ...base, - backendArg: 'lmdb', - lmdbAvailable: false, - lmdbCodeAvailable: false -}); -assert.ok(forcedLmdb.error, 'expected lmdb error when forced and missing'); -assert.ok(forcedLmdb.error.message.includes('LMDB backend requested'), 'expected lmdb error message'); -assert.ok(forcedLmdb.error.message.includes('code=lmdb-code'), 'expected lmdb missing path in message'); - -const forcedTantivy = await resolveBackendSelection({ - ...base, - backendArg: 'tantivy' -}); -assert.equal(forcedTantivy.useSqlite, false, 'expected tantivy to avoid sqlite'); -assert.equal(forcedTantivy.useLmdb, false, 'expected tantivy to avoid lmdb'); -assert.equal(forcedTantivy.backendPolicy.backendLabel, 'tantivy', 'expected tantivy backend label'); -assert.equal(forcedTantivy.backendForcedTantivy, true, 'expected tantivy backend flag'); - -console.log('retrieval backend policy test passed'); diff --git a/tests/retrieval/backend/retrieval-strict-manifest-embeddings.test.js b/tests/retrieval/backend/retrieval-strict-manifest-embeddings.test.js deleted file mode 100644 index e21f5f0de..000000000 --- a/tests/retrieval/backend/retrieval-strict-manifest-embeddings.test.js +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const cacheRoot = resolveTestCachePath(root, 'retrieval-strict-manifest-embeddings'); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub' -}); - -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { - env, - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', fixtureRoot], 'build index'); -run([path.join(root, 'tools', 'build/embeddings.js'), '--stub-embeddings', '--repo', fixtureRoot], 'build embeddings'); - -const userConfig = loadUserConfig(fixtureRoot); -const codeDir = getIndexDir(fixtureRoot, 'code', userConfig); -const manifestPath = path.join(codeDir, 'pieces', 'manifest.json'); -await fsPromises.rm(manifestPath, { force: true }); -await fsPromises.rm(`${manifestPath}.bak`, { force: true }); - -const searchArgs = [ - path.join(root, 'search.js'), - 'token', - '--backend', - 'memory', - '--json', - '--repo', - fixtureRoot -]; -const strictResult = spawnSync(process.execPath, searchArgs, { - env, - encoding: 'utf8' -}); -if (strictResult.status === 0) { - console.error('Expected strict search to fail without pieces manifest.'); - process.exit(1); -} -const strictOut = strictResult.stdout || ''; -let strictPayload = null; -try { - strictPayload = JSON.parse(strictOut); -} catch {} -const strictMessage = strictPayload?.message || strictOut || strictResult.stderr || ''; -if (!String(strictMessage).toLowerCase().includes('manifest')) { - console.error('Expected strict search failure to mention manifest.'); - process.exit(1); -} - -const nonStrictResult = spawnSync( - process.execPath, - [...searchArgs, '--non-strict'], - { env, encoding: 'utf8' } -); -if (nonStrictResult.status === 0) { - console.error('Expected non-strict search to fail without pieces manifest after hard cutover.'); - process.exit(1); -} -const nonStrictOut = nonStrictResult.stdout || ''; -let nonStrictPayload = null; -try { - nonStrictPayload = JSON.parse(nonStrictOut); -} catch {} -const nonStrictMessage = nonStrictPayload?.message || nonStrictOut || nonStrictResult.stderr || ''; -if (!String(nonStrictMessage).toLowerCase().includes('manifest')) { - console.error('Expected non-strict search failure to mention manifest.'); - process.exit(1); -} - -console.log('retrieval strict manifest embeddings test passed'); diff --git a/tests/retrieval/backend/search-routing-policy.test.js b/tests/retrieval/backend/search-routing-policy.test.js index 632de8eb4..d1287ace8 100644 --- a/tests/retrieval/backend/search-routing-policy.test.js +++ b/tests/retrieval/backend/search-routing-policy.test.js @@ -1,21 +1,11 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; - -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); +import { + createAlphaSearchIndex, + createAlphaTokenIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; const sqliteCalls = []; const rankSqliteFts = (_idx, _tokens, mode) => { @@ -36,76 +26,17 @@ const routingPolicy = resolveSqliteFtsRoutingByMode({ assert.equal(routingPolicy.byMode.code.desired, 'sparse', 'expected code mode default sparse route'); assert.equal(routingPolicy.byMode.prose.desired, 'fts', 'expected prose mode default fts route'); -const pipeline = createSearchPipeline({ +const pipeline = createSearchPipelineFixture({ useSqlite: true, sqliteFtsRequested: true, sqliteFtsRoutingByMode: routingPolicy, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, buildCandidateSetSqlite: () => new Set([0]), - getTokenIndexForQuery: (tokens, mode) => (mode === 'code' ? { - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - } : null), + getTokenIndexForQuery: (_tokens, mode) => (mode === 'code' ? createAlphaTokenIndex() : null), rankSqliteFts, - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => true, - signal: null, - rrf: { enabled: false } + sqliteHasFts: () => true }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: { - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex(); const codeHits = await pipeline(idx, 'code', null); const proseHits = await pipeline(idx, 'prose', null); diff --git a/tests/retrieval/backend/sparse-preflight-fallback-helper.js b/tests/retrieval/backend/sparse-preflight-fallback-helper.js new file mode 100644 index 000000000..0d0843813 --- /dev/null +++ b/tests/retrieval/backend/sparse-preflight-fallback-helper.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; + +import { runSearchCli } from '../../../src/retrieval/cli.js'; +import { ensureFixtureIndex, ensureFixtureSqlite } from '../../helpers/fixture-index.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const SPARSE_TABLES = Object.freeze([ + 'token_vocab', + 'token_postings', + 'doc_lengths', + 'token_stats', + 'phrase_vocab', + 'phrase_postings', + 'chargram_vocab', + 'chargram_postings' +]); + +export async function prepareSparsePreflightFallbackCase({ + label, + cacheName, + extraArgs = [] +}) { + applyTestEnv(); + + let Database; + try { + ({ default: Database } = await import('better-sqlite3')); + } catch { + console.log(`${label} skipped: better-sqlite3 not available`); + process.exit(0); + } + + const { fixtureRoot, env, userConfig } = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName, + cacheScope: 'shared' + }); + const sqlitePaths = await ensureFixtureSqlite({ fixtureRoot, userConfig, env }); + + const db = new Database(sqlitePaths.codePath); + for (const tableName of SPARSE_TABLES) { + db.exec(`DROP TABLE IF EXISTS ${tableName}`); + } + db.close(); + + return [ + 'rust_greet', + '--repo', + fixtureRoot, + '--mode', + 'code', + '--backend', + 'sqlite-fts', + '--no-ann', + ...extraArgs, + '--stats', + '--json', + '--compact' + ]; +} + +export async function assertSparsePreflightFallback({ + baseArgs, + failMessage, + annMessage +}) { + let baseFailed = false; + try { + await runSearchCli(baseArgs, { emitOutput: false, exitOnError: false }); + } catch (err) { + baseFailed = true; + const message = String(err?.message || err); + assert.ok( + /retrieval_sparse_unavailable/i.test(message), + 'expected sparse-unavailable error without fallback override' + ); + } + + const payload = await runSearchCli( + [...baseArgs, '--allow-sparse-fallback'], + { emitOutput: false, exitOnError: false } + ); + + assert.equal(baseFailed, true, failMessage); + assert.ok(Array.isArray(payload?.code), 'expected CLI search payload to include code hits'); + assert.equal(payload?.stats?.annEnabled, true, annMessage); + assert.equal( + Array.isArray(payload?.stats?.pipeline) + && payload.stats.pipeline.some((entry) => entry?.stage === 'startup.backend.reinit'), + true, + 'expected backend context reinit when sparse preflight forces ANN fallback' + ); +} diff --git a/tests/retrieval/backend/sqlite-fts-eligibility.test.js b/tests/retrieval/backend/sqlite-fts-eligibility.test.js deleted file mode 100644 index f63576cf4..000000000 --- a/tests/retrieval/backend/sqlite-fts-eligibility.test.js +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; - -let sqliteCalls = 0; -const rankSqliteFts = () => { - sqliteCalls += 1; - return [{ idx: 0, score: 1 }]; -}; - -const vectorAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; -const vectorAnnUsed = { - code: false, - prose: false, - records: false, - 'extracted-prose': false -}; -const hnswAnnState = { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}; -const hnswAnnUsed = { ...vectorAnnUsed }; -const lanceAnnState = { ...hnswAnnState }; -const lanceAnnUsed = { ...vectorAnnUsed }; - -const pipeline = createSearchPipeline({ - useSqlite: true, - sqliteFtsRequested: true, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: [], - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - queryTokens: ['hello'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: null, - filters: { filePrefilter: { enabled: true } }, - filtersActive: undefined, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState, - vectorAnnUsed, - hnswAnnState, - hnswAnnUsed, - lanceAnnState, - lanceAnnUsed, - lancedbConfig: {}, - buildCandidateSetSqlite: () => new Set(), - getTokenIndexForQuery: () => null, - rankSqliteFts, - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => true, - signal: null, - rrf: { enabled: false } -}); - -const idx = { - chunkMeta: [{ id: 0, file: 'foo.js', tokens: [] }], - fileRelations: null, - filterIndex: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; - -const hits = await pipeline(idx, 'code', null); -assert.equal(sqliteCalls, 1, 'expected sqlite FTS to be invoked when filters are internal-only'); -assert.equal(hits.length, 1, 'expected a single hit from sqlite FTS'); -assert.equal(hits[0].file, 'foo.js'); - -console.log('sqlite FTS eligibility test passed'); diff --git a/tests/retrieval/backend/sqlite-missing-sparse-tables-is-controlled-error.test.js b/tests/retrieval/backend/sqlite-missing-sparse-tables-is-controlled-error.test.js index 9e824f5ef..9857cd635 100644 --- a/tests/retrieval/backend/sqlite-missing-sparse-tables-is-controlled-error.test.js +++ b/tests/retrieval/backend/sqlite-missing-sparse-tables-is-controlled-error.test.js @@ -1,22 +1,11 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ +const pipeline = createSearchPipelineFixture({ useSqlite: true, sqliteFtsRequested: false, sqliteFtsRoutingByMode: { @@ -34,28 +23,7 @@ const pipeline = createSearchPipeline({ substringMode: false, stemming: false }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, postingsConfig: { enablePhraseNgrams: true, enableChargrams: true }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { code: { profileId: 'default', @@ -63,32 +31,11 @@ const pipeline = createSearchPipeline({ allowSparseFallback: false } }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], sqliteHasFts: () => false, - sqliteHasTable: (_mode, _table) => false, - signal: null, - rrf: { enabled: false } + sqliteHasTable: (_mode, _table) => false }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ tokenIndex: null }); let failed = false; try { diff --git a/tests/retrieval/backend/vector-only-ann-query-failure-requires-ann.test.js b/tests/retrieval/backend/vector-only-ann-query-failure-requires-ann.test.js index 3cc4d83e9..507796e53 100644 --- a/tests/retrieval/backend/vector-only-ann-query-failure-requires-ann.test.js +++ b/tests/retrieval/backend/vector-only-ann-query-failure-requires-ann.test.js @@ -1,53 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsRoutingByMode: { byMode: {} }, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, +const pipeline = createSearchPipelineFixture({ annEnabled: true, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { prose: { profileId: 'vector_only', @@ -55,20 +15,6 @@ const pipeline = createSearchPipeline({ allowSparseFallback: false } }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, createAnnProviders: () => new Map([ [ANN_PROVIDER_IDS.DENSE, { id: ANN_PROVIDER_IDS.DENSE, @@ -81,15 +27,11 @@ const pipeline = createSearchPipeline({ ]) }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], +const idx = createAlphaSearchIndex({ + chunks: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, denseVec: { vectors: [new Uint8Array([1])], dims: 1, model: 'stub' } -}; +}); let failed = false; try { diff --git a/tests/retrieval/backend/vector-only-compatibility-key-mismatch.test.js b/tests/retrieval/backend/vector-only-compatibility-key-mismatch.test.js index f8ed93e55..50b4b1749 100644 --- a/tests/retrieval/backend/vector-only-compatibility-key-mismatch.test.js +++ b/tests/retrieval/backend/vector-only-compatibility-key-mismatch.test.js @@ -1,100 +1,19 @@ #!/usr/bin/env node import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; +import { + createMixedProfileFixture, + createMixedProfileLoadOptions +} from '../../helpers/index-compatibility-fixture.js'; applyTestEnv(); -const createIndex = async (rootDir, mode, { compatibilityKey, profileId }) => { - const indexDir = path.join(rootDir, `index-${mode}`); - await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - const chunkMeta = [{ id: 0, file: `src/${mode}.js`, start: 0, end: 1 }]; - const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 - }; - const indexState = { - generatedAt: new Date().toISOString(), - mode, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - profile: { - id: profileId, - schemaVersion: 1 - } - }; - const fileLists = { - generatedAt: new Date().toISOString(), - scanned: { count: 1, sample: [] }, - skipped: { count: 0, sample: [] } - }; - const pieces = [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' } - ]; - const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces - }; - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2)); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); - await fs.writeFile(path.join(indexDir, '.filelists.json'), JSON.stringify(fileLists, null, 2)); - await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2)); -}; - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-vector-only-compat-mismatch-')); -await createIndex(rootDir, 'code', { - compatibilityKey: 'compat-default-profile', - profileId: 'default' -}); -await createIndex(rootDir, 'prose', { - compatibilityKey: 'compat-vector-only-profile', - profileId: 'vector_only' -}); +const { rootDir } = await createMixedProfileFixture('poc-vector-only-compat-mismatch-'); let failed = false; try { - await loadSearchIndexes({ - rootDir, - userConfig: {}, - searchMode: 'default', - runProse: true, - runExtractedProse: false, - loadExtractedProse: false, - runCode: true, - runRecords: false, - useSqlite: false, - useLmdb: false, - emitOutput: false, - exitOnError: false, - annActive: false, - filtersActive: false, - contextExpansionEnabled: false, - sqliteFtsRequested: false, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, - lancedbConfig: { enabled: false }, - tantivyConfig: { enabled: false }, - loadIndexFromSqlite: () => ({}), - loadIndexFromLmdb: () => ({}), - resolvedDenseVectorMode: 'auto' - }); + await loadSearchIndexes(createMixedProfileLoadOptions(rootDir)); } catch (err) { failed = true; assert.match( diff --git a/tests/retrieval/backend/vector-only-empty-ann-results-do-not-fail.test.js b/tests/retrieval/backend/vector-only-empty-ann-results-do-not-fail.test.js index 1027a9c14..41a2e776a 100644 --- a/tests/retrieval/backend/vector-only-empty-ann-results-do-not-fail.test.js +++ b/tests/retrieval/backend/vector-only-empty-ann-results-do-not-fail.test.js @@ -1,53 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsRoutingByMode: { byMode: {} }, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, +const pipeline = createSearchPipelineFixture({ annEnabled: true, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { prose: { profileId: 'vector_only', @@ -55,20 +15,6 @@ const pipeline = createSearchPipeline({ allowSparseFallback: false } }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, createAnnProviders: () => new Map([ [ANN_PROVIDER_IDS.DENSE, { id: ANN_PROVIDER_IDS.DENSE, @@ -79,15 +25,11 @@ const pipeline = createSearchPipeline({ ]) }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], +const idx = createAlphaSearchIndex({ + chunks: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, denseVec: { vectors: [new Uint8Array([1])], dims: 1, model: 'stub' } -}; +}); const results = await pipeline(idx, 'prose', [0.1]); assert.deepEqual(results, [], 'expected vector_only search with empty ANN hits to return no results (not capability error)'); diff --git a/tests/retrieval/backend/vector-only-empty-index-does-not-require-ann.test.js b/tests/retrieval/backend/vector-only-empty-index-does-not-require-ann.test.js index 21dc35be9..70bd9730b 100644 --- a/tests/retrieval/backend/vector-only-empty-index-does-not-require-ann.test.js +++ b/tests/retrieval/backend/vector-only-empty-index-does-not-require-ann.test.js @@ -1,84 +1,22 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsRoutingByMode: { byMode: {} }, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, +const pipeline = createSearchPipelineFixture({ annEnabled: true, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { prose: { profileId: 'vector_only', vectorOnly: true, allowSparseFallback: false } - }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false } + } }); -const idx = { - chunkMeta: [], - tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ chunks: [], tokenIndex: null }); const results = await pipeline(idx, 'prose', null); assert.deepEqual(results, [], 'expected empty vector-only indexes to return no results'); diff --git a/tests/retrieval/backend/vector-only-rejects-sparse-mode.test.js b/tests/retrieval/backend/vector-only-rejects-sparse-mode.test.js index e39017482..01a63832d 100644 --- a/tests/retrieval/backend/vector-only-rejects-sparse-mode.test.js +++ b/tests/retrieval/backend/vector-only-rejects-sparse-mode.test.js @@ -1,84 +1,21 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsRoutingByMode: { byMode: {} }, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', +const pipeline = createSearchPipelineFixture({ profilePolicyByMode: { code: { profileId: 'vector_only', vectorOnly: true, allowSparseFallback: false } - }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false } + } }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ tokenIndex: null }); let failed = false; try { diff --git a/tests/retrieval/backend/vector-only-search-requires-ann.test.js b/tests/retrieval/backend/vector-only-search-requires-ann.test.js index a2ab7f97d..fea583788 100644 --- a/tests/retrieval/backend/vector-only-search-requires-ann.test.js +++ b/tests/retrieval/backend/vector-only-search-requires-ann.test.js @@ -1,84 +1,25 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsRoutingByMode: { byMode: {} }, - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 5, +const pipeline = createSearchPipelineFixture({ annEnabled: true, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', profilePolicyByMode: { prose: { profileId: 'vector_only', vectorOnly: true, allowSparseFallback: false } - }, - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => [], - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false } + } }); -const idx = { - chunkMeta: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], - tokenIndex: null, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; +const idx = createAlphaSearchIndex({ + chunks: [{ id: 0, file: 'src/doc.md', tokens: ['alpha'], weight: 1 }], + tokenIndex: null +}); let failed = false; try { diff --git a/tests/retrieval/cache/embedding-query-cache-dims-lru.test.js b/tests/retrieval/cache/embedding-query-dims-lru.test.js similarity index 100% rename from tests/retrieval/cache/embedding-query-cache-dims-lru.test.js rename to tests/retrieval/cache/embedding-query-dims-lru.test.js diff --git a/tests/retrieval/cache/index-cache-contract-matrix.test.js b/tests/retrieval/cache/index-cache-contract-matrix.test.js new file mode 100644 index 000000000..8cea61fd4 --- /dev/null +++ b/tests/retrieval/cache/index-cache-contract-matrix.test.js @@ -0,0 +1,200 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { INDEX_SIGNATURE_TTL_MS, buildIndexSignature, loadIndexWithCache } from '../../../src/retrieval/index-cache.js'; +import { getIndexSignature } from '../../../src/retrieval/cli-index.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const cases = [ + { + name: 'loadIndexWithCache reuses entries until signature or generation changes', + async run() { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-index-cache-')); + try { + const indexDir = path.join(tempRoot, 'index'); + await fs.mkdir(indexDir, { recursive: true }); + const writeMeta = async (value) => { + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(value)); + }; + + const cache = new Map(); + let loads = 0; + const loader = () => ({ loaded: ++loads }); + + await writeMeta([{ id: 1 }]); + const first = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); + const second = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); + assert.equal(loads, 1); + assert.equal(first.loaded, second.loaded); + + const chunkMetaModeCache = new Map(); + let chunkMetaModeLoads = 0; + const chunkMetaModeLoader = () => ({ loaded: ++chunkMetaModeLoads }); + await loadIndexWithCache( + chunkMetaModeCache, + indexDir, + { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: false }, + chunkMetaModeLoader + ); + await loadIndexWithCache( + chunkMetaModeCache, + indexDir, + { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: false }, + chunkMetaModeLoader + ); + await loadIndexWithCache( + chunkMetaModeCache, + indexDir, + { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: true }, + chunkMetaModeLoader + ); + assert.equal(chunkMetaModeLoads, 2); + + const generationScopedCache = new Map(); + let generationScopedLoads = 0; + const generationScopedLoader = () => ({ loaded: ++generationScopedLoads }); + await loadIndexWithCache( + generationScopedCache, + indexDir, + { + modelIdDefault: 'm', + fileChargramN: 3, + generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-a' } + }, + generationScopedLoader + ); + await loadIndexWithCache( + generationScopedCache, + indexDir, + { + modelIdDefault: 'm', + fileChargramN: 3, + generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-a' } + }, + generationScopedLoader + ); + await loadIndexWithCache( + generationScopedCache, + indexDir, + { + modelIdDefault: 'm', + fileChargramN: 3, + generationTag: { mode: 'code', buildId: 'build-b', buildGenerationKey: 'gen-b' } + }, + generationScopedLoader + ); + assert.equal(generationScopedLoads, 2); + + await writeMeta([{ id: 2 }]); + const originalNow = Date.now; + let now = originalNow(); + try { + Date.now = () => now; + now += INDEX_SIGNATURE_TTL_MS + 1; + const third = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); + assert.equal(loads, 2); + assert.notEqual(third.loaded, first.loaded); + } finally { + Date.now = originalNow; + } + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + } + }, + { + name: 'index signature cache evicts oldest entries beyond the 256-entry cap', + async run() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-signature-cache-')); + const dirs = []; + try { + for (let index = 0; index < 257; index += 1) { + const dir = path.join(root, `idx-${index}`); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, 'index_state.json'), + JSON.stringify({ buildId: `build-${index}`, mode: 'code', artifactSurfaceVersion: '1' }), + 'utf8' + ); + dirs.push(dir); + } + + for (const dir of dirs) { + await buildIndexSignature(dir); + } + + const originalReadFile = fs.readFile; + let readCount = 0; + fs.readFile = async (...args) => { + readCount += 1; + return originalReadFile(...args); + }; + try { + await buildIndexSignature(dirs[0]); + } finally { + fs.readFile = originalReadFile; + } + assert.ok(readCount > 0); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + } + }, + { + name: 'cli index signatures include sharded chunk meta manifests and part hashes', + async run() { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-chunk-meta-sig-')); + try { + const codeDir = path.join(rootDir, 'index-code'); + const partsDir = path.join(codeDir, 'chunk_meta.parts'); + await fs.mkdir(partsDir, { recursive: true }); + + await fs.writeFile( + path.join(partsDir, 'chunk_meta.part-0000.jsonl'), + `${JSON.stringify({ id: 0, file: 'src/a.js', start: 0, end: 1 })}\n` + ); + await fs.writeFile(path.join(codeDir, 'chunk_meta.meta.json'), JSON.stringify({ + schemaVersion: '0.0.1', + artifact: 'chunk_meta', + format: 'jsonl-sharded', + generatedAt: new Date().toISOString(), + compression: 'none', + totalRecords: 1, + totalBytes: 1, + maxPartRecords: 1, + maxPartBytes: 1, + targetMaxBytes: 1, + parts: [{ path: 'chunk_meta.parts/chunk_meta.part-0000.jsonl', records: 1, bytes: 1 }] + }, null, 2)); + + const signature = await getIndexSignature({ + useSqlite: false, + backendLabel: 'memory', + sqliteCodePath: null, + sqliteProsePath: null, + runRecords: false, + runExtractedProse: false, + includeExtractedProse: false, + root: rootDir, + userConfig: {} + }); + + assert.equal(signature.modes?.code?.includes('chunk_meta.meta.json:'), true); + assert.equal(signature.modes?.code?.includes('|parts:'), true); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('index cache contract matrix test passed'); diff --git a/tests/retrieval/cache/index-cache.test.js b/tests/retrieval/cache/index-cache.test.js deleted file mode 100644 index 29efb41de..000000000 --- a/tests/retrieval/cache/index-cache.test.js +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { INDEX_SIGNATURE_TTL_MS, loadIndexWithCache } from '../../../src/retrieval/index-cache.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-index-cache-')); -const indexDir = path.join(tempRoot, 'index'); -await fs.mkdir(indexDir, { recursive: true }); - -const writeMeta = async (value) => { - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(value)); -}; - -const cache = new Map(); -let loads = 0; -const loader = () => { - loads += 1; - return { loaded: loads }; -}; - -await writeMeta([{ id: 1 }]); -const first = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); -const second = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); -assert.equal(loads, 1, 'cache should prevent reloads'); -assert.equal(first.loaded, second.loaded, 'cached result should match'); - -const chunkMetaModeCache = new Map(); -let chunkMetaModeLoads = 0; -const chunkMetaModeLoader = () => { - chunkMetaModeLoads += 1; - return { loaded: chunkMetaModeLoads }; -}; -await loadIndexWithCache( - chunkMetaModeCache, - indexDir, - { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: false }, - chunkMetaModeLoader -); -await loadIndexWithCache( - chunkMetaModeCache, - indexDir, - { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: false }, - chunkMetaModeLoader -); -await loadIndexWithCache( - chunkMetaModeCache, - indexDir, - { modelIdDefault: 'm', fileChargramN: 3, includeChunkMetaCold: true }, - chunkMetaModeLoader -); -assert.equal( - chunkMetaModeLoads, - 2, - 'cache key should include includeChunkMetaCold to avoid stale meta shape reuse' -); - -await writeMeta([{ id: 2 }]); -const originalNow = Date.now; -let now = originalNow(); -Date.now = () => now; -let third; -try { - now += INDEX_SIGNATURE_TTL_MS + 1; - third = await loadIndexWithCache(cache, indexDir, { modelIdDefault: 'm', fileChargramN: 3 }, loader); -} finally { - Date.now = originalNow; -} -assert.equal(loads, 2, 'cache should reload after signature change'); -assert.notEqual(third.loaded, first.loaded, 'reloaded result should differ'); - -console.log('index cache tests passed'); diff --git a/tests/retrieval/cache/index-signature-cache-bounds.test.js b/tests/retrieval/cache/index-signature-cache-bounds.test.js deleted file mode 100644 index e3cf94d4d..000000000 --- a/tests/retrieval/cache/index-signature-cache-bounds.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; - -const root = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-signature-cache-')); -const dirs = []; -for (let i = 0; i < 300; i += 1) { - const dir = path.join(root, `idx-${i}`); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, 'index_state.json'), - JSON.stringify({ buildId: `build-${i}`, mode: 'code', artifactSurfaceVersion: '1' }), - 'utf8' - ); - dirs.push(dir); -} - -for (const dir of dirs) { - await buildIndexSignature(dir); -} - -const originalReadFile = fs.readFile; -let readCount = 0; -fs.readFile = async (...args) => { - readCount += 1; - return originalReadFile(...args); -}; - -try { - await buildIndexSignature(dirs[0]); -} finally { - fs.readFile = originalReadFile; - await fs.rm(root, { recursive: true, force: true }); -} - -assert.ok(readCount > 0, 'expected oldest signature cache entry to be evicted and recomputed'); - -console.log('index signature cache bounds test passed'); diff --git a/tests/retrieval/cache/query-cache-contract-matrix.test.js b/tests/retrieval/cache/query-cache-contract-matrix.test.js new file mode 100644 index 000000000..947d6d000 --- /dev/null +++ b/tests/retrieval/cache/query-cache-contract-matrix.test.js @@ -0,0 +1,208 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; +import { + findQueryCacheEntry, + loadQueryCache, + pruneQueryCache +} from '../../../src/retrieval/query-cache.js'; +import { getRepoId } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); + +const QUERY_CACHE_FAST_TEST_CONFIG = { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + scm: { provider: 'none' } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } +}; + +const createQueryCacheEnv = (cacheRoot, testConfig = {}) => applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + ...QUERY_CACHE_FAST_TEST_CONFIG, + ...testConfig + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + syncProcess: false +}); + +const runNodeScript = (cwd, env, args, label) => { + const result = runNode(args, label, cwd, env, { stdio: 'pipe' }); + return result.stdout || ''; +}; + +const cases = [ + { + name: 'query cache lookup prefers newest entries and supports prewarm trimming', + async run() { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-query-cache-lookup-')); + try { + const cachePath = path.join(tempRoot, 'queryCache.json'); + const baseTs = Date.now(); + await fsPromises.writeFile(cachePath, JSON.stringify({ + version: 1, + entries: [ + { key: 'k1', signature: 's1', ts: baseTs - 300, payload: { code: [{ id: 1 }] } }, + { key: 'k1', signature: 's1', ts: baseTs - 200, payload: { code: [{ id: 2 }] } }, + { key: 'k2', signature: 's2', ts: baseTs - 250, payload: { code: [{ id: 3 }] } } + ] + }, null, 2)); + + const cache = loadQueryCache(cachePath); + assert.equal(findQueryCacheEntry(cache, 'k1', 's1')?.ts, baseTs - 200); + + cache.entries.push({ key: 'k1', signature: 's1', ts: baseTs - 100, payload: { code: [{ id: 4 }] } }); + assert.equal(findQueryCacheEntry(cache, 'k1', 's1')?.ts, baseTs - 100); + + pruneQueryCache(cache, 2); + assert.equal(findQueryCacheEntry(cache, 'k1', 's1')?.ts, baseTs - 100); + + const coldMemoryMiss = findQueryCacheEntry( + { version: 1, entries: [] }, + 'k1', + 's1', + { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } + ); + assert.equal(coldMemoryMiss, null); + + loadQueryCache(cachePath, { prewarm: true, prewarmMaxEntries: 8 }); + const prewarmedHit = findQueryCacheEntry( + { version: 1, entries: [] }, + 'k1', + 's1', + { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } + ); + assert.ok(prewarmedHit); + assert.equal(prewarmedHit?.key, 'k1'); + assert.equal(prewarmedHit?.signature, 's1'); + + loadQueryCache(cachePath, { prewarm: true, prewarmMaxEntries: 0.5 }); + assert.ok(findQueryCacheEntry( + { version: 1, entries: [] }, + 'k1', + 's1', + { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } + )); + assert.equal(findQueryCacheEntry( + { version: 1, entries: [] }, + 'k2', + 's2', + { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } + ), null); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + } + }, + { + name: 'code-mode query cache records miss then hit and persists payloads', + async run() { + const tempRoot = resolveTestCachePath(root, 'query-cache-contract-code'); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const cacheRootResolved = resolveVersionedCacheRoot(cacheRoot); + + await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'cache-sample.js'), + [ + 'export function greet(name = "world") {', + ' return `greet ${name}`;', + '}', + '' + ].join('\n') + ); + + const env = createQueryCacheEnv(cacheRoot); + + runNodeScript(repoRoot, env, [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--repo', + repoRoot, + '--stage', + 'stage1', + '--mode', + 'code', + '--no-sqlite' + ], 'build index'); + const searchArgs = [ + path.join(root, 'search.js'), + 'greet', + '--mode', + 'code', + '--json', + '--stats', + '--backend', + 'memory', + '--no-ann', + '--repo', + repoRoot + ]; + const first = JSON.parse(runNodeScript(repoRoot, env, searchArgs, 'search (first)')); + const second = JSON.parse(runNodeScript(repoRoot, env, searchArgs, 'search (second)')); + + assert.equal(first?.stats?.cache?.hit, false); + assert.equal(second?.stats?.cache?.hit, true); + + const repoCacheDirs = await fsPromises.readdir(path.join(cacheRootResolved, 'repos')); + assert.ok(repoCacheDirs.length > 0); + const queryCachePath = path.join(cacheRootResolved, 'repos', repoCacheDirs[0], 'query-cache', 'queryCache.json'); + assert.equal(fs.existsSync(queryCachePath), true); + } + }, + { + name: 'extracted-prose query cache persists extracted payloads', + async run() { + const tempRoot = resolveTestCachePath(root, 'query-cache-contract-extracted-payload'); + await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); + const queryCachePath = path.join(tempRoot, 'queryCache.json'); + const key = 'query-cache:extracted-prose'; + const signature = 'signature:extracted-prose'; + await fsPromises.mkdir(path.dirname(queryCachePath), { recursive: true }); + await fsPromises.writeFile(queryCachePath, JSON.stringify({ + version: 1, + entries: [{ + key, + signature, + ts: Date.now(), + payload: { + extractedProse: [{ file: 'src/sample.js', text: 'extracted prose cache sentinel' }] + } + }] + }, null, 2)); + assert.equal(fs.existsSync(queryCachePath), true); + const cacheData = loadQueryCache(queryCachePath); + const cached = findQueryCacheEntry(cacheData, key, signature); + assert.ok(cached); + assert.ok(cached.payload.extractedProse.some((hit) => hit?.file === 'src/sample.js')); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('query cache contract matrix test passed'); diff --git a/tests/retrieval/cache/query-cache-corrupt-evicts-file.test.js b/tests/retrieval/cache/query-cache-corrupt-evicts-file.test.js new file mode 100644 index 000000000..59a2fd6f4 --- /dev/null +++ b/tests/retrieval/cache/query-cache-corrupt-evicts-file.test.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { loadQueryCache } from '../../../src/retrieval/query-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'query-cache-corrupt-evicts-file'); +fs.rmSync(tempRoot, { recursive: true, force: true }); +fs.mkdirSync(tempRoot, { recursive: true }); +const cachePath = path.join(tempRoot, 'query_cache.json'); +fs.writeFileSync(cachePath, '{not-json', 'utf8'); + +const cache = loadQueryCache(cachePath); +assert.equal(Array.isArray(cache?.entries), true, 'expected fallback cache payload'); +assert.equal(cache.entries.length, 0, 'expected empty fallback entries'); +assert.equal(fs.existsSync(cachePath), false, 'expected corrupted query cache file to be removed'); + +console.log('query cache corrupt-evicts-file test passed'); diff --git a/tests/retrieval/cache/query-cache-extracted-prose.test.js b/tests/retrieval/cache/query-cache-extracted-prose.test.js deleted file mode 100644 index c1d0610f3..000000000 --- a/tests/retrieval/cache/query-cache-extracted-prose.test.js +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoId } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; -import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'query-cache-extracted-prose'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const cacheRootResolved = resolveVersionedCacheRoot(cacheRoot); -const srcDir = path.join(repoRoot, 'src'); - -await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); -await fsPromises.mkdir(srcDir, { recursive: true }); - -const commentText = 'extracted prose cache sentinel'; -const source = [ - '/**', - ` * ${commentText}`, - ' */', - 'export function sample() { return 1; }', - '' -].join('\n'); -await fsPromises.writeFile(path.join(srcDir, 'sample.js'), source); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - quality: 'max', - indexing: { - scm: { provider: 'none' }, - generatedPolicy: { - extractedProse: { - prefilter: { enabled: false } - } - }, - extractedProse: { - prefilter: { enabled: false } - } - } - } -}); - -const repoId = getRepoId(repoRoot); -const repoCacheBase = path.join(cacheRootResolved, 'repos', repoId); -const readTail = (filePath, maxLines = 120) => { - try { - const raw = fs.readFileSync(filePath, 'utf8'); - const lines = raw.split(/\r?\n/); - return lines.slice(Math.max(0, lines.length - maxLines)).join('\n'); - } catch { - return ''; - } -}; -const logCrash = () => { - const logsDir = path.join(repoCacheBase, 'logs'); - const crashLog = path.join(logsDir, 'index-crash.log'); - const crashState = path.join(logsDir, 'index-crash-state.json'); - console.error(`Crash logs: ${logsDir}`); - if (fs.existsSync(crashState)) { - const stateText = readTail(crashState); - if (stateText) { - console.error('index-crash-state.json:'); - console.error(stateText); - } - } - if (fs.existsSync(crashLog)) { - const tail = readTail(crashLog); - if (tail) { - console.error('index-crash.log (tail):'); - console.error(tail); - } - } -}; - -const run = (args, label, { includeCrashLog = false } = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - if (result.stdout) console.error(result.stdout.trim()); - if (includeCrashLog) logCrash(); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -run( - [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--repo', repoRoot, '--mode', 'extracted-prose'], - 'build index', - { includeCrashLog: true } -); - -const searchArgs = [ - path.join(root, 'search.js'), - '--repo', - repoRoot, - '--mode', - 'extracted-prose', - '--no-ann', - '--json', - '--stats', - commentText -]; -const first = JSON.parse(run(searchArgs, 'search (first)')); -const second = JSON.parse(run(searchArgs, 'search (second)')); - -if (!first?.stats?.cache || first.stats.cache.hit !== false) { - console.error('Query cache extracted-prose test failed: first request should be cache miss.'); - process.exit(1); -} -if (!second?.stats?.cache || second.stats.cache.hit !== true) { - console.error('Query cache extracted-prose test failed: second request should be cache hit.'); - process.exit(1); -} -const hits = Array.isArray(second.extractedProse) ? second.extractedProse : []; -if (!hits.some((hit) => hit?.file === 'src/sample.js')) { - console.error('Query cache extracted-prose test failed: expected extracted-prose hit missing.'); - process.exit(1); -} - -const repoCacheDirs = await fsPromises.readdir(path.join(cacheRootResolved, 'repos')); -if (!repoCacheDirs.length) { - console.error('Query cache extracted-prose test failed: repo cache not created.'); - process.exit(1); -} -const repoCacheRoot = path.join(cacheRootResolved, 'repos', repoCacheDirs[0]); -const queryCachePath = path.join(repoCacheRoot, 'query-cache', 'queryCache.json'); -if (!fs.existsSync(queryCachePath)) { - console.error(`Query cache extracted-prose test failed: missing cache file at ${queryCachePath}`); - process.exit(1); -} -const cacheData = JSON.parse(await fsPromises.readFile(queryCachePath, 'utf8')); -const entries = Array.isArray(cacheData?.entries) ? cacheData.entries : []; -const cached = entries.find((entry) => - Array.isArray(entry?.payload?.extractedProse) - && entry.payload.extractedProse.some((hit) => hit?.file === 'src/sample.js') -); -if (!cached) { - console.error('Query cache extracted-prose test failed: cached payload missing extracted-prose hits.'); - process.exit(1); -} - -console.log('Query cache extracted-prose test passed'); - diff --git a/tests/retrieval/cache/query-cache-lookup.test.js b/tests/retrieval/cache/query-cache-lookup.test.js deleted file mode 100644 index 7479e24d7..000000000 --- a/tests/retrieval/cache/query-cache-lookup.test.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { - findQueryCacheEntry, - loadQueryCache, - pruneQueryCache -} from '../../../src/retrieval/query-cache.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-query-cache-lookup-')); -const cachePath = path.join(tempRoot, 'queryCache.json'); -const baseTs = Date.now(); - -await fs.writeFile(cachePath, JSON.stringify({ - version: 1, - entries: [ - { key: 'k1', signature: 's1', ts: baseTs - 300, payload: { code: [{ id: 1 }] } }, - { key: 'k1', signature: 's1', ts: baseTs - 200, payload: { code: [{ id: 2 }] } }, - { key: 'k2', signature: 's2', ts: baseTs - 250, payload: { code: [{ id: 3 }] } } - ] -}, null, 2)); - -const cache = loadQueryCache(cachePath); -const entry = findQueryCacheEntry(cache, 'k1', 's1'); -assert.ok(entry, 'expected cache entry lookup'); -assert.equal(entry.ts, baseTs - 200, 'expected newest entry for key/signature'); - -cache.entries.push({ key: 'k1', signature: 's1', ts: baseTs - 100, payload: { code: [{ id: 4 }] } }); -const updated = findQueryCacheEntry(cache, 'k1', 's1'); -assert.ok(updated, 'expected cache entry lookup after in-memory mutation'); -assert.equal(updated.ts, baseTs - 100, 'expected lookup index refresh after mutation'); - -pruneQueryCache(cache, 2); -const stillPresent = findQueryCacheEntry(cache, 'k1', 's1'); -assert.ok(stillPresent, 'expected lookup to remain available after prune'); -assert.equal(stillPresent.ts, baseTs - 100, 'expected newest entry retained after prune'); - -const coldMemoryMiss = findQueryCacheEntry( - { version: 1, entries: [] }, - 'k1', - 's1', - { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } -); -assert.equal( - coldMemoryMiss, - null, - 'expected memory-first lookup to miss before prewarm' -); - -loadQueryCache(cachePath, { prewarm: true, prewarmMaxEntries: 8 }); -const prewarmedHit = findQueryCacheEntry( - { version: 1, entries: [] }, - 'k1', - 's1', - { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } -); -assert.ok( - prewarmedHit, - 'expected prewarm to hydrate hot cache even when disk cache data is reused' -); -assert.equal(prewarmedHit?.key, 'k1', 'expected prewarm hit for requested key'); -assert.equal(prewarmedHit?.signature, 's1', 'expected prewarm hit for requested signature'); - -loadQueryCache(cachePath, { prewarm: true, prewarmMaxEntries: 0.5 }); -const fractionalCapHit = findQueryCacheEntry( - { version: 1, entries: [] }, - 'k1', - 's1', - { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } -); -assert.ok(fractionalCapHit, 'expected fractional prewarm cap to retain at least one hot entry'); -const fractionalCapMiss = findQueryCacheEntry( - { version: 1, entries: [] }, - 'k2', - 's2', - { cachePath, strategy: 'memory-first', memoryFreshMs: 60_000 } -); -assert.equal( - fractionalCapMiss, - null, - 'expected fractional prewarm cap to clamp to one entry instead of disabling trim' -); - -console.log('query cache lookup test passed'); diff --git a/tests/retrieval/cache/query-cache-retrieval-knobs-invalidation.test.js b/tests/retrieval/cache/query-cache-retrieval-knobs-invalidation.test.js deleted file mode 100644 index 4a9c64792..000000000 --- a/tests/retrieval/cache/query-cache-retrieval-knobs-invalidation.test.js +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'query-cache-retrieval-knobs'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const cacheRootResolved = resolveVersionedCacheRoot(cacheRoot); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); - -await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const buildTestConfig = ({ - relationBoostEnabled, - annCandidateCap, - sqliteTailLatencyTuning = false, - sqliteFtsOverfetchRowCap = null -}) => ({ - quality: 'max', - indexing: { - scm: { provider: 'none' } - }, - retrieval: { - relationBoost: { - enabled: relationBoostEnabled, - perCall: 0.5, - perUse: 0.2, - maxBoost: 2.0 - }, - annCandidateCap, - annCandidateMinDocCount: 100, - annCandidateMaxDocCount: 20000, - sqliteTailLatencyTuning, - ...(Number.isFinite(Number(sqliteFtsOverfetchRowCap)) - ? { sqliteFtsOverfetchRowCap: Number(sqliteFtsOverfetchRowCap) } - : {}) - } -}); - -const envA = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: buildTestConfig({ relationBoostEnabled: false, annCandidateCap: 20000 }) -}); -const envB = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: buildTestConfig({ relationBoostEnabled: true, annCandidateCap: 20000 }) -}); -const envC = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: buildTestConfig({ relationBoostEnabled: true, annCandidateCap: 100 }) -}); -const envD = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: buildTestConfig({ - relationBoostEnabled: false, - annCandidateCap: 20000, - sqliteTailLatencyTuning: true, - sqliteFtsOverfetchRowCap: 4096 - }) -}); -const envE = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: buildTestConfig({ - relationBoostEnabled: false, - annCandidateCap: 20000, - sqliteTailLatencyTuning: true, - sqliteFtsOverfetchRowCap: 2048 - }) -}); - -const run = (args, label, env) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], 'build index', envA); - -const searchArgs = [ - path.join(root, 'search.js'), - 'greet', - '--json', - '--stats', - '--backend', - 'memory', - '--repo', - repoRoot -]; - -const runSearch = (env, label, expectedHit, args = searchArgs) => { - const payload = JSON.parse(run(args, label, env)); - const cacheHit = payload?.stats?.cache?.hit; - if (cacheHit !== expectedHit) { - console.error(`${label} failed: expected cache hit=${expectedHit}, got ${cacheHit}`); - process.exit(1); - } -}; - -runSearch(envA, 'search config A first', false); -runSearch(envA, 'search config A second', true); -runSearch(envB, 'search config B first', false); -runSearch(envB, 'search config B second', true); -runSearch(envC, 'search config C first', false); -runSearch(envC, 'search config C second', true); -const bm25ArgsA = [ - ...searchArgs, - '--bm25-k1', - '1.2', - '--bm25-b', - '0.75' -]; -const bm25ArgsB = [ - ...searchArgs, - '--bm25-k1', - '1.7', - '--bm25-b', - '0.75' -]; -runSearch(envA, 'search bm25 A first', false, bm25ArgsA); -runSearch(envA, 'search bm25 A second', true, bm25ArgsA); -runSearch(envA, 'search bm25 B first', false, bm25ArgsB); -runSearch(envA, 'search bm25 B second', true, bm25ArgsB); -runSearch(envD, 'search sqlite tail tuning A first', false); -runSearch(envD, 'search sqlite tail tuning A second', true); -runSearch(envE, 'search sqlite tail tuning B first', false); -runSearch(envE, 'search sqlite tail tuning B second', true); - -const repoCacheDirs = await fsPromises.readdir(path.join(cacheRootResolved, 'repos')); -if (!repoCacheDirs.length) { - console.error('query cache retrieval knobs invalidation test failed: repo cache not created.'); - process.exit(1); -} -const repoCacheRoot = path.join(cacheRootResolved, 'repos', repoCacheDirs[0]); -const queryCachePath = path.join(repoCacheRoot, 'query-cache', 'queryCache.json'); -if (!fs.existsSync(queryCachePath)) { - console.error(`query cache retrieval knobs invalidation test failed: missing cache file at ${queryCachePath}`); - process.exit(1); -} - -console.log('query cache retrieval knobs invalidation test passed'); diff --git a/tests/retrieval/cache/query-cache-signature-sharded-chunk-meta.test.js b/tests/retrieval/cache/query-cache-signature-sharded-chunk-meta.test.js deleted file mode 100644 index 6cace2870..000000000 --- a/tests/retrieval/cache/query-cache-signature-sharded-chunk-meta.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { getIndexSignature } from '../../../src/retrieval/cli-index.js'; - -applyTestEnv(); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-chunk-meta-sig-')); -const codeDir = path.join(rootDir, 'index-code'); -const partsDir = path.join(codeDir, 'chunk_meta.parts'); -await fs.mkdir(partsDir, { recursive: true }); - -const partPath = path.join(partsDir, 'chunk_meta.part-0000.jsonl'); -await fs.writeFile(partPath, JSON.stringify({ id: 0, file: 'src/a.js', start: 0, end: 1 }) + '\n'); - -const metaPath = path.join(codeDir, 'chunk_meta.meta.json'); -const meta = { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: 1, - totalBytes: 1, - maxPartRecords: 1, - maxPartBytes: 1, - targetMaxBytes: 1, - parts: [{ path: 'chunk_meta.parts/chunk_meta.part-0000.jsonl', records: 1, bytes: 1 }] -}; -await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); - -const signature = await getIndexSignature({ - useSqlite: false, - backendLabel: 'memory', - sqliteCodePath: null, - sqliteProsePath: null, - runRecords: false, - runExtractedProse: false, - includeExtractedProse: false, - root: rootDir, - userConfig: {} -}); - -assert.equal( - signature.modes?.code?.includes('chunk_meta.meta.json:'), - true, - 'signature should include sharded chunk_meta metadata' -); -assert.equal( - signature.modes?.code?.includes('|parts:'), - true, - 'signature should include sharded chunk_meta part signatures' -); - -console.log('query cache signature sharded chunk_meta test passed'); diff --git a/tests/retrieval/cache/query-cache.test.js b/tests/retrieval/cache/query-cache.test.js deleted file mode 100644 index bcbf65db8..000000000 --- a/tests/retrieval/cache/query-cache.test.js +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'query-cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const cacheRootResolved = resolveVersionedCacheRoot(cacheRoot); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); - -await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { quality: 'max' } -}); - -function run(args, label, cwd, envVars) { - const result = spawnSync(process.execPath, args, { - cwd, - env: envVars, - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -} - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], 'build index', repoRoot, env); - -const query = 'greet'; -const searchArgs = [ - path.join(root, 'search.js'), - query, - '--json', - '--stats', - '--backend', - 'memory', - '--no-ann', - '--repo', - repoRoot -]; -const first = JSON.parse(run(searchArgs, 'search (first)', repoRoot, env)); -const second = JSON.parse(run(searchArgs, 'search (second)', repoRoot, env)); - -if (!first?.stats?.cache || first.stats.cache.hit !== false) { - console.error('Query cache test failed: first request should be cache miss.'); - process.exit(1); -} -if (!second?.stats?.cache || second.stats.cache.hit !== true) { - console.error('Query cache test failed: second request should be cache hit.'); - process.exit(1); -} - -const repoCacheDirs = await fsPromises.readdir(path.join(cacheRootResolved, 'repos')); -if (!repoCacheDirs.length) { - console.error('Query cache test failed: repo cache not created.'); - process.exit(1); -} -const repoCacheRoot = path.join(cacheRootResolved, 'repos', repoCacheDirs[0]); -const queryCachePath = path.join(repoCacheRoot, 'query-cache', 'queryCache.json'); -if (!fs.existsSync(queryCachePath)) { - console.error(`Query cache test failed: missing cache file at ${queryCachePath}`); - process.exit(1); -} - -console.log('Query cache test passed'); diff --git a/tests/retrieval/cache/query-cache-disk-first-miss.test.js b/tests/retrieval/cache/query-disk-first-miss.test.js similarity index 100% rename from tests/retrieval/cache/query-cache-disk-first-miss.test.js rename to tests/retrieval/cache/query-disk-first-miss.test.js diff --git a/tests/retrieval/cache/query-plan-cache-bounds.test.js b/tests/retrieval/cache/query-plan-cache-bounds.test.js deleted file mode 100644 index b1615f202..000000000 --- a/tests/retrieval/cache/query-plan-cache-bounds.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from '../pipeline/query-plan-helpers.js'; - -const cache = createQueryPlanCache({ maxEntries: 2, ttlMs: 1000 }); - -const makeEntry = (query) => { - const inputs = createPlanInputs({ query }); - const plan = buildTestPlan(inputs); - const configSignature = buildPlanConfigSignature(inputs); - const indexSignature = buildPlanIndexSignature({ code: `sig:${query}` }); - const key = buildPlanCacheKey({ query, configSignature, indexSignature }); - const entry = createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: key.payload - }); - return { key: key.key, configSignature, indexSignature, entry }; -}; - -const a = makeEntry('alpha'); -const b = makeEntry('beta'); -const c = makeEntry('gamma'); - -cache.set(a.key, a.entry); -cache.set(b.key, b.entry); -cache.set(c.key, c.entry); -assert.ok(cache.size() <= 2, 'expected query-plan cache to honor maxEntries bound'); -assert.equal(cache.get(a.key, { configSignature: a.configSignature, indexSignature: a.indexSignature }), null); -assert.ok(cache.get(b.key, { configSignature: b.configSignature, indexSignature: b.indexSignature })); -assert.ok(cache.get(c.key, { configSignature: c.configSignature, indexSignature: c.indexSignature })); - -console.log('query plan cache bounds test passed'); diff --git a/tests/retrieval/cache/query-retrieval-knobs-invalidation.test.js b/tests/retrieval/cache/query-retrieval-knobs-invalidation.test.js new file mode 100644 index 000000000..bcb345890 --- /dev/null +++ b/tests/retrieval/cache/query-retrieval-knobs-invalidation.test.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; +import { buildQueryCacheKey } from '../../../src/retrieval/cli-index.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; +import { runNode } from '../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'query-cache-retrieval-knobs'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const cacheRootResolved = resolveVersionedCacheRoot(cacheRoot); + +await rmDirRecursive(tempRoot, { retries: 6, delayMs: 120 }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'cache-knobs.js'), + [ + 'export function greet(name = "world") {', + ' return `greet ${name}`;', + '}', + '' + ].join('\n') +); + +const buildTestConfig = ({ + relationBoostEnabled, + annCandidateCap, + sqliteTailLatencyTuning = false, + sqliteFtsOverfetchRowCap = null +}) => ({ + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + scm: { provider: 'none' } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + }, + retrieval: { + relationBoost: { + enabled: relationBoostEnabled, + perCall: 0.5, + perUse: 0.2, + maxBoost: 2.0 + }, + annCandidateCap, + annCandidateMinDocCount: 100, + annCandidateMaxDocCount: 20000, + sqliteTailLatencyTuning, + ...(Number.isFinite(Number(sqliteFtsOverfetchRowCap)) + ? { sqliteFtsOverfetchRowCap: Number(sqliteFtsOverfetchRowCap) } + : {}) + } +}); + +const envA = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: buildTestConfig({ relationBoostEnabled: false, annCandidateCap: 20000 }), + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + syncProcess: false +}); + +const run = (args, label, env) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'pipe' }); + return result.stdout || ''; +}; + +run([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--repo', + repoRoot, + '--stage', + 'stage1', + '--mode', + 'code', + '--no-sqlite' +], 'build index', envA); + +const searchArgs = [ + path.join(root, 'search.js'), + 'greet', + '--mode', + 'code', + '--json', + '--stats', + '--backend', + 'memory', + '--repo', + repoRoot +]; + +const makeCacheKey = (overrides = {}) => buildQueryCacheKey({ + query: 'greet', + backend: 'memory', + mode: 'code', + topN: null, + sqliteFtsRequested: false, + ann: false, + annBackend: null, + annMode: null, + annProvider: null, + annExtension: false, + annAdaptiveProviders: null, + relationBoost: { enabled: false, perCall: 0.5, perUse: 0.2, maxBoost: 2.0 }, + annCandidatePolicy: { cap: 20000, minDocCount: 100, maxDocCount: 20000 }, + bm25: { k1: null, b: null }, + scoreBlend: null, + rrf: null, + fieldWeights: null, + symbolBoost: null, + denseVectorMode: null, + intent: null, + minhashMaxDocs: null, + maxCandidates: null, + sparseBackend: null, + explain: false, + sqliteFtsNormalize: null, + sqliteFtsProfile: null, + sqliteFtsWeights: null, + sqliteFtsVariant: { trigram: false, stemming: false }, + sqliteFtsTuning: { + tailLatencyTuning: false, + overfetch: { rowCap: null, timeBudgetMs: null, chunkSize: null } + }, + comments: { enabled: false }, + models: null, + embeddings: null, + contextExpansion: null, + graphRanking: null, + filters: null, + asOf: null, + ...overrides +}).key; + +const baseKey = makeCacheKey(); +const relationKey = makeCacheKey({ + relationBoost: { enabled: true, perCall: 0.5, perUse: 0.2, maxBoost: 2.0 } +}); +const bm25Key = makeCacheKey({ bm25: { k1: 1.7, b: 0.75 } }); +const sqliteTuningKey = makeCacheKey({ + sqliteFtsTuning: { + tailLatencyTuning: true, + overfetch: { rowCap: 4096, timeBudgetMs: null, chunkSize: null } + } +}); +if (new Set([baseKey, relationKey, bm25Key, sqliteTuningKey]).size !== 4) { + console.error('query cache retrieval knobs invalidation test failed: retrieval knobs did not produce distinct cache keys.'); + process.exit(1); +} + +const runSearch = (env, label, expectedHit, args = searchArgs) => { + const payload = JSON.parse(run(args, label, env)); + const cacheHit = payload?.stats?.cache?.hit; + if (cacheHit !== expectedHit) { + console.error(`${label} failed: expected cache hit=${expectedHit}, got ${cacheHit}`); + process.exit(1); + } +}; + +runSearch(envA, 'search config A first', false); +runSearch(envA, 'search config A second', true); +const bm25ArgsB = [ + ...searchArgs, + '--bm25-k1', + '1.7', + '--bm25-b', + '0.75' +]; +runSearch(envA, 'search bm25 B first', false, bm25ArgsB); + +const repoCacheDirs = await fsPromises.readdir(path.join(cacheRootResolved, 'repos')); +if (!repoCacheDirs.length) { + console.error('query cache retrieval knobs invalidation test failed: repo cache not created.'); + process.exit(1); +} +const repoCacheRoot = path.join(cacheRootResolved, 'repos', repoCacheDirs[0]); +const queryCachePath = path.join(repoCacheRoot, 'query-cache', 'queryCache.json'); +if (!fs.existsSync(queryCachePath)) { + console.error(`query cache retrieval knobs invalidation test failed: missing cache file at ${queryCachePath}`); + process.exit(1); +} + +console.log('query cache retrieval knobs invalidation test passed'); diff --git a/tests/retrieval/cache/query-cache-signature-passthrough.test.js b/tests/retrieval/cache/query-signature-passthrough.test.js similarity index 100% rename from tests/retrieval/cache/query-cache-signature-passthrough.test.js rename to tests/retrieval/cache/query-signature-passthrough.test.js diff --git a/tests/retrieval/cli/cli-dictionary-summary-languages.test.js b/tests/retrieval/cli/dictionary-summary-languages.test.js similarity index 100% rename from tests/retrieval/cli/cli-dictionary-summary-languages.test.js rename to tests/retrieval/cli/dictionary-summary-languages.test.js diff --git a/tests/retrieval/cli/cli-options-smoke.test.js b/tests/retrieval/cli/options-smoke.test.js similarity index 100% rename from tests/retrieval/cli/cli-options-smoke.test.js rename to tests/retrieval/cli/options-smoke.test.js diff --git a/tests/retrieval/cli/run-config-contract.test.js b/tests/retrieval/cli/run-config-contract.test.js new file mode 100644 index 000000000..bb4366e12 --- /dev/null +++ b/tests/retrieval/cli/run-config-contract.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + RUN_CONFIG_KEYS, + resolveRunConfig +} from '../../../src/retrieval/cli/resolve-run-config.js'; + +const buildNormalizedConfig = () => { + const normalized = {}; + for (const [index, key] of RUN_CONFIG_KEYS.entries()) { + if (key === 'scoreMode') continue; + normalized[key] = `${key}:${index}`; + } + normalized.annEnabled = false; + normalized.scoreBlendEnabled = false; + normalized.scoreBlendSparseWeight = 0.25; + normalized.scoreBlendAnnWeight = 0.75; + normalized.rrfEnabled = true; + return normalized; +}; + +const normalized = buildNormalizedConfig(); +const resolved = resolveRunConfig({ normalized, scoreModeOverride: null }); + +assert.deepEqual(Object.keys(resolved), RUN_CONFIG_KEYS); +for (const key of RUN_CONFIG_KEYS) { + if (key === 'scoreMode') continue; + assert.equal(resolved[key], normalized[key], `expected ${key} to pass through`); +} +assert.equal(resolved.scoreMode, null); + +const dense = resolveRunConfig({ normalized, scoreModeOverride: 'dense' }); +assert.equal(dense.scoreMode, 'dense'); +assert.equal(dense.annEnabled, true); +assert.equal(dense.scoreBlendEnabled, true); +assert.equal(dense.scoreBlendSparseWeight, 0); +assert.equal(dense.scoreBlendAnnWeight, 1); +assert.equal(dense.rrfEnabled, false); + +const hybrid = resolveRunConfig({ normalized, scoreModeOverride: 'hybrid' }); +assert.equal(hybrid.scoreMode, 'hybrid'); +assert.equal(hybrid.annEnabled, true); +assert.equal(hybrid.scoreBlendEnabled, true); +assert.equal(hybrid.scoreBlendSparseWeight, 0.5); +assert.equal(hybrid.scoreBlendAnnWeight, 0.5); +assert.equal(hybrid.rrfEnabled, false); + +const sparse = resolveRunConfig({ normalized, scoreModeOverride: 'sparse' }); +assert.equal(sparse.scoreMode, 'sparse'); +assert.equal(sparse.annEnabled, false); +assert.equal(sparse.scoreBlendEnabled, false); +assert.equal(sparse.scoreBlendSparseWeight, normalized.scoreBlendSparseWeight); +assert.equal(sparse.scoreBlendAnnWeight, normalized.scoreBlendAnnWeight); +assert.equal(sparse.rrfEnabled, false); + +assert.throws( + () => resolveRunConfig({ normalized, scoreModeOverride: 'invalid' }), + /Invalid score mode "invalid"/ +); + +console.log('retrieval run config contract test passed'); diff --git a/tests/retrieval/cli/run-search-module-load.test.js b/tests/retrieval/cli/run-search-module-load.test.js index 4651233e6..4c97d00f2 100644 --- a/tests/retrieval/cli/run-search-module-load.test.js +++ b/tests/retrieval/cli/run-search-module-load.test.js @@ -4,8 +4,8 @@ import { applyTestEnv } from '../../helpers/test-env.js'; applyTestEnv(); -const runSearchModule = await import('../../../src/retrieval/cli/run-search.js'); -assert.equal(typeof runSearchModule.runSearchCli, 'function', 'expected run-search module to export runSearchCli'); +const planRunnerModule = await import('../../../src/retrieval/cli/run-search/plan-runner.js'); +assert.equal(typeof planRunnerModule.runSearchCli, 'function', 'expected plan-runner module to export runSearchCli'); const retrievalCliModule = await import('../../../src/retrieval/cli.js'); assert.equal(typeof retrievalCliModule.runSearchCli, 'function', 'expected retrieval cli module to export runSearchCli'); diff --git a/tests/retrieval/context-expansion/context-expansion-contract-matrix.test.js b/tests/retrieval/context-expansion/context-expansion-contract-matrix.test.js new file mode 100644 index 000000000..d98fb731b --- /dev/null +++ b/tests/retrieval/context-expansion/context-expansion-contract-matrix.test.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { buildContextIndex, expandContext } from '../../../src/retrieval/context-expansion.js'; +import { graphRelationsFromEdges } from '../../graph/helpers/graph-fixtures.js'; + +const fixtureRoot = path.join( + process.cwd(), + 'tests', + 'fixtures', + 'retrieval', + 'context-expansion' +); + +const GRAPH_GENERATED_AT = '2026-01-01T00:00:00.000Z'; +const DEFAULT_EXPAND_OPTIONS = { + maxPerHit: 5, + maxTotal: 5 +}; + +const createSeedTargetChunkMeta = () => [ + { id: 0, chunkUid: 'seed', file: 'src/a.js', name: 'alpha' }, + { id: 1, chunkUid: 'target', file: 'src/b.js', name: 'beta' } +]; + +const createGraphRelations = (options = {}) => graphRelationsFromEdges({ + generatedAt: GRAPH_GENERATED_AT, + ...options +}); + +const runExpandContextCase = ({ + hits = [{ id: 0 }], + chunkMeta, + fileRelations = null, + graphRelations = null, + contextIndex = null, + allowedIds = null, + options = {} +}) => expandContext({ + hits, + chunkMeta, + fileRelations, + graphRelations, + repoMap: null, + contextIndex, + allowedIds, + options: { + ...DEFAULT_EXPAND_OPTIONS, + ...options + } +}); + +const cases = [ + { + name: 'expansion includes call and import targets while honoring allowedIds', + run() { + const chunkMeta = [ + { id: 0, file: 'src/a.js', name: 'alpha', codeRelations: { calls: [['alpha', 'beta']] } }, + { id: 1, file: 'src/b.js', name: 'beta' }, + { id: 2, file: 'src/c.js', name: 'gamma' } + ]; + const fileRelations = new Map([ + ['src/a.js', { importLinks: ['src/c.js'], usages: ['beta'], exports: [] }] + ]); + const hits = [{ id: 0, file: 'src/a.js' }]; + const contextIndex = buildContextIndex({ chunkMeta, repoMap: null }); + const expansionCase = { + hits, + chunkMeta, + fileRelations, + contextIndex, + options: { + maxTotal: 10, + includeCalls: true, + includeImports: true, + includeUsages: true + } + }; + + const expanded = runExpandContextCase(expansionCase); + const ids = new Set(expanded.contextHits.map((hit) => hit.id)); + assert.equal(ids.has(1), true); + assert.equal(ids.has(2), true); + + const filtered = runExpandContextCase({ + ...expansionCase, + allowedIds: new Set([2]) + }); + const filteredIds = new Set(filtered.contextHits.map((hit) => hit.id)); + assert.deepEqual([...filteredIds], [2]); + } + }, + { + name: 'shuffled chunk meta resolves docIds through chunkUid map', + run() { + const chunkMeta = JSON.parse( + fs.readFileSync(path.join(fixtureRoot, 'chunk-meta-shuffled.json'), 'utf8') + ); + const graphRelations = JSON.parse( + fs.readFileSync(path.join(fixtureRoot, 'graph-relations-basic.json'), 'utf8') + ); + const result = runExpandContextCase({ + hits: [{ id: 7 }], + chunkMeta, + graphRelations, + options: { + maxPerHit: 5, + maxTotal: 5, + includeCalls: true + } + }); + const ids = new Set(result.contextHits.map((hit) => hit.id)); + assert.equal(ids.has(42), true); + } + }, + { + name: 'call reason takes precedence over usage', + run() { + const chunkMeta = createSeedTargetChunkMeta(); + const graphRelations = createGraphRelations({ + callEdges: [['seed', 'target']], + usageEdges: [['seed', 'target']] + }); + const result = runExpandContextCase({ + chunkMeta, + graphRelations, + options: { + includeCalls: true, + includeUsages: true + } + }); + assert.equal((result.contextHits[0]?.context?.reason || '').startsWith('call'), true); + } + }, + { + name: 'work budget prevents candidate explosion and records truncation', + run() { + const neighborCount = 50; + const neighbors = Array.from({ length: neighborCount }, (_, index) => `c${index}`); + const chunkMeta = [ + { id: 0, chunkUid: 'seed', file: 'src/seed.js', name: 'seed' }, + ...neighbors.map((id, index) => ({ + id: index + 1, + chunkUid: id, + file: `src/${id}.js`, + name: id + })) + ]; + const graphRelations = createGraphRelations({ + callEdges: [['seed', neighbors]] + }); + const result = runExpandContextCase({ + chunkMeta, + graphRelations, + options: { + maxPerHit: 10, + maxTotal: 10, + maxWorkUnits: 3, + includeCalls: true + } + }); + assert.equal(result.stats.workUnitsUsed > 3, false); + const caps = new Set((result.stats.truncation || []).map((entry) => entry.cap)); + assert.equal(caps.has('maxWorkUnits'), true); + } + }, + { + name: 'output is deterministic for identical inputs', + run() { + const chunkMeta = createSeedTargetChunkMeta(); + const graphRelations = createGraphRelations({ + callEdges: [['seed', 'target']] + }); + const buildOnce = () => runExpandContextCase({ + chunkMeta, + graphRelations, + options: { + includeCalls: true + } + }); + assert.equal(JSON.stringify(buildOnce()), JSON.stringify(buildOnce())); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('context expansion contract matrix test passed'); diff --git a/tests/retrieval/context-expansion/context-expansion-determinism.test.js b/tests/retrieval/context-expansion/context-expansion-determinism.test.js deleted file mode 100644 index 69de64cf6..000000000 --- a/tests/retrieval/context-expansion/context-expansion-determinism.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import { expandContext } from '../../../src/retrieval/context-expansion.js'; - -const chunkMeta = [ - { id: 0, chunkUid: 'seed', file: 'src/a.js', name: 'alpha' }, - { id: 1, chunkUid: 'target', file: 'src/b.js', name: 'beta' } -]; - -const graphRelations = { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - callGraph: { - nodeCount: 2, - edgeCount: 1, - nodes: [ - { id: 'seed', out: ['target'], in: [] }, - { id: 'target', out: [], in: ['seed'] } - ] - }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } -}; - -const buildOnce = () => expandContext({ - hits: [{ id: 0 }], - chunkMeta, - graphRelations, - options: { - maxPerHit: 5, - maxTotal: 5, - includeCalls: true - } -}); - -const first = JSON.stringify(buildOnce()); -const second = JSON.stringify(buildOnce()); - -if (first !== second) { - console.error('Expected deterministic context expansion output.'); - process.exit(1); -} - -console.log('context expansion determinism test passed'); diff --git a/tests/retrieval/context-expansion/context-expansion-no-candidate-explosion.test.js b/tests/retrieval/context-expansion/context-expansion-no-candidate-explosion.test.js deleted file mode 100644 index b8e7e86f6..000000000 --- a/tests/retrieval/context-expansion/context-expansion-no-candidate-explosion.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import { expandContext } from '../../../src/retrieval/context-expansion.js'; - -const neighborCount = 50; -const neighbors = Array.from({ length: neighborCount }, (_, index) => `c${index}`); -const chunkMeta = [ - { id: 0, chunkUid: 'seed', file: 'src/seed.js', name: 'seed' }, - ...neighbors.map((id, index) => ({ - id: index + 1, - chunkUid: id, - file: `src/${id}.js`, - name: id - })) -]; - -const graphRelations = { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - callGraph: { - nodeCount: neighborCount + 1, - edgeCount: neighborCount, - nodes: [ - { id: 'seed', out: neighbors, in: [] }, - ...neighbors.map((id) => ({ id, out: [], in: ['seed'] })) - ] - }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } -}; - -const result = expandContext({ - hits: [{ id: 0 }], - chunkMeta, - graphRelations, - options: { - maxPerHit: 10, - maxTotal: 10, - maxWorkUnits: 3, - includeCalls: true - } -}); - -if (result.stats.workUnitsUsed > 3) { - console.error('Expected work budget to bound candidate scans.'); - process.exit(1); -} - -const caps = new Set((result.stats.truncation || []).map((entry) => entry.cap)); -if (!caps.has('maxWorkUnits')) { - console.error('Expected truncation to record maxWorkUnits.'); - process.exit(1); -} - -console.log('context expansion work budget test passed'); diff --git a/tests/retrieval/context-expansion/context-expansion-reason-precedence.test.js b/tests/retrieval/context-expansion/context-expansion-reason-precedence.test.js deleted file mode 100644 index 608652ca1..000000000 --- a/tests/retrieval/context-expansion/context-expansion-reason-precedence.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import { expandContext } from '../../../src/retrieval/context-expansion.js'; - -const chunkMeta = [ - { id: 0, chunkUid: 'seed', file: 'src/a.js', name: 'alpha' }, - { id: 1, chunkUid: 'target', file: 'src/b.js', name: 'beta' } -]; - -const graphRelations = { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - callGraph: { - nodeCount: 2, - edgeCount: 1, - nodes: [ - { id: 'seed', out: ['target'], in: [] }, - { id: 'target', out: [], in: ['seed'] } - ] - }, - usageGraph: { - nodeCount: 2, - edgeCount: 1, - nodes: [ - { id: 'seed', out: ['target'], in: [] }, - { id: 'target', out: [], in: ['seed'] } - ] - }, - importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } -}; - -const result = expandContext({ - hits: [{ id: 0 }], - chunkMeta, - graphRelations, - options: { - maxPerHit: 5, - maxTotal: 5, - includeCalls: true, - includeUsages: true - } -}); - -const firstReason = result.contextHits[0]?.context?.reason || ''; -if (!firstReason.startsWith('call')) { - console.error('Expected call reason to take precedence over usage.'); - process.exit(1); -} - -console.log('context expansion reason precedence test passed'); diff --git a/tests/retrieval/context-expansion/context-expansion-shuffled-chunkmeta.test.js b/tests/retrieval/context-expansion/context-expansion-shuffled-chunkmeta.test.js deleted file mode 100644 index 71a95e965..000000000 --- a/tests/retrieval/context-expansion/context-expansion-shuffled-chunkmeta.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { expandContext } from '../../../src/retrieval/context-expansion.js'; - -const fixtureRoot = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'retrieval', - 'context-expansion' -); - -const chunkMeta = JSON.parse( - fs.readFileSync(path.join(fixtureRoot, 'chunk-meta-shuffled.json'), 'utf8') -); -const graphRelations = JSON.parse( - fs.readFileSync(path.join(fixtureRoot, 'graph-relations-basic.json'), 'utf8') -); - -const result = expandContext({ - hits: [{ id: 7 }], - chunkMeta, - graphRelations, - options: { - maxPerHit: 5, - maxTotal: 5, - includeCalls: true - } -}); - -const ids = new Set(result.contextHits.map((hit) => hit.id)); -if (!ids.has(42)) { - console.error('Expected context expansion to resolve docId via chunkUid map.'); - process.exit(1); -} - -console.log('context expansion shuffled chunk meta test passed'); diff --git a/tests/retrieval/context-pack/context-pack-assembly.test.js b/tests/retrieval/context-pack/context-pack-assembly.test.js deleted file mode 100644 index 3d00521ac..000000000 --- a/tests/retrieval/context-pack/context-pack-assembly.test.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { assembleCompositeContextPack } from '../../../src/context-pack/assemble.js'; - -const repoRoot = process.cwd(); -const samplePath = path.join(repoRoot, 'tests', 'fixtures', 'context-pack', 'sample.js'); -const fileText = fs.readFileSync(samplePath, 'utf8'); -const start = fileText.indexOf('function alpha'); -const end = fileText.indexOf('}', start) + 1; - -const chunkMeta = [ - { - id: 0, - file: 'tests/fixtures/context-pack/sample.js', - chunkUid: 'chunk-alpha', - start, - end, - startLine: 1, - endLine: 3, - docmeta: { - inferredTypes: { - returns: [{ type: 'number', source: 'heur', confidence: 0.9 }] - } - } - } -]; - -const graphRelations = { - callGraph: { - nodes: [ - { id: 'chunk-alpha', out: [] } - ] - }, - usageGraph: { nodes: [] }, - importGraph: { nodes: [] } -}; - -const pack = assembleCompositeContextPack({ - seed: { type: 'chunk', chunkUid: 'chunk-alpha' }, - chunkMeta, - repoRoot, - graphRelations, - includeGraph: true, - includeTypes: true, - includeRisk: false, - depth: 1, - maxBytes: 200, - indexCompatKey: 'compat-context-pack' -}); - -assert(pack.primary.excerpt.includes('function alpha'), 'expected primary excerpt to include function alpha'); -assert(pack.graph, 'expected graph slice to be present'); -assert(pack.types?.facts?.length > 0, 'expected type facts when includeTypes=true'); - -console.log('context pack assembly test passed'); diff --git a/tests/retrieval/context/context-expansion.test.js b/tests/retrieval/context/context-expansion.test.js deleted file mode 100644 index 5623f6491..000000000 --- a/tests/retrieval/context/context-expansion.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import { buildContextIndex, expandContext } from '../../../src/retrieval/context-expansion.js'; - -const chunkMeta = [ - { id: 0, file: 'src/a.js', name: 'alpha', codeRelations: { calls: [['alpha', 'beta']] } }, - { id: 1, file: 'src/b.js', name: 'beta' }, - { id: 2, file: 'src/c.js', name: 'gamma' } -]; - -const fileRelations = new Map([ - ['src/a.js', { importLinks: ['src/c.js'], usages: ['beta'], exports: [] }] -]); - -const hits = [{ id: 0, file: 'src/a.js' }]; -const contextIndex = buildContextIndex({ chunkMeta, repoMap: null }); -const contextResult = expandContext({ - hits, - chunkMeta, - fileRelations, - repoMap: null, - contextIndex, - options: { - maxPerHit: 5, - maxTotal: 10, - includeCalls: true, - includeImports: true, - includeUsages: true - } -}); - -const ids = new Set(contextResult.contextHits.map((hit) => hit.id)); -if (!ids.has(1) || !ids.has(2)) { - console.error('Expected context expansion to include call and import targets.'); - process.exit(1); -} - -const filteredResult = expandContext({ - hits, - chunkMeta, - fileRelations, - repoMap: null, - contextIndex, - allowedIds: new Set([2]), - options: { - maxPerHit: 5, - maxTotal: 10, - includeCalls: true, - includeImports: true, - includeUsages: true - } -}); -const filteredIds = new Set(filteredResult.contextHits.map((hit) => hit.id)); -if (filteredIds.size !== 1 || !filteredIds.has(2)) { - console.error('Expected context expansion to honor allowedIds.'); - process.exit(1); -} - -console.log('context expansion test passed'); diff --git a/tests/retrieval/contracts/compact-json.test.js b/tests/retrieval/contracts/compact-json.test.js index ae74a1c16..b509653fd 100644 --- a/tests/retrieval/contracts/compact-json.test.js +++ b/tests/retrieval/contracts/compact-json.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { runNode } from '../../helpers/run-node.js'; const { root, fixtureRoot, env } = await ensureFixtureIndex({ fixtureName: 'sample', @@ -9,8 +9,7 @@ const { root, fixtureRoot, env } = await ensureFixtureIndex({ cacheScope: 'shared' }); -const result = spawnSync( - process.execPath, +const result = runNode( [ path.join(root, 'search.js'), 'message', @@ -22,14 +21,12 @@ const result = spawnSync( '--repo', fixtureRoot ], - { cwd: fixtureRoot, env, encoding: 'utf8' } + 'Fixture compact JSON failed: search error.', + fixtureRoot, + env, + { stdio: 'pipe' } ); -if (result.status !== 0) { - console.error('Fixture compact JSON failed: search error.'); - process.exit(result.status ?? 1); -} - const payload = JSON.parse(result.stdout || '{}'); const compactHits = [...(payload.code || []), ...(payload.prose || [])]; if (!compactHits.length) { diff --git a/tests/retrieval/contracts/result-shape.test.js b/tests/retrieval/contracts/result-shape.test.js index 993596cc3..165397501 100644 --- a/tests/retrieval/contracts/result-shape.test.js +++ b/tests/retrieval/contracts/result-shape.test.js @@ -12,7 +12,7 @@ const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); const payload = await runSearch({ query: 'message', mode: 'code', - args: ['--explain'] + args: ['--backend', 'memory', '--explain'] }); const hit = (payload.code || [])[0]; if (!hit) { diff --git a/tests/retrieval/contracts/score-breakdown-contract-parity.test.js b/tests/retrieval/contracts/score-breakdown-contract-parity.test.js index 09e378a23..a868fe0b8 100644 --- a/tests/retrieval/contracts/score-breakdown-contract-parity.test.js +++ b/tests/retrieval/contracts/score-breakdown-contract-parity.test.js @@ -1,121 +1,24 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; +import { + createScoreBreakdownHits, + EXPECTED_SCORE_BREAKDOWN_KEYS +} from './score-breakdown-fixture.js'; -const expectedKeys = [ - 'schemaVersion', - 'selected', - 'sparse', - 'ann', - 'rrf', - 'blend', - 'symbol', - 'phrase', - 'relation', - 'graph' -]; - -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: true, - sqliteFtsRequested: true, - sqliteFtsRoutingByMode: resolveSqliteFtsRoutingByMode({ - useSqlite: true, - sqliteFtsRequested: true, - sqliteFtsExplicit: false, - runCode: true, - runProse: true, - runExtractedProse: false, - runRecords: false - }), - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 3, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => new Set([0]), - getTokenIndexForQuery: () => ({ - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }), - rankSqliteFts: () => [{ idx: 0, score: 2 }], - rankVectorAnnSqlite: () => [], - sqliteHasFts: (mode) => mode === 'prose', - signal: null, - rrf: { enabled: false } -}); - -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: { - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; - -const codeHit = (await pipeline(idx, 'code', null))[0]; -const proseHit = (await pipeline(idx, 'prose', null))[0]; +const { codeHit, proseHit } = await createScoreBreakdownHits(); assert.ok(codeHit?.scoreBreakdown, 'expected code hit scoreBreakdown'); assert.ok(proseHit?.scoreBreakdown, 'expected prose hit scoreBreakdown'); -assert.deepEqual(Object.keys(codeHit.scoreBreakdown), expectedKeys, 'expected code scoreBreakdown contract keys'); -assert.deepEqual(Object.keys(proseHit.scoreBreakdown), expectedKeys, 'expected prose scoreBreakdown contract keys'); +assert.deepEqual( + Object.keys(codeHit.scoreBreakdown), + EXPECTED_SCORE_BREAKDOWN_KEYS, + 'expected code scoreBreakdown contract keys' +); +assert.deepEqual( + Object.keys(proseHit.scoreBreakdown), + EXPECTED_SCORE_BREAKDOWN_KEYS, + 'expected prose scoreBreakdown contract keys' +); assert.equal(codeHit.scoreBreakdown.schemaVersion, 1, 'expected schema version in code hit'); assert.equal(proseHit.scoreBreakdown.schemaVersion, 1, 'expected schema version in prose hit'); diff --git a/tests/retrieval/contracts/score-breakdown-fixture.js b/tests/retrieval/contracts/score-breakdown-fixture.js new file mode 100644 index 000000000..5d8fe9132 --- /dev/null +++ b/tests/retrieval/contracts/score-breakdown-fixture.js @@ -0,0 +1,46 @@ +import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; +import { + createAlphaSearchIndex, + createAlphaTokenIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; + +export const EXPECTED_SCORE_BREAKDOWN_KEYS = [ + 'schemaVersion', + 'selected', + 'sparse', + 'ann', + 'rrf', + 'blend', + 'symbol', + 'phrase', + 'relation', + 'graph' +]; + +export const createScoreBreakdownHits = async () => { + const pipeline = createSearchPipelineFixture({ + useSqlite: true, + sqliteFtsRequested: true, + sqliteFtsRoutingByMode: resolveSqliteFtsRoutingByMode({ + useSqlite: true, + sqliteFtsRequested: true, + sqliteFtsExplicit: false, + runCode: true, + runProse: true, + runExtractedProse: false, + runRecords: false + }), + topN: 3, + buildCandidateSetSqlite: () => new Set([0]), + getTokenIndexForQuery: () => createAlphaTokenIndex(), + rankSqliteFts: () => [{ idx: 0, score: 2 }], + sqliteHasFts: (mode) => mode === 'prose' + }); + + const idx = createAlphaSearchIndex(); + return { + codeHit: (await pipeline(idx, 'code', null))[0], + proseHit: (await pipeline(idx, 'prose', null))[0] + }; +}; diff --git a/tests/retrieval/contracts/score-breakdown-snapshots.test.js b/tests/retrieval/contracts/score-breakdown-snapshots.test.js index 642f3e52a..f77c0a976 100644 --- a/tests/retrieval/contracts/score-breakdown-snapshots.test.js +++ b/tests/retrieval/contracts/score-breakdown-snapshots.test.js @@ -3,109 +3,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { resolveSqliteFtsRoutingByMode } from '../../../src/retrieval/routing-policy.js'; +import { createScoreBreakdownHits } from './score-breakdown-fixture.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const expectedPath = path.join(__dirname, 'golden', 'score-breakdown-snapshots.json'); const expected = JSON.parse(fs.readFileSync(expectedPath, 'utf8')); -const makeAnnState = () => ({ - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } -}); - -const makeAnnUsed = () => ({ - code: false, - prose: false, - records: false, - 'extracted-prose': false -}); - -const pipeline = createSearchPipeline({ - useSqlite: true, - sqliteFtsRequested: true, - sqliteFtsRoutingByMode: resolveSqliteFtsRoutingByMode({ - useSqlite: true, - sqliteFtsRequested: true, - sqliteFtsExplicit: false, - runCode: true, - runProse: true, - runExtractedProse: false, - runRecords: false - }), - sqliteFtsVariantConfig: { - explicitTrigram: false, - substringMode: false, - stemming: false - }, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', - sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - phraseNgramSet: null, - phraseRange: null, - explain: true, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 3, - annEnabled: false, - annBackend: 'auto', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: makeAnnState(), - vectorAnnUsed: makeAnnUsed(), - hnswAnnState: makeAnnState(), - hnswAnnUsed: makeAnnUsed(), - lanceAnnState: makeAnnState(), - lanceAnnUsed: makeAnnUsed(), - lancedbConfig: {}, - buildCandidateSetSqlite: () => new Set([0]), - getTokenIndexForQuery: () => ({ - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }), - rankSqliteFts: () => [{ idx: 0, score: 2 }], - rankVectorAnnSqlite: () => [], - sqliteHasFts: (mode) => mode === 'prose', - signal: null, - rrf: { enabled: false } -}); - -const idx = { - chunkMeta: [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], - tokenIndex: { - vocab: ['alpha'], - vocabIndex: new Map([['alpha', 0]]), - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }, - filterIndex: null, - fileRelations: null, - phraseNgrams: null, - minhash: null, - denseVec: null -}; - -const codeHit = (await pipeline(idx, 'code', null))[0]; -const proseHit = (await pipeline(idx, 'prose', null))[0]; +const { codeHit, proseHit } = await createScoreBreakdownHits(); const snapshot = { code: codeHit?.scoreBreakdown || null, diff --git a/tests/retrieval/explain-includes-relation-boost.test.js b/tests/retrieval/explain-includes-relation-boost.test.js index 71096f727..73feecb2e 100644 --- a/tests/retrieval/explain-includes-relation-boost.test.js +++ b/tests/retrieval/explain-includes-relation-boost.test.js @@ -4,7 +4,10 @@ import { createRelationBoostIndex, createRelationBoostPipeline } from './helpers/relation-boost-fixture.js'; -import { renderSearchOutput } from '../../src/retrieval/cli/render.js'; +import { + createSearchOutputHitState, + renderSearchOutputForTest +} from './helpers/search-output-fixture.js'; const idx = createRelationBoostIndex({ chunks: [{ @@ -76,95 +79,10 @@ assert.equal( 'expected lexicon chargram-domain token count' ); -const payload = renderSearchOutput({ - emitOutput: false, - jsonOutput: true, - jsonCompact: true, - explain: true, - color: {}, - rootDir: process.cwd(), - backendLabel: 'memory', - backendPolicyInfo: { backendLabel: 'memory', reason: 'test' }, - routingPolicy: { byMode: { code: { desired: 'sparse', route: 'sparse' } } }, - runCode: true, - runProse: false, - runExtractedProse: false, - runRecords: false, - topN: 5, +const payload = renderSearchOutputForTest({ queryTokens: ['fetchdata', 'result'], - highlightRegex: null, - contextExpansionEnabled: false, - expandedHits: { - prose: { hits: [], contextHits: [] }, - extractedProse: { hits: [], contextHits: [] }, - code: { hits: [hit], contextHits: [] }, - records: { hits: [], contextHits: [] } - }, - baseHits: { - proseHits: [], - extractedProseHits: [], - codeHits: [hit], - recordHits: [] - }, - annEnabled: false, - annActive: false, - annBackend: 'none', - vectorExtension: { annMode: 'none', provider: 'none', table: null }, - vectorAnnEnabled: false, - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswConfig: { enabled: false }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnState: { - code: { available: false, metric: null }, - prose: { available: false, metric: null }, - records: { available: false, metric: null }, - 'extracted-prose': { available: false, metric: null } - }, - modelIds: { - code: 'test-model', - prose: 'test-model', - extractedProse: 'test-model', - records: 'test-model' - }, - embeddingProvider: 'stub', - embeddingOnnx: {}, - cacheInfo: { enabled: false, hit: false, key: null }, - profileInfo: null, - intentInfo: { type: 'keyword' }, - resolvedDenseVectorMode: 'auto', - fieldWeights: null, - contextExpansionStats: { - enabled: false, - code: { added: 0, workUnitsUsed: 0, truncation: null }, - prose: { added: 0, workUnitsUsed: 0, truncation: null }, - 'extracted-prose': { added: 0, workUnitsUsed: 0, truncation: null }, - records: { added: 0, workUnitsUsed: 0, truncation: null } - }, - idxProse: { chunkMeta: [] }, - idxExtractedProse: { chunkMeta: [] }, - idxCode: { chunkMeta: [hit] }, - idxRecords: { chunkMeta: [] }, - showStats: false, - showMatched: false, - verboseCache: false, - elapsedMs: 5, - stageTracker: null + ...createSearchOutputHitState({ codeHits: [hit] }), + intentInfo: { type: 'keyword' } }); assert.ok(payload?.stats?.relationBoost, 'expected stats relationBoost section'); diff --git a/tests/retrieval/explain/confidence-surface-contract.test.js b/tests/retrieval/explain/confidence-surface-contract.test.js index 0dd6e66d0..3678f7644 100644 --- a/tests/retrieval/explain/confidence-surface-contract.test.js +++ b/tests/retrieval/explain/confidence-surface-contract.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { renderSearchOutputForTest } from '../helpers/search-output-fixture.js'; import { TRUST_SURFACE_SCHEMA_VERSION, readTrustSurface @@ -13,95 +13,9 @@ const intentInfo = classifyQuery({ phrases: ['configure proxy', 'proxy headers'] }); -const payload = renderSearchOutput({ - emitOutput: false, - jsonOutput: true, - jsonCompact: true, - explain: true, - color: {}, - rootDir: process.cwd(), - backendLabel: 'memory', - backendPolicyInfo: { backendLabel: 'memory', reason: 'test' }, - routingPolicy: { byMode: { code: { desired: 'sparse', route: 'sparse' } } }, - runCode: true, - runProse: false, - runExtractedProse: false, - runRecords: false, - topN: 5, +const payload = renderSearchOutputForTest({ queryTokens: ['proxy', 'headers'], - highlightRegex: null, - contextExpansionEnabled: false, - expandedHits: { - prose: { hits: [], contextHits: [] }, - extractedProse: { hits: [], contextHits: [] }, - code: { hits: [], contextHits: [] }, - records: { hits: [], contextHits: [] } - }, - baseHits: { - proseHits: [], - extractedProseHits: [], - codeHits: [], - recordHits: [] - }, - annEnabled: false, - annActive: false, - annBackend: 'none', - vectorExtension: { annMode: 'none', provider: 'none', table: null }, - vectorAnnEnabled: false, - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswConfig: { enabled: false }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnState: { - code: { available: false, metric: null }, - prose: { available: false, metric: null }, - records: { available: false, metric: null }, - 'extracted-prose': { available: false, metric: null } - }, - modelIds: { - code: 'test-model', - prose: 'test-model', - extractedProse: 'test-model', - records: 'test-model' - }, - embeddingProvider: 'stub', - embeddingOnnx: {}, - cacheInfo: { enabled: false, hit: false, key: null }, - profileInfo: null, - intentInfo, - resolvedDenseVectorMode: 'auto', - fieldWeights: null, - contextExpansionStats: { - enabled: false, - code: { added: 0, workUnitsUsed: 0, truncation: null }, - prose: { added: 0, workUnitsUsed: 0, truncation: null }, - 'extracted-prose': { added: 0, workUnitsUsed: 0, truncation: null }, - records: { added: 0, workUnitsUsed: 0, truncation: null } - }, - idxProse: { chunkMeta: [] }, - idxExtractedProse: { chunkMeta: [] }, - idxCode: { chunkMeta: [] }, - idxRecords: { chunkMeta: [] }, - showStats: false, - showMatched: false, - verboseCache: false, - elapsedMs: 5, - stageTracker: null + intentInfo }); const trust = payload?.stats?.trust || null; diff --git a/tests/retrieval/federation/abort-does-not-cache-partial.test.js b/tests/retrieval/federation/abort-does-not-cache-partial.test.js deleted file mode 100644 index 256e33935..000000000 --- a/tests/retrieval/federation/abort-does-not-cache-partial.test.js +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-abort-cache-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "a", "priority": 10 }, - { "root": "./repo-b", "alias": "b", "priority": 5 } - ] -}`, 'utf8'); - -let searchCalls = 0; -const controller = new AbortController(); -const searchFn = async (repoRootCanonical, params) => { - searchCalls += 1; - const leaf = path.basename(repoRootCanonical); - if (leaf === 'repo-a') { - // Simulate client disconnect after one successful repo result. - controller.abort(); - return { - backend: 'memory', - code: [{ id: 'hit-a', file: 'src/a.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; - } - if (params?.signal?.aborted) { - throw createError(ERROR_CODES.CANCELLED, 'Search cancelled.'); - } - return { - backend: 'memory', - code: [{ id: 'hit-b', file: 'src/b.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -await assert.rejects( - runFederatedSearch({ - workspacePath, - query: 'abort-cache', - search: { mode: 'code', top: 5 }, - limits: { concurrency: 1 } - }, { - signal: controller.signal, - searchFn - }), - (error) => { - assert.equal(error?.code, ERROR_CODES.CANCELLED); - return true; - } -); - -const successful = await runFederatedSearch({ - workspacePath, - query: 'abort-cache', - search: { mode: 'code', top: 5 }, - limits: { concurrency: 1 } -}, { - searchFn -}); - -assert.equal(searchCalls, 4, 'second request should re-run fanout, not reuse aborted partial cache'); -assert.equal(successful.code.length, 2, 'non-aborted retry should include both repos'); - -console.log('federated abort should not cache partial results test passed'); diff --git a/tests/retrieval/federation/build-pointer-active-generation-change-clears-cache.test.js b/tests/retrieval/federation/build-pointer-active-generation-change-clears-cache.test.js new file mode 100644 index 000000000..70ff90292 --- /dev/null +++ b/tests/retrieval/federation/build-pointer-active-generation-change-clears-cache.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadUserConfig, getRepoCacheRoot, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { createRepoCacheManager } from '../../../src/shared/repo-cache-config.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-pointer-generation-clear-')); +const cacheRoot = path.join(tempRoot, 'cache'); +const repoRoot = path.join(tempRoot, 'repo'); + +const writeBuild = async (buildsRoot, buildId) => { + const buildRoot = path.join(buildsRoot, buildId); + await fs.mkdir(path.join(buildRoot, 'index-code'), { recursive: true }); + await fs.writeFile(path.join(buildRoot, 'index-code', 'chunk_meta.json'), '[]', 'utf8'); + return buildRoot; +}; + +await fs.mkdir(repoRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } +}, null, 2), 'utf8'); + +const userConfig = loadUserConfig(repoRoot); +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const currentPath = path.join(buildsRoot, 'current.json'); +const fixedTick = new Date('2026-03-23T00:00:00.000Z'); + +const buildRootA = await writeBuild(buildsRoot, 'build-a'); +const buildRootB = await writeBuild(buildsRoot, 'build-b'); +await fs.writeFile(currentPath, JSON.stringify({ + buildId: 'build-a', + buildRoot: 'builds/build-a' +}, null, 2), 'utf8'); +await fs.utimes(currentPath, fixedTick, fixedTick); + +const manager = createRepoCacheManager({ defaultRepo: repoRoot }); +const entry = manager.getRepoCaches(repoRoot); +await manager.refreshBuildPointer(entry); + +entry.indexCache.set('sentinel', { value: 1 }); +entry.sqliteCache.set(path.join(buildRootA, 'index.sqlite'), { close() {} }); + +assert.equal(entry.indexCache.size(), 1, 'expected warm index cache entry before generation change'); +assert.equal(entry.sqliteCache.size(), 1, 'expected warm sqlite cache entry before generation change'); +assert.equal(entry.buildId, 'build-a', 'expected initial build id'); +assert.equal(entry.activeBuildRoot, toRealPathSync(buildRootA), 'expected initial active generation root'); + +await fs.writeFile(currentPath, JSON.stringify({ + buildId: 'build-a', + buildRoot: 'builds/build-b' +}, null, 2), 'utf8'); +await fs.utimes(currentPath, fixedTick, fixedTick); + +await manager.refreshBuildPointer(entry); + +assert.equal(entry.buildId, 'build-a', 'same build id should remain visible after pointer refresh'); +assert.equal(entry.activeBuildRoot, toRealPathSync(buildRootB), 'expected active generation root to refresh from current.json'); +assert.equal(entry.indexCache.size(), 0, 'active generation change should clear stale index cache state'); +assert.equal(entry.sqliteCache.size(), 0, 'active generation change should clear stale sqlite cache state'); + +manager.closeRepoCaches(); + +console.log('build pointer active generation change clears cache test passed'); diff --git a/tests/retrieval/federation/build-pointer-invalid-clears-cache.test.js b/tests/retrieval/federation/build-pointer-invalid-clears-cache.test.js deleted file mode 100644 index 14897ffa7..000000000 --- a/tests/retrieval/federation/build-pointer-invalid-clears-cache.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadUserConfig, getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; -import { createRepoCacheManager } from '../../../tools/shared/repo-cache-config.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-pointer-cache-clear-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const currentPath = path.join(repoCacheRoot, 'builds', 'current.json'); -await fs.mkdir(path.dirname(currentPath), { recursive: true }); -await fs.writeFile(currentPath, '{invalid-json', 'utf8'); - -const manager = createRepoCacheManager({ defaultRepo: repoRoot }); -const entry = manager.getRepoCaches(repoRoot); -entry.buildId = 'build-1'; -entry.indexCache.set('sentinel', { value: 1 }); -assert.equal(entry.indexCache.size(), 1, 'expected warm cache entry before pointer corruption'); - -await manager.refreshBuildPointer(entry); - -assert.equal(entry.buildId, null, 'invalid pointer should clear cached build id'); -assert.equal(entry.indexCache.size(), 0, 'invalid pointer should clear index cache state'); - -manager.closeRepoCaches(); - -console.log('build pointer invalid clears cache test passed'); diff --git a/tests/retrieval/federation/cache-failure-contract-matrix.test.js b/tests/retrieval/federation/cache-failure-contract-matrix.test.js new file mode 100644 index 000000000..6f0fed147 --- /dev/null +++ b/tests/retrieval/federation/cache-failure-contract-matrix.test.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { createRepoCacheManager } from '../../../src/shared/repo-cache-config.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { writeFederationRepoFixture } from './repo-fixture.js'; + +const withTempRoot = async (prefix, run) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await run({ + tempRoot, + cacheRoot: path.join(tempRoot, 'cache') + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +const cases = [ + { + name: 'invalid build pointers clear repo cache state', + async run() { + await withTempRoot('poc-fed-cache-pointer-clear-', async ({ tempRoot, cacheRoot }) => { + const repoRoot = path.join(tempRoot, 'repo'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } + }, null, 2), 'utf8'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const currentPath = path.join(repoCacheRoot, 'builds', 'current.json'); + await fs.mkdir(path.dirname(currentPath), { recursive: true }); + await fs.writeFile(currentPath, '{invalid-json', 'utf8'); + + const manager = createRepoCacheManager({ defaultRepo: repoRoot }); + const entry = manager.getRepoCaches(repoRoot); + entry.buildId = 'build-1'; + entry.indexCache.set('sentinel', { value: 1 }); + assert.equal(entry.indexCache.size(), 1); + + await manager.refreshBuildPointer(entry); + + assert.equal(entry.buildId, null); + assert.equal(entry.indexCache.size(), 0); + manager.closeRepoCaches(); + }); + } + }, + { + name: 'partial failures are not reused from federated cache', + async run() { + await withTempRoot('poc-fed-cache-partial-', async ({ tempRoot, cacheRoot }) => { + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "a", "priority": 10 }, + { "root": "./repo-b", "alias": "b", "priority": 5 } + ] +}`, 'utf8'); + + let searchCalls = 0; + const repoAttempts = new Map(); + const searchFn = async (repoRootCanonical) => { + searchCalls += 1; + const leaf = path.basename(repoRootCanonical); + const attempts = (repoAttempts.get(leaf) || 0) + 1; + repoAttempts.set(leaf, attempts); + if (leaf === 'repo-a' && attempts === 1) { + throw createError(ERROR_CODES.NO_INDEX, 'simulated transient index miss'); + } + return { + backend: 'memory', + code: [{ id: `hit-${leaf}`, file: `src/${leaf}.js`, start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + + const request = { + workspacePath, + query: 'partial-failure-cache', + search: { mode: 'code', top: 5 }, + limits: { concurrency: 1 } + }; + + const first = await runFederatedSearch(request, { searchFn }); + assert.equal(first.ok, true); + assert.equal(first.status, 'partial'); + assert.equal(first.code.length, 1); + + const second = await runFederatedSearch(request, { searchFn }); + assert.equal(second.ok, true); + assert.equal(second.status, 'complete'); + assert.equal(second.code.length, 2); + assert.equal(searchCalls, 4); + }); + } + }, + { + name: 'aborted requests do not cache partial success from early repos', + async run() { + await withTempRoot('poc-fed-cache-abort-', async ({ tempRoot, cacheRoot }) => { + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "a", "priority": 10 }, + { "root": "./repo-b", "alias": "b", "priority": 5 } + ] +}`, 'utf8'); + + let searchCalls = 0; + const controller = new AbortController(); + const searchFn = async (repoRootCanonical, params) => { + searchCalls += 1; + const leaf = path.basename(repoRootCanonical); + if (leaf === 'repo-a') { + controller.abort(); + return { + backend: 'memory', + code: [{ id: 'hit-a', file: 'src/a.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } + if (params?.signal?.aborted) { + throw createError(ERROR_CODES.CANCELLED, 'Search cancelled.'); + } + return { + backend: 'memory', + code: [{ id: 'hit-b', file: 'src/b.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + + await assert.rejects( + runFederatedSearch({ + workspacePath, + query: 'abort-cache', + search: { mode: 'code', top: 5 }, + limits: { concurrency: 1 } + }, { + signal: controller.signal, + searchFn + }), + (error) => { + assert.equal(error?.code, ERROR_CODES.CANCELLED); + return true; + } + ); + + const successful = await runFederatedSearch({ + workspacePath, + query: 'abort-cache', + search: { mode: 'code', top: 5 }, + limits: { concurrency: 1 } + }, { searchFn }); + + assert.equal(searchCalls, 4); + assert.equal(successful.code.length, 2); + }); + } + }, + { + name: 'non-strict mode still propagates hard failures after all repos are attempted', + async run() { + await withTempRoot('poc-fed-cache-hard-fail-', async ({ tempRoot, cacheRoot }) => { + const repoMissing = path.join(tempRoot, 'repo-missing'); + const repoBroken = path.join(tempRoot, 'repo-broken'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoMissing, cacheRoot }); + await writeFederationRepoFixture({ repoRoot: repoBroken, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-missing", "alias": "missing", "priority": 10 }, + { "root": "./repo-broken", "alias": "broken", "priority": 5 } + ] +}`, 'utf8'); + + let searchCalls = 0; + const searchFn = async (repoRootCanonical) => { + searchCalls += 1; + const leaf = path.basename(repoRootCanonical); + if (leaf === 'repo-missing') { + throw createError(ERROR_CODES.NO_INDEX, 'Index not found'); + } + throw createError(ERROR_CODES.INTERNAL, 'Backend unavailable'); + }; + + await assert.rejects( + runFederatedSearch({ + workspacePath, + query: 'federated', + search: { mode: 'code', top: 5 }, + limits: { perRepoTop: 5, concurrency: 2 } + }, { searchFn }), + (error) => { + assert.equal(error?.code, ERROR_CODES.INTERNAL); + assert.match(String(error?.message || ''), /backend unavailable/i); + return true; + } + ); + + assert.equal(searchCalls, 2); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('federation cache and failure contract matrix test passed'); diff --git a/tests/retrieval/federation/cache-key-workspace-metadata.test.js b/tests/retrieval/federation/cache-key-workspace-metadata.test.js deleted file mode 100644 index 30dd99151..000000000 --- a/tests/retrieval/federation/cache-key-workspace-metadata.test.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-cache-workspace-meta-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' -}, null, 2), 'utf8'); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "name": "Workspace Alpha", - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "alpha" } - ] -}`, 'utf8'); - -let searchCalls = 0; -const searchFn = async () => { - searchCalls += 1; - return { - backend: 'memory', - code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const first = await runFederatedSearch({ - workspacePath, - query: 'cache-workspace-meta', - search: { mode: 'code', top: 5 } -}, { searchFn }); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "name": "Workspace Beta", - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "beta" } - ] -}`, 'utf8'); - -const second = await runFederatedSearch({ - workspacePath, - query: 'cache-workspace-meta', - search: { mode: 'code', top: 5 } -}, { searchFn }); - -assert.equal(searchCalls, 2, 'workspace metadata changes should invalidate federated query cache entries'); -assert.equal(first.meta?.workspace?.name, 'Workspace Alpha'); -assert.equal(second.meta?.workspace?.name, 'Workspace Beta'); -assert.equal(first.code[0]?.repoAlias, 'alpha'); -assert.equal(second.code[0]?.repoAlias, 'beta'); - -console.log('federated cache key workspace metadata test passed'); diff --git a/tests/retrieval/federation/cli-mode-forwarding.test.js b/tests/retrieval/federation/cli-mode-forwarding.test.js deleted file mode 100644 index 83823c5ac..000000000 --- a/tests/retrieval/federation/cli-mode-forwarding.test.js +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { parseFederatedCliRequest } from '../../../src/retrieval/federation/args.js'; - -const workspacePath = 'C:\\workspace\\.pairofcleats-workspace.jsonc'; -const request = parseFederatedCliRequest([ - '--workspace', - workspacePath, - '--mode', - 'records', - 'find-me' -]); - -assert.equal(request.workspacePath, workspacePath); -assert.equal(request.query, 'find-me'); -assert.equal(request.mode, 'records', 'CLI --mode should be propagated to federated request payload'); - -console.log('federated cli mode forwarding test passed'); diff --git a/tests/retrieval/federation/cli-top-zero-preserved.test.js b/tests/retrieval/federation/cli-top-zero-preserved.test.js deleted file mode 100644 index 36ac3c936..000000000 --- a/tests/retrieval/federation/cli-top-zero-preserved.test.js +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { parseFederatedCliRequest } from '../../../src/retrieval/federation/args.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-cli-top-zero-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' -}, null, 2), 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -const request = parseFederatedCliRequest([ - '--workspace', - workspacePath, - '--mode', - 'code', - '--top', - '0', - 'needle' -]); - -assert.equal(request.top, 0, 'federated CLI parser must preserve explicit --top 0'); -assert.equal(request.perRepoTop, 0, 'per-repo top fallback should also preserve zero'); - -const searchCalls = []; -const response = await runFederatedSearch(request, { - searchFn: async (_repoRootCanonical, params) => { - searchCalls.push(params); - return { - backend: 'memory', - code: [ - { id: 'hit-1', file: 'src/a.js', start: 1, end: 1, score: 1 }, - { id: 'hit-2', file: 'src/b.js', start: 1, end: 1, score: 1 } - ], - prose: [], - extractedProse: [], - records: [] - }; - } -}); - -assert.equal(searchCalls.length, 1, 'expected one federated repo search call'); -const args = Array.isArray(searchCalls[0]?.args) ? searchCalls[0].args : []; -const topFlagIndex = args.findIndex((token) => token === '--top'); -assert.notEqual(topFlagIndex, -1, 'per-repo args should include --top'); -assert.equal(args[topFlagIndex + 1], '0', 'per-repo args should preserve top zero'); -assert.equal(response.code.length, 0, 'merged response should honor top zero'); - -console.log('federated cli top zero preservation test passed'); diff --git a/tests/retrieval/federation/compat-cohort-all-unavailable-empty-selection.test.js b/tests/retrieval/federation/compat-cohort-all-unavailable-empty-selection.test.js deleted file mode 100644 index 28ae756be..000000000 --- a/tests/retrieval/federation/compat-cohort-all-unavailable-empty-selection.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; - -const repos = [ - { - repoId: 'repo-a', - priority: 10, - indexes: { - code: { - cohortKey: 'cohort-a', - compatibilityKey: 'compat-a', - present: false, - availabilityReason: 'missing-index-dir' - } - } - }, - { - repoId: 'repo-b', - priority: 5, - indexes: { - code: { - cohortKey: 'cohort-b', - compatibilityKey: 'compat-b', - present: false, - availabilityReason: 'missing-index-dir' - } - } - } -]; - -const selected = applyCohortPolicy({ - repos, - modes: ['code'], - policy: 'default' -}); - -assert.equal(selected.modeSelections.code, null); -assert.deepEqual( - selected.selectedReposByMode.code, - [], - 'all-unavailable repos should remain excluded for that mode' -); -assert.deepEqual( - selected.excluded.code, - [], - 'no cohorts should be selected or excluded when every repo is unavailable' -); - -console.log('federation cohort all-unavailable selection test passed'); diff --git a/tests/retrieval/federation/compat-cohort-availability-filtering.test.js b/tests/retrieval/federation/compat-cohort-availability-filtering.test.js deleted file mode 100644 index 72b2f18e3..000000000 --- a/tests/retrieval/federation/compat-cohort-availability-filtering.test.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; - -const repos = [ - { - repoId: 'repo-code', - priority: 1, - indexes: { - code: { - cohortKey: 'code-cohort', - compatibilityKey: 'code-compat', - present: true, - availabilityReason: 'present' - }, - prose: { - cohortKey: null, - compatibilityKey: null, - present: false, - availabilityReason: 'missing-index-dir' - } - } - }, - { - repoId: 'repo-prose', - priority: 1, - indexes: { - code: { - cohortKey: null, - compatibilityKey: null, - present: false, - availabilityReason: 'missing-index-dir' - }, - prose: { - cohortKey: 'prose-cohort', - compatibilityKey: 'prose-compat', - present: true, - availabilityReason: 'present' - } - } - }, - { - repoId: 'repo-unavailable', - priority: 10, - indexes: { - code: { - cohortKey: null, - compatibilityKey: null, - present: false, - availabilityReason: 'missing-index-dir' - }, - prose: { - cohortKey: null, - compatibilityKey: null, - present: false, - availabilityReason: 'missing-index-dir' - } - } - } -]; - -const selected = applyCohortPolicy({ - repos, - modes: ['code', 'prose'], - policy: 'default' -}); - -assert.equal(selected.modeSelections.code, 'code-cohort'); -assert.equal(selected.modeSelections.prose, 'prose-cohort'); -assert.deepEqual( - selected.selectedReposByMode.code.map((entry) => entry.repoId), - ['repo-code'], - 'code mode should ignore repos unavailable for code' -); -assert.deepEqual( - selected.selectedReposByMode.prose.map((entry) => entry.repoId), - ['repo-prose'], - 'prose mode should ignore repos unavailable for prose' -); - -console.log('federation cohort availability filtering test passed'); diff --git a/tests/retrieval/federation/compat-cohort-defaults.test.js b/tests/retrieval/federation/compat-cohort-defaults.test.js deleted file mode 100644 index 8abfeb2d9..000000000 --- a/tests/retrieval/federation/compat-cohort-defaults.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { FEDERATION_COHORT_WARNINGS } from '../../../src/retrieval/federation/cohorts.js'; -import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; - -const makeRepo = (repoId, priority, modeKey) => ({ - repoId, - priority, - indexes: { - code: { - cohortKey: modeKey, - compatibilityKey: `${modeKey}-compat` - } - } -}); - -const repos = [ - makeRepo('repo-a', 10, 'cohort-a'), - makeRepo('repo-b', 5, 'cohort-a'), - makeRepo('repo-c', 100, 'cohort-b') -]; - -const result = applyCohortPolicy({ - repos, - modes: ['code'], - policy: 'default' -}); - -assert.equal(result.modeSelections.code, 'cohort-a', 'default policy should pick the largest cohort'); -assert.deepEqual( - result.selectedReposByMode.code.map((entry) => entry.repoId), - ['repo-a', 'repo-b'] -); -assert.deepEqual( - result.excluded.code.map((entry) => entry.repoId), - ['repo-c'] -); -assert.ok( - result.warnings.includes(FEDERATION_COHORT_WARNINGS.MULTI_COHORT), - 'default policy should emit multi-cohort warning when cohorts are excluded' -); - -console.log('federation cohort defaults test passed'); diff --git a/tests/retrieval/federation/compat-cohort-determinism.test.js b/tests/retrieval/federation/compat-cohort-determinism.test.js deleted file mode 100644 index c64d05e0f..000000000 --- a/tests/retrieval/federation/compat-cohort-determinism.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; - -const reposA = [ - { - repoId: 'repo-z', - priority: 5, - indexes: { code: { cohortKey: 'cohort-b', compatibilityKey: null } } - }, - { - repoId: 'repo-a', - priority: 5, - indexes: { code: { cohortKey: 'cohort-a', compatibilityKey: null } } - } -]; - -const reposB = [...reposA].reverse(); - -const first = applyCohortPolicy({ - repos: reposA, - modes: ['code'], - policy: 'default' -}); -const second = applyCohortPolicy({ - repos: reposB, - modes: ['code'], - policy: 'default' -}); - -assert.equal(first.modeSelections.code, 'cohort-a', 'tie should resolve lexically with null last'); -assert.equal(stableStringify(first), stableStringify(second), 'cohort selection should be deterministic regardless of repo order'); - -console.log('federation cohort determinism test passed'); diff --git a/tests/retrieval/federation/compat-cohort-explicit-selection.test.js b/tests/retrieval/federation/compat-cohort-explicit-selection.test.js deleted file mode 100644 index 3c9ca194c..000000000 --- a/tests/retrieval/federation/compat-cohort-explicit-selection.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - FEDERATION_COHORT_ERRORS -} from '../../../src/retrieval/federation/cohorts.js'; -import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; - -const repos = [ - { - repoId: 'repo-a', - priority: 1, - indexes: { - code: { cohortKey: 'code-a', compatibilityKey: null }, - prose: { cohortKey: 'prose-a', compatibilityKey: null } - } - }, - { - repoId: 'repo-b', - priority: 1, - indexes: { - code: { cohortKey: 'code-b', compatibilityKey: null }, - prose: { cohortKey: 'prose-b', compatibilityKey: null } - } - } -]; - -const selected = applyCohortPolicy({ - repos, - modes: ['code', 'prose'], - cohort: ['code:code-b'], - policy: 'default' -}); - -assert.deepEqual( - selected.selectedReposByMode.code.map((entry) => entry.repoId), - ['repo-b'], - 'mode-specific cohort selector should pin code mode selection' -); -assert.ok(selected.selectedReposByMode.prose.length > 0, 'other modes should still use default cohort policy'); - -assert.throws(() => applyCohortPolicy({ - repos, - modes: ['code'], - cohort: ['missing-cohort'], - policy: 'default' -}), (error) => { - assert.equal(error.code, FEDERATION_COHORT_ERRORS.COHORT_NOT_FOUND); - return true; -}); - -console.log('federation cohort explicit selection test passed'); diff --git a/tests/retrieval/federation/compat-cohort-policy-matrix.test.js b/tests/retrieval/federation/compat-cohort-policy-matrix.test.js new file mode 100644 index 000000000..a27f328be --- /dev/null +++ b/tests/retrieval/federation/compat-cohort-policy-matrix.test.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + FEDERATION_COHORT_ERRORS, + FEDERATION_COHORT_WARNINGS +} from '../../../src/retrieval/federation/cohorts.js'; +import { applyCohortPolicy } from '../../../src/retrieval/federation/coordinator.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; + +const makeRepo = (repoId, priority, modes) => ({ + repoId, + priority, + indexes: { ...modes } +}); + +const cases = [ + { + name: 'default policy picks the largest available cohort and warns on exclusions', + run() { + const repos = [ + makeRepo('repo-a', 10, { + code: { cohortKey: 'cohort-a', compatibilityKey: 'compat-a' } + }), + makeRepo('repo-b', 5, { + code: { cohortKey: 'cohort-a', compatibilityKey: 'compat-a' } + }), + makeRepo('repo-c', 100, { + code: { cohortKey: 'cohort-b', compatibilityKey: 'compat-b' } + }) + ]; + + const result = applyCohortPolicy({ + repos, + modes: ['code'], + policy: 'default' + }); + + assert.equal(result.modeSelections.code, 'cohort-a'); + assert.deepEqual( + result.selectedReposByMode.code.map((entry) => entry.repoId), + ['repo-a', 'repo-b'] + ); + assert.deepEqual( + result.excluded.code.map((entry) => entry.repoId), + ['repo-c'] + ); + assert.ok(result.warnings.includes(FEDERATION_COHORT_WARNINGS.MULTI_COHORT)); + } + }, + { + name: 'availability filtering keeps per-mode cohorts independent', + run() { + const repos = [ + makeRepo('repo-code', 1, { + code: { + cohortKey: 'code-cohort', + compatibilityKey: 'code-compat', + present: true, + availabilityReason: 'present' + }, + prose: { + cohortKey: null, + compatibilityKey: null, + present: false, + availabilityReason: 'missing-index-dir' + } + }), + makeRepo('repo-prose', 1, { + code: { + cohortKey: null, + compatibilityKey: null, + present: false, + availabilityReason: 'missing-index-dir' + }, + prose: { + cohortKey: 'prose-cohort', + compatibilityKey: 'prose-compat', + present: true, + availabilityReason: 'present' + } + }), + makeRepo('repo-unavailable', 10, { + code: { + cohortKey: null, + compatibilityKey: null, + present: false, + availabilityReason: 'missing-index-dir' + }, + prose: { + cohortKey: null, + compatibilityKey: null, + present: false, + availabilityReason: 'missing-index-dir' + } + }) + ]; + + const result = applyCohortPolicy({ + repos, + modes: ['code', 'prose'], + policy: 'default' + }); + + assert.equal(result.modeSelections.code, 'code-cohort'); + assert.equal(result.modeSelections.prose, 'prose-cohort'); + assert.deepEqual(result.selectedReposByMode.code.map((entry) => entry.repoId), ['repo-code']); + assert.deepEqual(result.selectedReposByMode.prose.map((entry) => entry.repoId), ['repo-prose']); + } + }, + { + name: 'explicit selectors pin one mode while other modes use defaults', + run() { + const repos = [ + makeRepo('repo-a', 1, { + code: { cohortKey: 'code-a', compatibilityKey: null }, + prose: { cohortKey: 'prose-a', compatibilityKey: null } + }), + makeRepo('repo-b', 1, { + code: { cohortKey: 'code-b', compatibilityKey: null }, + prose: { cohortKey: 'prose-b', compatibilityKey: null } + }) + ]; + + const selected = applyCohortPolicy({ + repos, + modes: ['code', 'prose'], + cohort: ['code:code-b'], + policy: 'default' + }); + + assert.deepEqual( + selected.selectedReposByMode.code.map((entry) => entry.repoId), + ['repo-b'] + ); + assert.ok(selected.selectedReposByMode.prose.length > 0); + + assert.throws(() => applyCohortPolicy({ + repos, + modes: ['code'], + cohort: ['missing-cohort'], + policy: 'default' + }), (error) => { + assert.equal(error.code, FEDERATION_COHORT_ERRORS.COHORT_NOT_FOUND); + return true; + }); + } + }, + { + name: 'ties resolve deterministically regardless of repo order', + run() { + const reposA = [ + makeRepo('repo-z', 5, { + code: { cohortKey: 'cohort-b', compatibilityKey: null } + }), + makeRepo('repo-a', 5, { + code: { cohortKey: 'cohort-a', compatibilityKey: null } + }) + ]; + const reposB = [...reposA].reverse(); + + const first = applyCohortPolicy({ + repos: reposA, + modes: ['code'], + policy: 'default' + }); + const second = applyCohortPolicy({ + repos: reposB, + modes: ['code'], + policy: 'default' + }); + + assert.equal(first.modeSelections.code, 'cohort-a'); + assert.equal(stableStringify(first), stableStringify(second)); + } + }, + { + name: 'all unavailable repos keep mode selection empty', + run() { + const repos = [ + makeRepo('repo-a', 10, { + code: { + cohortKey: 'cohort-a', + compatibilityKey: 'compat-a', + present: false, + availabilityReason: 'missing-index-dir' + } + }), + makeRepo('repo-b', 5, { + code: { + cohortKey: 'cohort-b', + compatibilityKey: 'compat-b', + present: false, + availabilityReason: 'missing-index-dir' + } + }) + ]; + + const selected = applyCohortPolicy({ + repos, + modes: ['code'], + policy: 'default' + }); + + assert.equal(selected.modeSelections.code, null); + assert.deepEqual(selected.selectedReposByMode.code, []); + assert.deepEqual(selected.excluded.code, []); + } + } +]; + +for (const entry of cases) { + entry.run(); +} + +console.log('federation cohort policy matrix test passed'); diff --git a/tests/retrieval/federation/explicit-root-no-fallback.test.js b/tests/retrieval/federation/explicit-root-no-fallback.test.js deleted file mode 100644 index 25ad050f0..000000000 --- a/tests/retrieval/federation/explicit-root-no-fallback.test.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { resolveIndexRef } from '../../../src/index/index-ref.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -applyTestEnv(); - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-explicit-root-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const userConfig = { cache: { root: cacheRoot } }; -await fs.mkdir(repoRoot, { recursive: true }); - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const buildsRoot = path.join(repoCacheRoot, 'builds'); -const liveBuildRoot = path.join(buildsRoot, 'build-live'); -await fs.mkdir(liveBuildRoot, { recursive: true }); - -const writeJson = async (targetPath, value) => { - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); -}; - -await writeJson(path.join(liveBuildRoot, 'build_state.json'), { - schemaVersion: 1, - buildId: 'build-live', - configHash: 'cfg-live', - tool: { version: '1.0.0' } -}); -await writeJson(path.join(buildsRoot, 'current.json'), { - buildRoot: 'builds/build-live', - buildRootsByMode: { code: 'builds/build-live' } -}); - -const snapshotId = 'snap-20260212-explicit-nofallback'; -const snapshotsRoot = path.join(repoCacheRoot, 'snapshots'); -await writeJson(path.join(snapshotsRoot, 'manifest.json'), { - version: 1, - updatedAt: '2026-02-12T00:00:00.000Z', - snapshots: { - [snapshotId]: { - snapshotId, - createdAt: '2026-02-12T00:00:00.000Z', - hasFrozen: false - } - }, - tags: {} -}); -await writeJson(path.join(snapshotsRoot, snapshotId, 'snapshot.json'), { - version: 1, - snapshotId, - kind: 'pointer', - pointer: { - buildRootsByMode: { - code: 'builds/missing-build-root' - }, - buildIdByMode: { - code: 'build-missing' - } - } -}); - -assert.throws( - () => resolveIndexRef({ - ref: `snap:${snapshotId}`, - repoRoot, - userConfig, - requestedModes: ['code'] - }), - /missing build root/i, - 'explicit snapshot refs must fail fast when their build roots are missing' -); - -const latest = resolveIndexRef({ - ref: 'latest', - repoRoot, - userConfig, - requestedModes: ['code'] -}); -assert.equal(latest.indexBaseRootByMode.code, liveBuildRoot); - -console.log('federated explicit root no-fallback test passed'); diff --git a/tests/retrieval/federation/invalid-cohort-selector-error-code.test.js b/tests/retrieval/federation/invalid-cohort-selector-error-code.test.js deleted file mode 100644 index 215d0c636..000000000 --- a/tests/retrieval/federation/invalid-cohort-selector-error-code.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-invalid-cohort-code-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const indexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(indexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' -}, null, 2), 'utf8'); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -await assert.rejects( - runFederatedSearch({ - workspacePath, - query: 'bad-selector', - cohort: ['code:'], - search: { mode: 'code', top: 5 } - }), - (error) => { - assert.equal(error?.code, 'ERR_FEDERATED_INVALID_COHORT_SELECTOR'); - return true; - } -); - -await assert.rejects( - runFederatedSearch({ - workspacePath, - query: 'multi-global', - cohort: ['c1', 'c2'], - search: { mode: 'code', top: 5 } - }), - (error) => { - assert.equal(error?.code, 'ERR_FEDERATED_INVALID_COHORT_SELECTOR'); - return true; - } -); - -console.log('federated invalid cohort selector error code test passed'); diff --git a/tests/retrieval/federation/mode-cohort-merge-cutoff-order.test.js b/tests/retrieval/federation/mode-cohort-merge-cutoff-order.test.js deleted file mode 100644 index 795d5e3c3..000000000 --- a/tests/retrieval/federation/mode-cohort-merge-cutoff-order.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { mergeFederatedResultsByMode } from '../../../src/retrieval/federation/coordinator.js'; - -const perRepoResults = [ - { - repoId: 'repo-high', - repoAlias: 'high', - priority: 100, - result: { - code: [{ id: 'high-code', file: 'src/high.js', start: 1, end: 1, score: 1 }], - prose: [{ id: 'high-prose', file: 'docs/high.md', start: 1, end: 1, score: 1 }], - extractedProse: [], - records: [] - } - }, - { - repoId: 'repo-low', - repoAlias: 'low', - priority: 1, - result: { - code: [{ id: 'low-code', file: 'src/low.js', start: 1, end: 1, score: 1 }], - prose: [{ id: 'low-prose', file: 'docs/low.md', start: 1, end: 1, score: 1 }], - extractedProse: [], - records: [] - } - } -]; - -const selectedReposByMode = { - code: [{ repoId: 'repo-low' }], - prose: [{ repoId: 'repo-high' }], - 'extracted-prose': [], - records: [] -}; - -const merged = mergeFederatedResultsByMode({ - perRepoResults, - selectedReposByMode, - topN: 1, - perRepoTop: 10, - rrfK: 60 -}); - -assert.deepEqual(merged.code.map((hit) => hit.repoId), ['repo-low']); -assert.deepEqual(merged.prose.map((hit) => hit.repoId), ['repo-high']); -assert.deepEqual(merged.extractedProse, []); -assert.deepEqual(merged.records, []); - -console.log('federation mode cohort merge cutoff ordering test passed'); diff --git a/tests/retrieval/federation/non-strict-propagates-hard-failure.test.js b/tests/retrieval/federation/non-strict-propagates-hard-failure.test.js deleted file mode 100644 index 4b23e439b..000000000 --- a/tests/retrieval/federation/non-strict-propagates-hard-failure.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-propagate-fail-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoMissing = path.join(tempRoot, 'repo-missing'); -const repoBroken = path.join(tempRoot, 'repo-broken'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoMissing); -await writeRepo(repoBroken); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-missing", "alias": "missing", "priority": 10 }, - { "root": "./repo-broken", "alias": "broken", "priority": 5 } - ] -}`, 'utf8'); - -let searchCalls = 0; -const searchFn = async (repoRootCanonical) => { - searchCalls += 1; - const leaf = path.basename(repoRootCanonical); - if (leaf === 'repo-missing') { - throw createError(ERROR_CODES.NO_INDEX, 'Index not found'); - } - throw createError(ERROR_CODES.INTERNAL, 'Backend unavailable'); -}; - -await assert.rejects( - runFederatedSearch({ - workspacePath, - query: 'federated', - search: { mode: 'code', top: 5 }, - limits: { perRepoTop: 5, concurrency: 2 } - }, { searchFn }), - (error) => { - assert.equal(error?.code, ERROR_CODES.INTERNAL); - assert.match(String(error?.message || ''), /backend unavailable/i); - return true; - } -); -assert.equal(searchCalls, 2, 'expected all selected repos to be attempted before failing'); - -console.log('federated non-strict failure propagation test passed'); diff --git a/tests/retrieval/federation/partial-failures-not-cached.test.js b/tests/retrieval/federation/partial-failures-not-cached.test.js deleted file mode 100644 index 89e2737e4..000000000 --- a/tests/retrieval/federation/partial-failures-not-cached.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-partial-cache-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "a", "priority": 10 }, - { "root": "./repo-b", "alias": "b", "priority": 5 } - ] -}`, 'utf8'); - -let searchCalls = 0; -const repoAttempts = new Map(); -const searchFn = async (repoRootCanonical) => { - searchCalls += 1; - const leaf = path.basename(repoRootCanonical); - const attempts = (repoAttempts.get(leaf) || 0) + 1; - repoAttempts.set(leaf, attempts); - if (leaf === 'repo-a' && attempts === 1) { - throw createError(ERROR_CODES.NO_INDEX, 'simulated transient index miss'); - } - return { - backend: 'memory', - code: [{ id: `hit-${leaf}`, file: `src/${leaf}.js`, start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const baseRequest = { - workspacePath, - query: 'partial-failure-cache', - search: { mode: 'code', top: 5 }, - limits: { concurrency: 1 } -}; - -const first = await runFederatedSearch(baseRequest, { searchFn }); -assert.equal(first.ok, true); -assert.equal(first.code.length, 1, 'first request should succeed with partial results'); -assert.ok( - first.repos.some((entry) => entry.repoId && entry.status === 'missing_index'), - 'first request should record the repo failure' -); - -const second = await runFederatedSearch(baseRequest, { searchFn }); -assert.equal(second.ok, true); -assert.equal(second.code.length, 2, 'second request should re-run fanout and include recovered repo'); -assert.equal(searchCalls, 4, 'partial first response should not be reused from cache'); - -console.log('federated partial failures should not be cached test passed'); diff --git a/tests/retrieval/federation/path-redaction-contract-matrix.test.js b/tests/retrieval/federation/path-redaction-contract-matrix.test.js new file mode 100644 index 000000000..4a5fd60ff --- /dev/null +++ b/tests/retrieval/federation/path-redaction-contract-matrix.test.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { writeFederationRepoFixture } from './repo-fixture.js'; + +const withTempRoot = async (prefix, run) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await run({ + tempRoot, + cacheRoot: path.join(tempRoot, 'cache') + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +const cases = [ + { + name: 'diagnostic messages redact embedded absolute paths while preserving context', + async run() { + await withTempRoot('poc-fed-redact-diagnostic-', async ({ tempRoot, cacheRoot }) => { + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "a", "priority": 10 }, + { "root": "./repo-b", "alias": "b", "priority": 5 } + ] +}`, 'utf8'); + + const leakedPath = path.join(repoB, 'index-code', 'pieces', 'manifest.json'); + const response = await runFederatedSearch({ + workspacePath, + query: 'diagnostic-redaction', + search: { mode: 'code', top: 5 } + }, { + searchFn: async (repoRootCanonical) => { + if (path.basename(repoRootCanonical) === 'repo-b') { + throw createError(ERROR_CODES.NO_INDEX, `Missing pieces manifest: ${leakedPath}`); + } + return { + backend: 'memory', + code: [{ id: 'hit-a', file: 'src/a.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } + }); + + assert.equal(response.ok, true); + assert.equal(response.code.length, 1); + const missingRepo = (response.repos || []).find((entry) => entry.repoId && entry.status === 'missing_index'); + assert.ok(missingRepo); + const message = String(missingRepo?.error?.message || ''); + assert.ok(message.includes('Missing pieces manifest:')); + assert.ok(message.includes('')); + assert.equal(message.includes(leakedPath), false); + }); + } + }, + { + name: 'field-level redaction preserves non-path snippets and stays cached', + async run() { + await withTempRoot('poc-fed-redact-fields-', async ({ tempRoot, cacheRoot }) => { + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "sample" } + ] +}`, 'utf8'); + + const literalSnippet = 'C:\\snippet\\literal text should survive redaction'; + const absoluteFilePath = path.join(repoRoot, 'src', 'app.js'); + let searchCalls = 0; + const request = { + workspacePath, + query: 'redaction-scope', + select: [repoRoot], + search: { mode: 'code', top: 5 } + }; + + const searchFn = async () => { + searchCalls += 1; + return { + backend: 'memory', + code: [ + { + id: 'hit-1', + file: absoluteFilePath, + snippet: literalSnippet, + start: 1, + end: 1, + score: 1 + } + ], + prose: [], + extractedProse: [], + records: [] + }; + }; + + const first = await runFederatedSearch(request, { searchFn }); + const second = await runFederatedSearch(request, { searchFn }); + + assert.equal(searchCalls, 1); + assert.equal(first.code[0]?.file, ''); + assert.equal(first.code[0]?.snippet, literalSnippet); + assert.equal(first.meta?.selection?.explicitSelects?.[0], ''); + assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.rootAbs, ''); + assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.repoRootResolved, ''); + assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.indexes?.code?.indexDir, ''); + assert.equal(second.code[0]?.file, ''); + assert.equal(second.code[0]?.snippet, literalSnippet); + assert.equal(second.meta?.selection?.explicitSelects?.[0], ''); + assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.rootAbs, ''); + assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.repoRootResolved, ''); + assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.indexes?.code?.indexDir, ''); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('federation path redaction contract matrix test passed'); diff --git a/tests/retrieval/federation/path-redaction-diagnostic-message.test.js b/tests/retrieval/federation/path-redaction-diagnostic-message.test.js deleted file mode 100644 index 8093b55a0..000000000 --- a/tests/retrieval/federation/path-redaction-diagnostic-message.test.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-redact-diagnostic-message-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "a", "priority": 10 }, - { "root": "./repo-b", "alias": "b", "priority": 5 } - ] -}`, 'utf8'); - -const leakedPath = path.join(repoB, 'index-code', 'pieces', 'manifest.json'); -const searchFn = async (repoRootCanonical) => { - const repoName = path.basename(repoRootCanonical); - if (repoName === 'repo-b') { - throw createError(ERROR_CODES.NO_INDEX, `Missing pieces manifest: ${leakedPath}`); - } - return { - backend: 'memory', - code: [{ id: 'hit-a', file: 'src/a.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const response = await runFederatedSearch({ - workspacePath, - query: 'diagnostic-redaction', - search: { mode: 'code', top: 5 } -}, { searchFn }); - -assert.equal(response.ok, true); -assert.equal(response.code.length, 1, 'successful repo should still return hits'); -const missingRepo = (response.repos || []).find((entry) => entry.repoId && entry.status === 'missing_index'); -assert.ok(missingRepo, 'missing-index repo diagnostic should be present'); -const message = String(missingRepo?.error?.message || ''); -assert.ok(message.includes('Missing pieces manifest:'), 'diagnostic context should remain readable'); -assert.ok(message.includes(''), 'diagnostic should redact embedded absolute paths'); -assert.equal(message.includes(leakedPath), false, 'diagnostic should not leak absolute path'); - -console.log('federated diagnostic message path redaction test passed'); diff --git a/tests/retrieval/federation/path-redaction-fields-only.test.js b/tests/retrieval/federation/path-redaction-fields-only.test.js deleted file mode 100644 index ad12256e8..000000000 --- a/tests/retrieval/federation/path-redaction-fields-only.test.js +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-redact-fields-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-test' -}, null, 2), 'utf8'); - -const literalSnippet = 'C:\\snippet\\literal text should survive redaction'; -const absoluteFilePath = path.join(repoRoot, 'src', 'app.js'); -let searchCalls = 0; - -const request = { - workspacePath, - query: 'redaction-scope', - select: [repoRoot], - search: { - mode: 'code', - top: 5 - } -}; - -const searchFn = async () => { - searchCalls += 1; - return { - backend: 'memory', - code: [ - { - id: 'hit-1', - file: absoluteFilePath, - snippet: literalSnippet, - start: 1, - end: 1, - score: 1 - } - ], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const first = await runFederatedSearch(request, { searchFn }); -const second = await runFederatedSearch(request, { searchFn }); - -assert.equal(searchCalls, 1, 'second request should reuse federated cache'); -assert.equal(first.code[0]?.file, '', 'absolute file path field should be redacted'); -assert.equal(first.code[0]?.snippet, literalSnippet, 'non-path snippet content should not be redacted'); -assert.equal(first.meta?.selection?.explicitSelects?.[0], '', 'absolute explicit select path should be redacted'); -assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.rootAbs, '', 'cohort rootAbs should be redacted'); -assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.repoRootResolved, '', 'cohort repoRootResolved should be redacted'); -assert.equal(first.meta?.cohorts?.selectedReposByMode?.code?.[0]?.indexes?.code?.indexDir, '', 'cohort indexDir should be redacted'); -assert.equal(second.code[0]?.file, '', 'cached file field should remain redacted'); -assert.equal(second.code[0]?.snippet, literalSnippet, 'cached snippet should preserve original content'); -assert.equal(second.meta?.selection?.explicitSelects?.[0], '', 'cached explicit select path should remain redacted'); -assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.rootAbs, '', 'cached cohort rootAbs should remain redacted'); -assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.repoRootResolved, '', 'cached cohort repoRootResolved should remain redacted'); -assert.equal(second.meta?.cohorts?.selectedReposByMode?.code?.[0]?.indexes?.code?.indexDir, '', 'cached cohort indexDir should remain redacted'); - -console.log('federation path redaction field scope test passed'); diff --git a/tests/retrieval/federation/per-repo-mode-eligibility.test.js b/tests/retrieval/federation/per-repo-mode-eligibility.test.js deleted file mode 100644 index 1411f47ea..000000000 --- a/tests/retrieval/federation/per-repo-mode-eligibility.test.js +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { parseFederatedCliRequest } from '../../../src/retrieval/federation/args.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-per-repo-mode-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoCode = path.join(tempRoot, 'repo-code'); -const repoProse = path.join(tempRoot, 'repo-prose'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoCode, ['code']); -await writeRepo(repoProse, ['prose']); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-code", "alias": "code-only", "priority": 10 }, - { "root": "./repo-prose", "alias": "prose-only", "priority": 5 } - ] -}`, 'utf8'); - -const queryToken = 'federated-per-repo-mode'; -const request = parseFederatedCliRequest([ - queryToken, - '--workspace', - workspacePath, - '--mode', - 'both', - '--top', - '5' -]); - -const searchCalls = []; -const getModeFromArgs = (args = []) => { - for (let i = 0; i < args.length; i += 1) { - const token = String(args[i] || ''); - if (token === '--mode') { - return String(args[i + 1] || '').trim().toLowerCase(); - } - if (token.startsWith('--mode=')) { - return token.slice('--mode='.length).trim().toLowerCase(); - } - } - return ''; -}; - -const response = await runFederatedSearch(request, { - searchFn: async (repoRootCanonical, params) => { - const leaf = path.basename(repoRootCanonical); - const mode = getModeFromArgs(params?.args); - searchCalls.push({ leaf, mode }); - if (leaf === 'repo-code') { - if (mode !== 'code') throw createError(ERROR_CODES.NO_INDEX, `repo-code only supports code, got ${mode || 'default'}`); - return { - backend: 'memory', - code: [{ id: 'code-hit', file: 'src/code.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; - } - if (leaf === 'repo-prose') { - if (mode !== 'prose') throw createError(ERROR_CODES.NO_INDEX, `repo-prose only supports prose, got ${mode || 'default'}`); - return { - backend: 'memory', - code: [], - prose: [{ id: 'prose-hit', file: 'docs/prose.md', start: 1, end: 1, score: 1 }], - extractedProse: [], - records: [] - }; - } - throw createError(ERROR_CODES.INTERNAL, `unexpected repo ${leaf}`); - } -}); - -assert.equal(response.ok, true); -assert.equal(response.code.length, 1, 'code-only repo should still contribute code hits'); -assert.equal(response.prose.length, 1, 'prose-only repo should still contribute prose hits'); -assert.equal(response.extractedProse.length, 0, 'no repo provides extracted-prose in this fixture'); - -const callsByRepo = new Map(searchCalls.map((entry) => [entry.leaf, entry.mode])); -assert.equal(callsByRepo.get('repo-code'), 'code', 'repo-code fanout should use --mode code'); -assert.equal(callsByRepo.get('repo-prose'), 'prose', 'repo-prose fanout should use --mode prose'); - -console.log('federated per-repo mode eligibility test passed'); diff --git a/tests/retrieval/federation/query-cache-corrupt-quarantine.test.js b/tests/retrieval/federation/query-cache-corrupt-quarantine.test.js new file mode 100644 index 000000000..c5b90838b --- /dev/null +++ b/tests/retrieval/federation/query-cache-corrupt-quarantine.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadFederatedQueryCache } from '../../../src/retrieval/federation/query-cache.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-fed-cache-corrupt-')); +const cachePath = path.join(tempRoot, 'queryCache.json'); +await fs.writeFile(cachePath, '{not-json', 'utf8'); + +const warnings = []; +const health = {}; +const cache = await loadFederatedQueryCache({ + cachePath, + repoSetId: 'ws-corrupt', + log: (message) => warnings.push(String(message)), + health +}); + +assert.equal(cache?.schemaVersion, 1, 'expected empty cache schema payload after parse failure'); +assert.equal( + Object.keys(cache?.entries || {}).length, + 0, + 'expected parse failure to fail open with empty federated cache entries' +); +assert.equal(health.federatedQueryCacheParseFailures, 1, 'expected parse failure health counter'); +assert.equal( + warnings.some((entry) => entry.includes('cache parse failed')), + true, + 'expected parse warning message' +); +const originalExists = await fs.stat(cachePath).then(() => true).catch(() => false); +assert.equal(originalExists, false, 'expected corrupted federated cache file to be quarantined'); +const files = await fs.readdir(tempRoot); +assert.equal( + files.some((entry) => entry.startsWith('queryCache.json.corrupt-')), + true, + 'expected quarantined federated cache file marker' +); + +console.log('federated query cache corrupt quarantine test passed'); diff --git a/tests/retrieval/federation/query-cache-invalidation-via-manifesthash.test.js b/tests/retrieval/federation/query-cache-invalidation-via-manifesthash.test.js deleted file mode 100644 index 47476926e..000000000 --- a/tests/retrieval/federation/query-cache-invalidation-via-manifesthash.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { - buildFederatedQueryCacheKey, - buildFederatedQueryCacheKeyPayload, - findFederatedQueryCacheEntry, - loadFederatedQueryCache, - persistFederatedQueryCache, - upsertFederatedQueryCacheEntry -} from '../../../src/retrieval/federation/query-cache.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-cache-manifest-')); -const cachePath = path.join(tempRoot, 'queryCache.json'); -const repoSetId = 'ws1-demo'; - -const payload = buildFederatedQueryCacheKeyPayload({ - repoSetId, - manifestHash: 'wm1-a', - query: 'needle', - selection: { - selectedRepoIds: ['repo-a'] - }, - cohorts: { - policy: 'default', - modeSelections: { code: null } - }, - search: { mode: 'code', top: 10 }, - merge: { strategy: 'rrf', rrfK: 60 }, - limits: { top: 10, perRepoTop: 20, concurrency: 2 } -}); -const keyInfo = buildFederatedQueryCacheKey(payload); - -const cache = await loadFederatedQueryCache({ cachePath, repoSetId }); -upsertFederatedQueryCacheEntry(cache, { - keyHash: keyInfo.keyHash, - keyPayloadHash: keyInfo.keyPayloadHash, - manifestHash: 'wm1-a', - result: { ok: true, backend: 'federated', code: [], prose: [], extractedProse: [], records: [] } -}); -await persistFederatedQueryCache({ cachePath, cache }); - -const loaded = await loadFederatedQueryCache({ cachePath, repoSetId }); -const hit = findFederatedQueryCacheEntry(loaded, { - keyHash: keyInfo.keyHash, - manifestHash: 'wm1-a' -}); -assert.ok(hit, 'expected cache hit for matching manifest hash'); - -const miss = findFederatedQueryCacheEntry(loaded, { - keyHash: keyInfo.keyHash, - manifestHash: 'wm1-b' -}); -assert.equal(miss, null, 'manifest hash mismatch must invalidate cache entry'); - -console.log('federated query cache manifest invalidation test passed'); diff --git a/tests/retrieval/federation/query-cache-key-contract-matrix.test.js b/tests/retrieval/federation/query-cache-key-contract-matrix.test.js new file mode 100644 index 000000000..069a74b63 --- /dev/null +++ b/tests/retrieval/federation/query-cache-key-contract-matrix.test.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { + buildFederatedQueryCacheKey, + buildFederatedQueryCacheKeyPayload, + findFederatedQueryCacheEntry, + loadFederatedQueryCache, + persistFederatedQueryCache, + upsertFederatedQueryCacheEntry +} from '../../../src/retrieval/federation/query-cache.js'; +import { writeFederationRepoFixture } from './repo-fixture.js'; + +const withTempWorkspace = async (prefix, build) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await build(tempRoot); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +const cases = [ + { + name: 'manifest hash participates in federated query cache invalidation', + async run() { + await withTempWorkspace('pairofcleats-fed-cache-manifest-', async (tempRoot) => { + const cachePath = path.join(tempRoot, 'queryCache.json'); + const repoSetId = 'ws1-demo'; + + const payload = buildFederatedQueryCacheKeyPayload({ + repoSetId, + manifestHash: 'wm1-a', + query: 'needle', + selection: { + selectedRepoIds: ['repo-a'] + }, + cohorts: { + policy: 'default', + modeSelections: { code: null } + }, + search: { mode: 'code', top: 10 }, + merge: { strategy: 'rrf', rrfK: 60 }, + limits: { top: 10, perRepoTop: 20, concurrency: 2 } + }); + const keyInfo = buildFederatedQueryCacheKey(payload); + + const cache = await loadFederatedQueryCache({ cachePath, repoSetId }); + upsertFederatedQueryCacheEntry(cache, { + keyHash: keyInfo.keyHash, + keyPayloadHash: keyInfo.keyPayloadHash, + manifestHash: 'wm1-a', + result: { ok: true, backend: 'federated', code: [], prose: [], extractedProse: [], records: [] } + }); + await persistFederatedQueryCache({ cachePath, cache }); + + const loaded = await loadFederatedQueryCache({ cachePath, repoSetId }); + assert.ok(findFederatedQueryCacheEntry(loaded, { + keyHash: keyInfo.keyHash, + manifestHash: 'wm1-a' + })); + assert.equal(findFederatedQueryCacheEntry(loaded, { + keyHash: keyInfo.keyHash, + manifestHash: 'wm1-b' + }), null); + }); + } + }, + { + name: 'key payload normalization is byte-stable and sensitive to workspace metadata', + async run() { + const payloadA = buildFederatedQueryCacheKeyPayload({ + repoSetId: 'ws1-demo', + manifestHash: 'wm1-alpha', + query: 'greet', + workspace: { configHash: 'wsc1-alpha' }, + selection: { + selectedRepoIds: ['repo-b', 'repo-a'], + selectedRepoPriorities: ['repo-b:5', 'repo-a:10'], + includeDisabled: false, + tags: ['service', 'api'], + repoFilter: ['repo-*', 'svc-*'], + explicitSelects: ['repo-b', 'repo-a'] + }, + cohorts: { + policy: 'default', + modeSelections: { code: 'cohort-a', prose: 'cohort-a' }, + excluded: { + code: [{ repoId: 'repo-c', effectiveKey: 'cohort-b', reason: 'cohort-excluded' }] + } + }, + cohortSelectors: ['code:cohort-a'], + search: { mode: 'code', top: 10, backend: 'auto' }, + merge: { strategy: 'rrf', rrfK: 60 }, + limits: { top: 10, perRepoTop: 20, concurrency: 4 }, + runtime: { perRepoArgs: ['--json', '--compact', '--top', '20'], requestedBackend: 'auto' } + }); + const payloadB = buildFederatedQueryCacheKeyPayload({ + repoSetId: 'ws1-demo', + manifestHash: 'wm1-alpha', + query: 'greet', + workspace: { configHash: 'wsc1-alpha' }, + selection: { + selectedRepoIds: ['repo-a', 'repo-b'], + selectedRepoPriorities: ['repo-a:10', 'repo-b:5'], + includeDisabled: false, + tags: ['api', 'service'], + repoFilter: ['svc-*', 'repo-*'], + explicitSelects: ['repo-a', 'repo-b'] + }, + cohorts: { + policy: 'default', + modeSelections: { prose: 'cohort-a', code: 'cohort-a' }, + excluded: { + code: [{ reason: 'cohort-excluded', effectiveKey: 'cohort-b', repoId: 'repo-c' }] + } + }, + cohortSelectors: ['code:cohort-a'], + search: { backend: 'auto', top: 10, mode: 'code' }, + merge: { rrfK: 60, strategy: 'rrf' }, + limits: { concurrency: 4, perRepoTop: 20, top: 10 }, + runtime: { requestedBackend: 'auto', perRepoArgs: ['--json', '--compact', '--top', '20'] } + }); + const keyA = buildFederatedQueryCacheKey(payloadA); + const keyB = buildFederatedQueryCacheKey(payloadB); + assert.equal(stableStringify(payloadA), stableStringify(payloadB)); + assert.equal(keyA.keyHash, keyB.keyHash); + assert.equal(keyA.keyPayloadHash, keyB.keyPayloadHash); + + const payloadC = buildFederatedQueryCacheKeyPayload({ + ...payloadA, + selection: { + ...payloadA.selection, + selectedRepoPriorities: ['repo-a:2', 'repo-b:1'] + } + }); + const payloadD = buildFederatedQueryCacheKeyPayload({ + ...payloadA, + workspace: { configHash: 'wsc1-beta' } + }); + assert.notEqual(keyA.keyHash, buildFederatedQueryCacheKey(payloadC).keyHash); + assert.notEqual(keyA.keyHash, buildFederatedQueryCacheKey(payloadD).keyHash); + } + }, + { + name: 'workspace metadata changes invalidate federated cache entries', + async run() { + await withTempWorkspace('pairofcleats-fed-cache-workspace-meta-', async (tempRoot) => { + const cacheRoot = path.join(tempRoot, 'cache'); + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "name": "Workspace Alpha", + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "alpha" } + ] +}`, 'utf8'); + let searchCalls = 0; + const searchFn = async () => { + searchCalls += 1; + return { + backend: 'memory', + code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + const first = await runFederatedSearch({ + workspacePath, + query: 'cache-workspace-meta', + search: { mode: 'code', top: 5 } + }, { searchFn }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "name": "Workspace Beta", + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "beta" } + ] +}`, 'utf8'); + const second = await runFederatedSearch({ + workspacePath, + query: 'cache-workspace-meta', + search: { mode: 'code', top: 5 } + }, { searchFn }); + assert.equal(searchCalls, 2); + assert.equal(first.meta?.workspace?.name, 'Workspace Alpha'); + assert.equal(second.meta?.workspace?.name, 'Workspace Beta'); + assert.equal(first.code[0]?.repoAlias, 'alpha'); + assert.equal(second.code[0]?.repoAlias, 'beta'); + }); + } + }, + { + name: 'strict mode does not reuse non-strict cache entries', + async run() { + await withTempWorkspace('pairofcleats-fed-strict-cache-key-', async (tempRoot) => { + const cacheRoot = path.join(tempRoot, 'cache'); + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "a", "priority": 10 }, + { "root": "./repo-b", "alias": "b", "priority": 1 } + ] +}`, 'utf8'); + let searchCalls = 0; + const searchFn = async (repoRootCanonical) => { + searchCalls += 1; + if (path.basename(repoRootCanonical) === 'repo-a') { + throw createError(ERROR_CODES.NO_INDEX, 'missing index for strict cache-key test'); + } + return { + backend: 'memory', + code: [{ id: 'hit-b', file: 'src/b.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + const baseRequest = { + workspacePath, + query: 'strict-cache-key-separation', + search: { mode: 'code', top: 5 }, + limits: { concurrency: 1 } + }; + const nonStrict = await runFederatedSearch(baseRequest, { searchFn }); + assert.equal(nonStrict.ok, true); + assert.equal(nonStrict.code.length, 1); + await assert.rejects( + runFederatedSearch({ ...baseRequest, strict: true }, { searchFn }), + (error) => error?.code === ERROR_CODES.NO_INDEX + ); + assert.ok(searchCalls >= 3); + }); + } + }, + { + name: 'object-only select does not fragment equivalent cache keys', + async run() { + await withTempWorkspace('pairofcleats-fed-select-object-no-fragment-', async (tempRoot) => { + const cacheRoot = path.join(tempRoot, 'cache'); + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot, cacheRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "sample", "enabled": false } + ] +}`, 'utf8'); + let searchCalls = 0; + const searchFn = async () => { + searchCalls += 1; + return { + backend: 'memory', + code: [{ id: 'hit-1', file: 'src/app.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + const first = await runFederatedSearch({ + workspacePath, + query: 'select-object-only', + search: { mode: 'code', top: 5 }, + select: { includeDisabled: true } + }, { searchFn }); + assert.equal(first.ok, true); + assert.deepEqual(first.meta?.selection?.explicitSelects || [], []); + assert.equal(first.code.length, 1); + const second = await runFederatedSearch({ + workspacePath, + query: 'select-object-only', + search: { mode: 'code', top: 5 }, + includeDisabled: true + }, { searchFn }); + assert.equal(second.ok, true); + assert.equal(second.code.length, 1); + assert.equal(searchCalls, 1); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('federated query cache key contract matrix test passed'); diff --git a/tests/retrieval/federation/query-cache-key-stability.test.js b/tests/retrieval/federation/query-cache-key-stability.test.js deleted file mode 100644 index bde2a501b..000000000 --- a/tests/retrieval/federation/query-cache-key-stability.test.js +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { - buildFederatedQueryCacheKey, - buildFederatedQueryCacheKeyPayload -} from '../../../src/retrieval/federation/query-cache.js'; - -const payloadA = buildFederatedQueryCacheKeyPayload({ - repoSetId: 'ws1-demo', - manifestHash: 'wm1-alpha', - query: 'greet', - workspace: { - configHash: 'wsc1-alpha' - }, - selection: { - selectedRepoIds: ['repo-b', 'repo-a'], - selectedRepoPriorities: ['repo-b:5', 'repo-a:10'], - includeDisabled: false, - tags: ['service', 'api'], - repoFilter: ['repo-*', 'svc-*'], - explicitSelects: ['repo-b', 'repo-a'] - }, - cohorts: { - policy: 'default', - modeSelections: { code: 'cohort-a', prose: 'cohort-a' }, - excluded: { - code: [{ repoId: 'repo-c', effectiveKey: 'cohort-b', reason: 'cohort-excluded' }] - } - }, - cohortSelectors: ['code:cohort-a'], - search: { - mode: 'code', - top: 10, - backend: 'auto' - }, - merge: { - strategy: 'rrf', - rrfK: 60 - }, - limits: { - top: 10, - perRepoTop: 20, - concurrency: 4 - }, - runtime: { - perRepoArgs: ['--json', '--compact', '--top', '20'], - requestedBackend: 'auto' - } -}); - -const payloadB = buildFederatedQueryCacheKeyPayload({ - repoSetId: 'ws1-demo', - manifestHash: 'wm1-alpha', - query: 'greet', - workspace: { - configHash: 'wsc1-alpha' - }, - selection: { - selectedRepoIds: ['repo-a', 'repo-b'], - selectedRepoPriorities: ['repo-a:10', 'repo-b:5'], - includeDisabled: false, - tags: ['api', 'service'], - repoFilter: ['svc-*', 'repo-*'], - explicitSelects: ['repo-a', 'repo-b'] - }, - cohorts: { - policy: 'default', - modeSelections: { prose: 'cohort-a', code: 'cohort-a' }, - excluded: { - code: [{ reason: 'cohort-excluded', effectiveKey: 'cohort-b', repoId: 'repo-c' }] - } - }, - cohortSelectors: ['code:cohort-a'], - search: { - backend: 'auto', - top: 10, - mode: 'code' - }, - merge: { - rrfK: 60, - strategy: 'rrf' - }, - limits: { - concurrency: 4, - perRepoTop: 20, - top: 10 - }, - runtime: { - requestedBackend: 'auto', - perRepoArgs: ['--json', '--compact', '--top', '20'] - } -}); - -const keyA = buildFederatedQueryCacheKey(payloadA); -const keyB = buildFederatedQueryCacheKey(payloadB); - -assert.equal(stableStringify(payloadA), stableStringify(payloadB), 'normalized key payload should be byte-stable'); -assert.equal(keyA.keyHash, keyB.keyHash, 'equivalent payloads should generate identical cache keys'); -assert.equal(keyA.keyPayloadHash, keyB.keyPayloadHash, 'equivalent payloads should generate identical payload hashes'); - -const payloadC = buildFederatedQueryCacheKeyPayload({ - ...payloadA, - selection: { - ...payloadA.selection, - selectedRepoPriorities: ['repo-a:2', 'repo-b:1'] - } -}); -const keyC = buildFederatedQueryCacheKey(payloadC); -assert.notEqual( - keyA.keyHash, - keyC.keyHash, - 'repo priority changes should invalidate federated cache keys' -); - -const payloadD = buildFederatedQueryCacheKeyPayload({ - ...payloadA, - workspace: { - configHash: 'wsc1-beta' - } -}); -const keyD = buildFederatedQueryCacheKey(payloadD); -assert.notEqual( - keyA.keyHash, - keyD.keyHash, - 'workspace metadata hash changes should invalidate federated cache keys' -); - -console.log('federated query cache key stability test passed'); diff --git a/tests/retrieval/federation/raw-args-compact-top-flag.test.js b/tests/retrieval/federation/raw-args-compact-top-flag.test.js deleted file mode 100644 index 8ea771d49..000000000 --- a/tests/retrieval/federation/raw-args-compact-top-flag.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { parseSearchArgs } from '../../../src/retrieval/cli-args.js'; -import { buildPerRepoArgsFromCli } from '../../../src/retrieval/federation/args.js'; - -const perRepoTop = 7; -const args = buildPerRepoArgsFromCli({ - rawArgs: [ - 'query-token', - '--workspace', - 'workspace.jsonc', - '-n10' - ], - perRepoTop -}); - -assert.equal(args.includes('-n10'), false, 'compact -n10 should be removed before per-repo forwarding'); -assert.equal( - args.filter((token) => token === '--top').length, - 1, - 'exactly one top flag should remain after forwarding rewrite' -); -assert.equal(args.includes('--json'), true, 'json output should be enforced for federated per-repo calls'); - -const parsed = parseSearchArgs(args); -assert.equal(Array.isArray(parsed.top), false, 'top should remain scalar after rewrite'); -assert.equal(Array.isArray(parsed.n), false, 'n alias should remain scalar after rewrite'); -assert.equal(Number(parsed.top), perRepoTop, 'rewritten top value should match perRepoTop'); - -assert.throws( - () => buildPerRepoArgsFromCli({ - rawArgs: [ - 'query-token', - '--workspace', - 'workspace.jsonc', - '-n10', - '--top', - '5' - ], - perRepoTop - }), - /multiple --top values/i, - 'compact -n10 should count as a top flag for duplicate detection' -); - -console.log('federated raw args compact top flag test passed'); diff --git a/tests/retrieval/federation/raw-args-contract-matrix.test.js b/tests/retrieval/federation/raw-args-contract-matrix.test.js new file mode 100644 index 000000000..b0659bbf0 --- /dev/null +++ b/tests/retrieval/federation/raw-args-contract-matrix.test.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + buildPerRepoArgsFromCli, + parseFederatedCliRequest +} from '../../../src/retrieval/federation/args.js'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { + createWorkspaceFixture, + removeWorkspaceFixture, + writeIndexArtifacts +} from '../../helpers/workspace-fixture.js'; + +const withWorkspace = async ({ modes, compatibilityKey = 'compat-test' }, run) => { + const fixture = await createWorkspaceFixture('pairofcleats-fed-rawargs-matrix-'); + try { + const buildRoot = path.join(fixture.repoCacheRoot, 'builds', 'test-build'); + await fs.mkdir(buildRoot, { recursive: true }); + for (const mode of modes) { + await writeIndexArtifacts({ + buildRoot, + mode, + compatibilityKey: `${compatibilityKey}-${mode}` + }); + } + await fs.writeFile( + path.join(fixture.repoCacheRoot, 'builds', 'current.json'), + JSON.stringify({ + buildId: 'test-build', + buildRoot, + modes + }, null, 2), + 'utf8' + ); + await run(fixture); + } finally { + await removeWorkspaceFixture(fixture.tempRoot); + } +}; + +const cases = [ + { + name: 'CLI mode forwards into federated request', + async run() { + const workspacePath = 'C:\\workspace\\.pairofcleats-workspace.jsonc'; + const request = parseFederatedCliRequest([ + '--workspace', + workspacePath, + '--mode', + 'records', + 'find-me' + ]); + assert.equal(request.workspacePath, workspacePath); + assert.equal(request.query, 'find-me'); + assert.equal(request.mode, 'records'); + } + }, + { + name: 'per-repo args preserve end-of-options marker and inject json/top once', + async run() { + const args = buildPerRepoArgsFromCli({ + rawArgs: [ + 'query-token', + '--workspace', + 'workspace.jsonc', + '--top', + '9', + '--', + '--tag', + '--top', + '-n', + '--repo-filter' + ], + perRepoTop: 7 + }); + const marker = args.indexOf('--'); + assert.ok(marker > -1); + const optionTokens = args.slice(0, marker); + const positionalTokens = args.slice(marker + 1); + assert.equal(optionTokens.includes('--workspace'), false); + assert.deepEqual(positionalTokens, ['--tag', '--top', '-n', '--repo-filter']); + assert.equal(optionTokens.filter((token) => token === '--top').length, 1); + assert.equal(optionTokens[optionTokens.length - 2], '--top'); + assert.equal(optionTokens[optionTokens.length - 1], '7'); + assert.equal(optionTokens.includes('--json'), true); + assert.doesNotThrow(() => buildPerRepoArgsFromCli({ + rawArgs: [ + 'query-token', + '--workspace', + 'workspace.jsonc', + '--', + '--top', + '--top' + ], + perRepoTop: 7 + })); + } + }, + { + name: 'compact top flag rewrites cleanly and still detects duplicates', + async run() { + const perRepoTop = 7; + const args = buildPerRepoArgsFromCli({ + rawArgs: [ + 'query-token', + '--workspace', + 'workspace.jsonc', + '-n10' + ], + perRepoTop + }); + assert.equal(args.includes('-n10'), false); + assert.equal(args.filter((token) => token === '--top').length, 1); + assert.equal(args.includes('--json'), true); + const topFlagIndex = args.indexOf('--top'); + assert.equal(args[topFlagIndex + 1], String(perRepoTop)); + assert.throws( + () => buildPerRepoArgsFromCli({ + rawArgs: [ + 'query-token', + '--workspace', + 'workspace.jsonc', + '-n10', + '--top', + '5' + ], + perRepoTop + }), + /multiple --top values/i + ); + } + }, + { + name: 'query token forwards exactly once in raw-args execution', + async run() { + await withWorkspace({ modes: ['code'] }, async ({ workspacePath }) => { + const queryToken = 'federated-query-token'; + const rawArgs = [ + queryToken, + '--workspace', + workspacePath, + '--mode', + 'code', + '--top', + '5' + ]; + const searchCalls = []; + await runFederatedSearch({ + workspacePath, + query: queryToken, + rawArgs + }, { + searchFn: async (_repoRootCanonical, params) => { + searchCalls.push(params); + return { + backend: 'memory', + code: [], + prose: [], + extractedProse: [], + records: [] + }; + } + }); + assert.equal(searchCalls.length, 1); + const [call] = searchCalls; + assert.equal(String(call?.query || ''), ''); + assert.equal( + (Array.isArray(call?.args) ? call.args : []).filter((token) => token === queryToken).length, + 1 + ); + }); + } + }, + { + name: 'records mode forwards and only merges records hits', + async run() { + await withWorkspace({ modes: ['records'] }, async ({ workspacePath }) => { + const queryToken = 'federated-mode-records-token'; + const rawArgs = [ + queryToken, + '--workspace', + workspacePath, + '--mode', + 'records', + '--top', + '5' + ]; + const request = parseFederatedCliRequest(rawArgs); + const searchCalls = []; + const response = await runFederatedSearch(request, { + searchFn: async (_repoRootCanonical, params) => { + searchCalls.push(params); + return { + backend: 'memory', + code: [{ id: 'code-1', file: 'src/code.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [{ id: 'record-1', file: 'records/input.json', start: 1, end: 1, score: 1 }] + }; + } + }); + assert.equal(searchCalls.length, 1); + const [call] = searchCalls; + assert.equal(String(call?.query || ''), ''); + assert.equal(Array.isArray(call?.args) && call.args.includes('records'), true); + assert.equal(response.records.length, 1); + assert.equal(response.code.length, 0); + assert.equal(response.prose.length, 0); + assert.equal(response.extractedProse.length, 0); + }); + } + }, + { + name: 'top zero survives parse and per-repo forwarding', + async run() { + await withWorkspace({ modes: ['code'] }, async ({ workspacePath }) => { + const request = parseFederatedCliRequest([ + '--workspace', + workspacePath, + '--mode', + 'code', + '--top', + '0', + 'needle' + ]); + assert.equal(request.top, 0); + assert.equal(request.perRepoTop, 0); + const searchCalls = []; + const response = await runFederatedSearch(request, { + searchFn: async (_repoRootCanonical, params) => { + searchCalls.push(params); + return { + backend: 'memory', + code: [ + { id: 'hit-1', file: 'src/a.js', start: 1, end: 1, score: 1 }, + { id: 'hit-2', file: 'src/b.js', start: 1, end: 1, score: 1 } + ], + prose: [], + extractedProse: [], + records: [] + }; + } + }); + const args = Array.isArray(searchCalls[0]?.args) ? searchCalls[0].args : []; + const topFlagIndex = args.findIndex((token) => token === '--top'); + assert.notEqual(topFlagIndex, -1); + assert.equal(args[topFlagIndex + 1], '0'); + assert.equal(response.code.length, 0); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('federated raw args contract matrix test passed'); diff --git a/tests/retrieval/federation/raw-args-end-of-options.test.js b/tests/retrieval/federation/raw-args-end-of-options.test.js deleted file mode 100644 index 876b76bca..000000000 --- a/tests/retrieval/federation/raw-args-end-of-options.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildPerRepoArgsFromCli } from '../../../src/retrieval/federation/args.js'; - -const args = buildPerRepoArgsFromCli({ - rawArgs: [ - 'query-token', - '--workspace', - 'workspace.jsonc', - '--top', - '9', - '--', - '--tag', - '--top', - '-n', - '--repo-filter' - ], - perRepoTop: 7 -}); - -const marker = args.indexOf('--'); -assert.ok(marker > -1, 'expected end-of-options marker to be preserved'); - -const optionTokens = args.slice(0, marker); -const positionalTokens = args.slice(marker + 1); - -assert.equal(optionTokens.includes('--workspace'), false, 'workspace flag must be stripped from option section'); -assert.deepEqual( - positionalTokens, - ['--tag', '--top', '-n', '--repo-filter'], - 'flag-like query tokens after -- must remain verbatim' -); -assert.equal( - optionTokens.filter((token) => token === '--top').length, - 1, - 'expected exactly one injected --top option before --' -); -assert.equal( - optionTokens[optionTokens.length - 2], - '--top', - 'injected --top should be appended in option section' -); -assert.equal(optionTokens[optionTokens.length - 1], '7', 'injected --top value should match perRepoTop'); -assert.equal(optionTokens.includes('--json'), true, 'expected --json option to be enforced before --'); - -assert.doesNotThrow(() => buildPerRepoArgsFromCli({ - rawArgs: [ - 'query-token', - '--workspace', - 'workspace.jsonc', - '--', - '--top', - '--top' - ], - perRepoTop: 7 -}), 'duplicate top detection should ignore tokens after --'); - -console.log('federated raw args end-of-options test passed'); diff --git a/tests/retrieval/federation/raw-args-mode-selection.test.js b/tests/retrieval/federation/raw-args-mode-selection.test.js deleted file mode 100644 index 8ac9f2a10..000000000 --- a/tests/retrieval/federation/raw-args-mode-selection.test.js +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { parseFederatedCliRequest } from '../../../src/retrieval/federation/args.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-rawargs-mode-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const recordsIndexDir = path.join(buildRoot, 'index-records'); -await fs.mkdir(recordsIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['records'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(recordsIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(recordsIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(recordsIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-records' -}, null, 2), 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -const queryToken = 'federated-mode-records-token'; -const rawArgs = [ - queryToken, - '--workspace', - workspacePath, - '--mode', - 'records', - '--top', - '5' -]; - -const request = parseFederatedCliRequest(rawArgs); -const searchCalls = []; - -const response = await runFederatedSearch(request, { - searchFn: async (_repoRootCanonical, params) => { - searchCalls.push(params); - return { - backend: 'memory', - code: [ - { - id: 'code-1', - file: 'src/code.js', - start: 1, - end: 1, - score: 1 - } - ], - prose: [], - extractedProse: [], - records: [ - { - id: 'record-1', - file: 'records/input.json', - start: 1, - end: 1, - score: 1 - } - ] - }; - } -}); - -assert.equal(searchCalls.length, 1, 'expected one federated repo search call'); -const [call] = searchCalls; -assert.equal(String(call?.query || ''), '', 'rawArgs path must not append query separately'); -assert.ok( - Array.isArray(call?.args) && call.args.includes('records'), - 'per-repo args should keep --mode records' -); -assert.equal(response.records.length, 1, 'records mode should merge records hits'); -assert.equal(response.code.length, 0, 'records mode should not merge code hits'); -assert.equal(response.prose.length, 0, 'records mode should not merge prose hits'); -assert.equal(response.extractedProse.length, 0, 'records mode should not merge extracted-prose hits'); - -console.log('federated raw args mode selection test passed'); diff --git a/tests/retrieval/federation/raw-args-query-forwarding.test.js b/tests/retrieval/federation/raw-args-query-forwarding.test.js deleted file mode 100644 index a4ce6babe..000000000 --- a/tests/retrieval/federation/raw-args-query-forwarding.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-rawargs-query-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' -}, null, 2), 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -const searchCalls = []; -const queryToken = 'federated-query-token'; -const rawArgs = [ - queryToken, - '--workspace', - workspacePath, - '--mode', - 'code', - '--top', - '5' -]; - -await runFederatedSearch({ - workspacePath, - query: queryToken, - rawArgs -}, { - searchFn: async (_repoRootCanonical, params) => { - searchCalls.push(params); - return { - backend: 'memory', - code: [], - prose: [], - extractedProse: [], - records: [] - }; - } -}); - -assert.equal(searchCalls.length, 1, 'expected one federated repo search call'); -const [call] = searchCalls; -assert.equal(String(call?.query || ''), '', 'rawArgs path must not append query separately'); -const queryMentions = (Array.isArray(call?.args) ? call.args : []) - .filter((token) => token === queryToken) - .length; -assert.equal(queryMentions, 1, 'query token should appear exactly once in per-repo args'); - -console.log('federated raw args query forwarding test passed'); diff --git a/tests/retrieval/federation/repo-fixture.js b/tests/retrieval/federation/repo-fixture.js new file mode 100644 index 000000000..1c03b757b --- /dev/null +++ b/tests/retrieval/federation/repo-fixture.js @@ -0,0 +1,65 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; + +export const DEFAULT_FEDERATION_BUILD_ID = 'test-build'; + +export const createFederationIndexDescriptor = ({ + repoCacheRoot, + buildId = DEFAULT_FEDERATION_BUILD_ID, + modes = ['code'] +}) => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + const indexes = Object.fromEntries(modes.map((mode) => [ + mode, + { + mode, + indexDir: path.join(buildRoot, `index-${mode}`), + compatibilityKey: `compat-${mode}` + } + ])); + return { + buildId, + buildRoot, + buildPointer: { + buildId, + buildRoot, + modes + }, + indexes + }; +}; + +export const writeFederationRepoFixture = async ({ + repoRoot, + cacheRoot, + modes = ['code'], + buildId = DEFAULT_FEDERATION_BUILD_ID +}) => { + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } + }, null, 2), 'utf8'); + + const repoCacheRoot = getRepoCacheRoot(repoRoot); + const descriptor = createFederationIndexDescriptor({ repoCacheRoot, buildId, modes }); + await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); + await fs.writeFile( + path.join(repoCacheRoot, 'builds', 'current.json'), + JSON.stringify(descriptor.buildPointer, null, 2), + 'utf8' + ); + for (const index of Object.values(descriptor.indexes)) { + await fs.mkdir(index.indexDir, { recursive: true }); + await fs.writeFile(path.join(index.indexDir, 'chunk_meta.json'), '[]', 'utf8'); + await fs.writeFile(path.join(index.indexDir, 'token_postings.json'), '{}', 'utf8'); + await fs.writeFile(path.join(index.indexDir, 'index_state.json'), JSON.stringify({ + compatibilityKey: index.compatibilityKey + }, null, 2), 'utf8'); + } + return { + repoCacheRoot, + ...descriptor + }; +}; diff --git a/tests/retrieval/federation/repo-generation-context-forwarding.test.js b/tests/retrieval/federation/repo-generation-context-forwarding.test.js new file mode 100644 index 000000000..7490e1a5f --- /dev/null +++ b/tests/retrieval/federation/repo-generation-context-forwarding.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-generation-context-')); +const cacheRoot = path.join(tempRoot, 'cache'); +const repoRoot = path.join(tempRoot, 'repo'); +const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + +await fs.mkdir(repoRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } +}, null, 2), 'utf8'); + +const repoCacheRoot = getRepoCacheRoot(repoRoot); +const buildRoot = path.join(repoCacheRoot, 'builds', 'build-ctx'); +const indexDir = path.join(buildRoot, 'index-code'); +await fs.mkdir(indexDir, { recursive: true }); +await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); +await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ + buildId: 'build-ctx', + buildRoot, + buildRootsByMode: { code: buildRoot }, + modes: ['code'] +}, null, 2), 'utf8'); +await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); +await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); +await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ + compatibilityKey: 'compat-code' +}, null, 2), 'utf8'); + +await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "alpha" } + ] +}`, 'utf8'); + +let capturedGenerationContext = null; +const response = await runFederatedSearch({ + workspacePath, + query: 'generation-context-forwarding', + search: { mode: 'code', top: 5 } +}, { + resolveRepoCaches: async () => ({ + indexCache: { get: () => null, set: () => {}, delete: () => {}, clear: () => {}, size: () => 0, cache: null }, + sqliteCache: null, + buildId: 'build-ctx', + buildRoot, + activeBuildRoot: buildRoot, + buildGenerationKey: 'test-generation-key' + }), + searchFn: async (_repoRootCanonical, params) => { + capturedGenerationContext = params.generationContext || null; + return { + backend: 'memory', + code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } +}); + +assert.equal(capturedGenerationContext?.buildId, 'build-ctx'); +assert.equal(capturedGenerationContext?.activeBuildRoot, buildRoot); +assert.equal(capturedGenerationContext?.buildGenerationKey, 'test-generation-key'); +assert.equal(response.repos[0]?.freshness?.buildId, 'build-ctx'); +assert.ok(response.repos[0]?.freshness?.generationKey, 'expected freshness generation key in response'); + +console.log('federated repo generation context forwarding test passed'); diff --git a/tests/retrieval/federation/repo-selection.test.js b/tests/retrieval/federation/repo-selection.test.js deleted file mode 100644 index f47aa69eb..000000000 --- a/tests/retrieval/federation/repo-selection.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { selectWorkspaceRepos } from '../../../src/retrieval/federation/select.js'; - -const workspaceConfig = { - repos: [ - { - repoId: 'repo-a', - alias: 'alpha', - repoRootCanonical: '/tmp/repo-a', - enabled: true, - priority: 2, - tags: ['service', 'api'] - }, - { - repoId: 'repo-b', - alias: 'beta', - repoRootCanonical: '/tmp/repo-b', - enabled: false, - priority: 100, - tags: ['batch'] - }, - { - repoId: 'repo-c', - alias: 'gamma', - repoRootCanonical: '/tmp/repo-c', - enabled: true, - priority: 1, - tags: ['service'] - } - ] -}; - -const explicitDisabled = selectWorkspaceRepos({ - workspaceConfig, - select: ['beta'], - includeDisabled: false -}); -assert.ok( - explicitDisabled.selectedRepos.some((repo) => repo.repoId === 'repo-b'), - 'explicit --select should include disabled repos even when includeDisabled=false' -); - -const tagged = selectWorkspaceRepos({ - workspaceConfig, - tag: ['service'] -}); -assert.deepEqual( - tagged.selectedRepos.map((repo) => repo.repoId), - ['repo-a', 'repo-c'], - '--tag should include repos with any matching tag' -); - -const filtered = selectWorkspaceRepos({ - workspaceConfig, - includeDisabled: true, - repoFilter: ['repo-*'] -}); -assert.deepEqual( - filtered.selectedRepos.map((repo) => repo.repoId), - ['repo-b', 'repo-a', 'repo-c'], - 'selection ordering should be deterministic by priority, alias, repoId' -); - -console.log('federation repo selection test passed'); diff --git a/tests/retrieval/federation/response-completeness-contract.test.js b/tests/retrieval/federation/response-completeness-contract.test.js new file mode 100644 index 000000000..46bb57005 --- /dev/null +++ b/tests/retrieval/federation/response-completeness-contract.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-response-completeness-')); +const cacheRoot = path.join(tempRoot, 'cache'); +const repoComplete = path.join(tempRoot, 'repo-complete'); +const repoFail = path.join(tempRoot, 'repo-fail'); +const repoSkipped = path.join(tempRoot, 'repo-skipped'); +const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + +const writeRepo = async (repoRoot, modes) => { + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } + }, null, 2), 'utf8'); + const repoCacheRoot = getRepoCacheRoot(repoRoot); + const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); + await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); + await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ + buildId: 'test-build', + buildRoot, + modes + }, null, 2), 'utf8'); + for (const mode of modes) { + const indexDir = path.join(buildRoot, `index-${mode}`); + await fs.mkdir(indexDir, { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ + compatibilityKey: `compat-${mode}` + }, null, 2), 'utf8'); + } +}; + +await writeRepo(repoComplete, ['code']); +await writeRepo(repoFail, ['code']); +await writeRepo(repoSkipped, ['prose']); + +await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-complete", "alias": "complete", "priority": 10 }, + { "root": "./repo-fail", "alias": "fail", "priority": 5 }, + { "root": "./repo-skipped", "alias": "skipped", "priority": 1 } + ] +}`, 'utf8'); + +const searchCalls = []; +const response = await runFederatedSearch({ + workspacePath, + query: 'response-completeness', + search: { mode: 'code', top: 5 }, + limits: { concurrency: 1 } +}, { + searchFn: async (repoRootCanonical) => { + const leaf = path.basename(repoRootCanonical); + searchCalls.push(leaf); + if (leaf === 'repo-fail') { + throw createError(ERROR_CODES.NO_INDEX, 'simulated missing code index'); + } + return { + backend: 'memory', + code: [{ id: `hit-${leaf}`, file: `src/${leaf}.js`, start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } +}); + +assert.equal(response.ok, true); +assert.equal(response.status, 'partial'); +assert.equal(response.partialSuccess, true); +assert.deepEqual(response.meta?.completeness?.repoCounts, { + complete: 1, + partial: 1, + degraded: 1, + empty: 0 +}); +assert.equal(response.meta?.policy?.acceptPartialResults, true); +assert.equal(response.meta?.policy?.responseStatus, 'partial'); +assert.deepEqual( + searchCalls.slice().sort(), + ['repo-complete', 'repo-fail'], + 'repo with no eligible requested modes should be reported, not executed' +); + +const completeRepo = response.repos.find((entry) => entry.repoId?.includes('repo-complete')); +assert.equal(completeRepo?.status, 'ok'); +assert.equal(completeRepo?.completeness, 'complete'); +assert.equal(completeRepo?.freshness?.buildId, 'test-build'); +assert.ok(completeRepo?.freshness?.generationKey, 'expected repo freshness to expose generation key'); +assert.equal(completeRepo?.modes?.fulfilled?.includes('code'), true); + +const failedRepo = response.repos.find((entry) => entry.repoId?.includes('repo-fail')); +assert.equal(failedRepo?.status, 'missing_index'); +assert.equal(failedRepo?.completeness, 'partial'); +assert.equal(failedRepo?.modes?.executionFailures?.length, 1); + +const skippedRepo = response.repos.find((entry) => entry.repoId?.includes('repo-skipped')); +assert.equal(skippedRepo?.status, 'skipped'); +assert.equal(skippedRepo?.completeness, 'degraded'); +assert.equal(skippedRepo?.modes?.eligible?.length, 0); +assert.equal(skippedRepo?.modes?.unavailable?.[0]?.mode, 'code'); + +console.log('federated response completeness contract test passed'); diff --git a/tests/retrieval/federation/search-determinism.test.js b/tests/retrieval/federation/search-determinism.test.js deleted file mode 100644 index 326d0f7de..000000000 --- a/tests/retrieval/federation/search-determinism.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-determinism-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "alpha", "priority": 5 }, - { "root": "./repo-b", "alias": "beta", "priority": 5 } - ] -}`, 'utf8'); - -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const searchFn = async (repoRootCanonical) => { - if (path.basename(repoRootCanonical) === 'repo-b') { - await wait(20); - } - return { - backend: 'memory', - code: [ - { - id: 'shared-id', - file: 'src/shared.js', - start: 1, - end: 1, - score: 1 - } - ], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const request = { - workspacePath, - query: 'deterministic', - search: { mode: 'code', top: 2 }, - limits: { perRepoTop: 2, concurrency: 2 } -}; - -const first = await runFederatedSearch(request, { searchFn }); -const second = await runFederatedSearch(request, { searchFn }); - -assert.equal( - stableStringify(first), - stableStringify(second), - 'federated response JSON must be deterministic across repeated runs' -); - -console.log('federated search determinism test passed'); diff --git a/tests/retrieval/federation/search-multi-repo-basic.test.js b/tests/retrieval/federation/search-multi-repo-basic.test.js index 96f9b640e..fc90403e1 100644 --- a/tests/retrieval/federation/search-multi-repo-basic.test.js +++ b/tests/retrieval/federation/search-multi-repo-basic.test.js @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { writeFederationRepoFixture } from './repo-fixture.js'; const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-basic-')); const cacheRoot = path.join(tempRoot, 'cache'); @@ -14,33 +14,9 @@ const repoB = path.join(tempRoot, 'repo-b'); const repoMissing = path.join(tempRoot, 'repo-missing'); const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await writeRepo(repoMissing); +await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot }); +await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot }); +await writeFederationRepoFixture({ repoRoot: repoMissing, cacheRoot }); await fs.writeFile(workspacePath, `{ "schemaVersion": 1, @@ -83,6 +59,12 @@ const response = await runFederatedSearch({ assert.equal(response.ok, true); assert.equal(response.backend, 'federated'); +assert.equal(response.status, 'partial'); +assert.equal(response.partialSuccess, true); +assert.equal(response.meta?.completeness?.status, 'partial'); +assert.equal(response.meta?.policy?.acceptPartialResults, true); +assert.equal(response.meta?.policy?.responseStatus, 'partial'); +assert.equal(typeof response.meta?.manifestGeneratedAt, 'string'); assert.equal(Array.isArray(response.code), true); assert.equal(response.code.length, 2, 'expected successful repos to contribute merged hits'); assert.deepEqual( @@ -106,5 +88,14 @@ assert.ok( diagnostics.some((entry) => entry.status === 'missing_index'), 'missing indexes should be non-fatal diagnostics' ); +const alphaRepo = diagnostics.find((entry) => entry.repoId && entry.status === 'ok'); +assert.equal(alphaRepo?.completeness, 'complete'); +assert.equal(alphaRepo?.freshness?.buildId, 'test-build'); +assert.ok(alphaRepo?.freshness?.generationKey, 'expected repo freshness to expose generation key'); +assert.equal(alphaRepo?.modes?.requested?.includes('code'), true); +assert.equal(alphaRepo?.modes?.fulfilled?.includes('code'), true); +const missingRepo = diagnostics.find((entry) => entry.status === 'missing_index'); +assert.equal(missingRepo?.completeness, 'partial'); +assert.equal(missingRepo?.modes?.executionFailures?.length, 1); console.log('federated search multi-repo basic test passed'); diff --git a/tests/retrieval/federation/select-aliases.test.js b/tests/retrieval/federation/select-aliases.test.js deleted file mode 100644 index f95a6187f..000000000 --- a/tests/retrieval/federation/select-aliases.test.js +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-select-aliases-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "alpha", "tags": ["service"] }, - { "root": "./repo-b", "alias": "beta", "tags": ["batch"] } - ] -}`, 'utf8'); - -const toLeaf = (repoRootCanonical) => path.basename(repoRootCanonical); -const emptyResult = { - backend: 'memory', - code: [], - prose: [], - extractedProse: [], - records: [] -}; - -const selectedByTagAlias = []; -await runFederatedSearch({ - workspacePath, - query: 'alias-tag-query', - search: { mode: 'code' }, - select: { tag: ['service'] } -}, { - searchFn: async (repoRootCanonical) => { - selectedByTagAlias.push(toLeaf(repoRootCanonical)); - return emptyResult; - } -}); -assert.deepEqual( - selectedByTagAlias, - ['repo-a'], - 'select.tag alias should be honored for workspace repo selection' -); - -const selectedByRepoFilterAlias = []; -await runFederatedSearch({ - workspacePath, - query: 'alias-repo-filter-query', - search: { mode: 'code' }, - select: { 'repo-filter': ['beta'] } -}, { - searchFn: async (repoRootCanonical) => { - selectedByRepoFilterAlias.push(toLeaf(repoRootCanonical)); - return emptyResult; - } -}); -assert.deepEqual( - selectedByRepoFilterAlias, - ['repo-b'], - 'select[\'repo-filter\'] alias should be honored for workspace repo selection' -); - -console.log('federation select aliases test passed'); diff --git a/tests/retrieval/federation/select-object-only-no-cache-fragmentation.test.js b/tests/retrieval/federation/select-object-only-no-cache-fragmentation.test.js deleted file mode 100644 index e2968c751..000000000 --- a/tests/retrieval/federation/select-object-only-no-cache-fragmentation.test.js +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-select-object-no-fragment-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); -const repoCacheRoot = getRepoCacheRoot(repoRoot); -const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] -}, null, 2), 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' -}, null, 2), 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo", "alias": "sample", "enabled": false } - ] -}`, 'utf8'); - -let searchCalls = 0; -const searchFn = async () => { - searchCalls += 1; - return { - backend: 'memory', - code: [{ id: 'hit-1', file: 'src/app.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const first = await runFederatedSearch({ - workspacePath, - query: 'select-object-only', - search: { mode: 'code', top: 5 }, - select: { includeDisabled: true } -}, { searchFn }); -assert.equal(first.ok, true); -assert.deepEqual( - first.meta?.selection?.explicitSelects || [], - [], - 'object-only select must not be coerced into explicit select tokens' -); -assert.equal(first.code.length, 1); - -const second = await runFederatedSearch({ - workspacePath, - query: 'select-object-only', - search: { mode: 'code', top: 5 }, - includeDisabled: true -}, { searchFn }); -assert.equal(second.ok, true); -assert.equal(second.code.length, 1); -assert.equal(searchCalls, 1, 'equivalent requests should reuse federated cache key'); - -console.log('federated object-only select cache-key stability test passed'); diff --git a/tests/retrieval/federation/selection-contract-matrix.test.js b/tests/retrieval/federation/selection-contract-matrix.test.js new file mode 100644 index 000000000..42e7857a5 --- /dev/null +++ b/tests/retrieval/federation/selection-contract-matrix.test.js @@ -0,0 +1,528 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { resolveIndexRef } from '../../../src/index/index-ref.js'; +import { parseFederatedCliRequest } from '../../../src/retrieval/federation/args.js'; +import { runFederatedSearch, mergeFederatedResultsByMode } from '../../../src/retrieval/federation/coordinator.js'; +import { selectWorkspaceRepos } from '../../../src/retrieval/federation/select.js'; +import { loadWorkspaceConfig } from '../../../src/workspace/config.js'; +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { writeFederationRepoFixture } from './repo-fixture.js'; + +applyTestEnv(); + +const withTempRoot = async (prefix, run) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await run({ + tempRoot, + cacheRoot: path.join(tempRoot, 'cache') + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; + +const cases = [ + { + name: 'explicit snapshot refs fail fast instead of falling back to latest', + async run() { + await withTempRoot('poc-fed-select-explicit-root-', async ({ tempRoot, cacheRoot }) => { + const repoRoot = path.join(tempRoot, 'repo'); + const userConfig = { cache: { root: cacheRoot } }; + await fs.mkdir(repoRoot, { recursive: true }); + + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const buildsRoot = path.join(repoCacheRoot, 'builds'); + const liveBuildRoot = path.join(buildsRoot, 'build-live'); + await fs.mkdir(liveBuildRoot, { recursive: true }); + + const writeJson = async (targetPath, value) => { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + }; + + await writeJson(path.join(liveBuildRoot, 'build_state.json'), { + schemaVersion: 1, + buildId: 'build-live', + configHash: 'cfg-live', + tool: { version: '1.0.0' } + }); + await writeJson(path.join(buildsRoot, 'current.json'), { + buildRoot: 'builds/build-live', + buildRootsByMode: { code: 'builds/build-live' } + }); + + const snapshotId = 'snap-20260212-explicit-nofallback'; + const snapshotsRoot = path.join(repoCacheRoot, 'snapshots'); + await writeJson(path.join(snapshotsRoot, 'manifest.json'), { + version: 1, + updatedAt: '2026-02-12T00:00:00.000Z', + snapshots: { + [snapshotId]: { + snapshotId, + createdAt: '2026-02-12T00:00:00.000Z', + hasFrozen: false + } + }, + tags: {} + }); + await writeJson(path.join(snapshotsRoot, snapshotId, 'snapshot.json'), { + version: 1, + snapshotId, + kind: 'pointer', + pointer: { + buildRootsByMode: { code: 'builds/missing-build-root' }, + buildIdByMode: { code: 'build-missing' } + } + }); + + assert.throws( + () => resolveIndexRef({ + ref: `snap:${snapshotId}`, + repoRoot, + userConfig, + requestedModes: ['code'] + }), + /missing build root/i + ); + + const latest = resolveIndexRef({ + ref: 'latest', + repoRoot, + userConfig, + requestedModes: ['code'] + }); + assert.equal(latest.indexBaseRootByMode.code, liveBuildRoot); + }); + } + }, + { + name: 'invalid cohort selectors keep the dedicated client-error code', + async run() { + await withTempRoot('poc-fed-select-invalid-cohort-', async ({ tempRoot, cacheRoot }) => { + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot, cacheRoot, modes: ['code'] }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo", "alias": "sample" } + ] +}`, 'utf8'); + + await assert.rejects( + runFederatedSearch({ + workspacePath, + query: 'bad-selector', + cohort: ['code:'], + search: { mode: 'code', top: 5 } + }), + (error) => { + assert.equal(error?.code, 'ERR_FEDERATED_INVALID_COHORT_SELECTOR'); + return true; + } + ); + + await assert.rejects( + runFederatedSearch({ + workspacePath, + query: 'multi-global', + cohort: ['c1', 'c2'], + search: { mode: 'code', top: 5 } + }), + (error) => { + assert.equal(error?.code, 'ERR_FEDERATED_INVALID_COHORT_SELECTOR'); + return true; + } + ); + }); + } + }, + { + name: 'mode cohort merge cutoff honors per-mode selected repos', + run() { + const perRepoResults = [ + { + repoId: 'repo-high', + repoAlias: 'high', + priority: 100, + result: { + code: [{ id: 'high-code', file: 'src/high.js', start: 1, end: 1, score: 1 }], + prose: [{ id: 'high-prose', file: 'docs/high.md', start: 1, end: 1, score: 1 }], + extractedProse: [], + records: [] + } + }, + { + repoId: 'repo-low', + repoAlias: 'low', + priority: 1, + result: { + code: [{ id: 'low-code', file: 'src/low.js', start: 1, end: 1, score: 1 }], + prose: [{ id: 'low-prose', file: 'docs/low.md', start: 1, end: 1, score: 1 }], + extractedProse: [], + records: [] + } + } + ]; + + const merged = mergeFederatedResultsByMode({ + perRepoResults, + selectedReposByMode: { + code: [{ repoId: 'repo-low' }], + prose: [{ repoId: 'repo-high' }], + 'extracted-prose': [], + records: [] + }, + topN: 1, + perRepoTop: 10, + rrfK: 60 + }); + + assert.deepEqual(merged.code.map((hit) => hit.repoId), ['repo-low']); + assert.deepEqual(merged.prose.map((hit) => hit.repoId), ['repo-high']); + assert.deepEqual(merged.extractedProse, []); + assert.deepEqual(merged.records, []); + } + }, + { + name: 'workspace repo selection remains deterministic across explicit, tag, and glob filters', + run() { + const workspaceConfig = { + repos: [ + { + repoId: 'repo-a', + alias: 'alpha', + repoRootCanonical: '/tmp/repo-a', + enabled: true, + priority: 2, + tags: ['service', 'api'] + }, + { + repoId: 'repo-b', + alias: 'beta', + repoRootCanonical: '/tmp/repo-b', + enabled: false, + priority: 100, + tags: ['batch'] + }, + { + repoId: 'repo-c', + alias: 'gamma', + repoRootCanonical: '/tmp/repo-c', + enabled: true, + priority: 1, + tags: ['service'] + } + ] + }; + + const explicitDisabled = selectWorkspaceRepos({ + workspaceConfig, + select: ['beta'], + includeDisabled: false + }); + assert.ok(explicitDisabled.selectedRepos.some((repo) => repo.repoId === 'repo-b')); + + const tagged = selectWorkspaceRepos({ + workspaceConfig, + tag: ['service'] + }); + assert.deepEqual(tagged.selectedRepos.map((repo) => repo.repoId), ['repo-a', 'repo-c']); + + const filtered = selectWorkspaceRepos({ + workspaceConfig, + includeDisabled: true, + repoFilter: ['repo-*'] + }); + assert.deepEqual(filtered.selectedRepos.map((repo) => repo.repoId), ['repo-b', 'repo-a', 'repo-c']); + } + }, + { + name: 'selection aliases and per-repo mode eligibility fan out correctly', + async run() { + await withTempRoot('poc-fed-select-aliases-eligibility-', async ({ tempRoot, cacheRoot }) => { + const repoAliasA = path.join(tempRoot, 'repo-a'); + const repoAliasB = path.join(tempRoot, 'repo-b'); + const aliasWorkspacePath = path.join(tempRoot, '.pairofcleats-workspace-aliases.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoAliasA, cacheRoot, modes: ['code'] }); + await writeFederationRepoFixture({ repoRoot: repoAliasB, cacheRoot, modes: ['code'] }); + + await fs.writeFile(aliasWorkspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "alpha", "tags": ["service"], "priority": 10 }, + { "root": "./repo-b", "alias": "beta", "tags": ["batch"], "priority": 5 } + ] +}`, 'utf8'); + + const selectedByTagAlias = []; + await runFederatedSearch({ + workspacePath: aliasWorkspacePath, + query: 'alias-tag-query', + search: { mode: 'code' }, + select: { tag: ['service'] } + }, { + searchFn: async (repoRootCanonical) => { + selectedByTagAlias.push(path.basename(repoRootCanonical)); + return { backend: 'memory', code: [], prose: [], extractedProse: [], records: [] }; + } + }); + assert.deepEqual(selectedByTagAlias, ['repo-a']); + + const selectedByRepoFilterAlias = []; + await runFederatedSearch({ + workspacePath: aliasWorkspacePath, + query: 'alias-repo-filter-query', + search: { mode: 'code' }, + select: { 'repo-filter': ['beta'] } + }, { + searchFn: async (repoRootCanonical) => { + selectedByRepoFilterAlias.push(path.basename(repoRootCanonical)); + return { backend: 'memory', code: [], prose: [], extractedProse: [], records: [] }; + } + }); + assert.deepEqual(selectedByRepoFilterAlias, ['repo-b']); + + const repoCode = path.join(tempRoot, 'repo-code'); + const repoProse = path.join(tempRoot, 'repo-prose'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace-modes.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoCode, cacheRoot, modes: ['code'] }); + await writeFederationRepoFixture({ repoRoot: repoProse, cacheRoot, modes: ['prose'] }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-code", "alias": "alpha", "tags": ["service"], "priority": 10 }, + { "root": "./repo-prose", "alias": "beta", "tags": ["batch"], "priority": 5 } + ] +}`, 'utf8'); + + const request = parseFederatedCliRequest([ + 'federated-per-repo-mode', + '--workspace', + workspacePath, + '--mode', + 'both', + '--top', + '5' + ]); + const searchCalls = []; + const getModeFromArgs = (args = []) => { + for (let i = 0; i < args.length; i += 1) { + const token = String(args[i] || ''); + if (token === '--mode') return String(args[i + 1] || '').trim().toLowerCase(); + if (token.startsWith('--mode=')) return token.slice('--mode='.length).trim().toLowerCase(); + } + return ''; + }; + + const response = await runFederatedSearch(request, { + searchFn: async (repoRootCanonical, params) => { + const leaf = path.basename(repoRootCanonical); + const mode = getModeFromArgs(params?.args); + searchCalls.push({ leaf, mode }); + if (leaf === 'repo-code') { + assert.equal(mode, 'code'); + return { + backend: 'memory', + code: [{ id: 'code-hit', file: 'src/code.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } + assert.equal(mode, 'prose'); + return { + backend: 'memory', + code: [], + prose: [{ id: 'prose-hit', file: 'docs/prose.md', start: 1, end: 1, score: 1 }], + extractedProse: [], + records: [] + }; + } + }); + + assert.equal(response.ok, true); + assert.equal(response.code.length, 1); + assert.equal(response.prose.length, 1); + const callsByRepo = new Map(searchCalls.map((entry) => [entry.leaf, entry.mode])); + assert.equal(callsByRepo.get('repo-code'), 'code'); + assert.equal(callsByRepo.get('repo-prose'), 'prose'); + }); + } + }, + { + name: 'validated workspace snapshots are reused while trust boundaries reject mismatched trusted configs', + async run() { + await withTempRoot('poc-fed-select-workspace-', async ({ tempRoot, cacheRoot }) => { + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePathPrimary = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + const workspacePathAlt = path.join(tempRoot, '.pairofcleats-workspace-alt.jsonc'); + + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot, modes: ['code'] }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot, modes: ['code'] }); + + await fs.writeFile(workspacePathPrimary, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "alpha" } + ] +}`, 'utf8'); + const validatedWorkspaceConfig = loadWorkspaceConfig(workspacePathPrimary); + + await fs.writeFile(workspacePathPrimary, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-b", "alias": "beta" } + ] +}`, 'utf8'); + + const searchedSnapshotRepos = []; + const snapshotResponse = await runFederatedSearch({ + workspacePath: workspacePathPrimary, + workspaceConfig: validatedWorkspaceConfig, + query: 'snapshot', + search: { mode: 'code', top: 5 } + }, { + trustedWorkspaceConfig: true, + searchFn: async (repoRootCanonical) => { + searchedSnapshotRepos.push(path.basename(repoRootCanonical)); + return { + backend: 'memory', + code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } + }); + + assert.deepEqual(searchedSnapshotRepos, ['repo-a']); + assert.equal(snapshotResponse.code[0]?.repoAlias, 'alpha'); + + await fs.writeFile(workspacePathPrimary, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-b", "alias": "beta" } + ] +}`, 'utf8'); + await fs.writeFile(workspacePathAlt, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "alpha" } + ] +}`, 'utf8'); + + const alternateConfig = loadWorkspaceConfig(workspacePathAlt); + const searchedUntrustedRepos = []; + await runFederatedSearch({ + workspacePath: workspacePathPrimary, + workspaceConfig: alternateConfig, + query: 'trust-boundary', + search: { mode: 'code', top: 5 } + }, { + searchFn: async (repoRootCanonical) => { + searchedUntrustedRepos.push(path.basename(repoRootCanonical)); + return { + backend: 'memory', + code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + } + }); + assert.deepEqual(searchedUntrustedRepos, ['repo-b']); + + await assert.rejects( + runFederatedSearch({ + workspacePath: workspacePathPrimary, + workspaceConfig: alternateConfig, + query: 'trust-boundary', + search: { mode: 'code', top: 5 } + }, { + trustedWorkspaceConfig: true, + searchFn: async () => ({ + backend: 'memory', + code: [], + prose: [], + extractedProse: [], + records: [] + }) + }), + (error) => { + assert.equal(error?.code, ERROR_CODES.INVALID_REQUEST); + assert.match(String(error?.message || ''), /workspacepath does not match/i); + return true; + } + ); + }); + } + }, + { + name: 'multi-repo responses stay deterministic across repeated runs', + async run() { + await withTempRoot('poc-fed-select-determinism-', async ({ tempRoot, cacheRoot }) => { + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + await writeFederationRepoFixture({ repoRoot: repoA, cacheRoot, modes: ['code'] }); + await writeFederationRepoFixture({ repoRoot: repoB, cacheRoot, modes: ['code'] }); + + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-a", "alias": "alpha", "priority": 5 }, + { "root": "./repo-b", "alias": "beta", "priority": 5 } + ] +}`, 'utf8'); + + const searchFn = async (repoRootCanonical) => { + if (path.basename(repoRootCanonical) === 'repo-b') { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return { + backend: 'memory', + code: [{ id: 'shared-id', file: 'src/shared.js', start: 1, end: 1, score: 1 }], + prose: [], + extractedProse: [], + records: [] + }; + }; + + const request = { + workspacePath, + query: 'deterministic', + search: { mode: 'code', top: 2 }, + limits: { perRepoTop: 2, concurrency: 2 } + }; + + const first = await runFederatedSearch(request, { searchFn }); + const second = await runFederatedSearch(request, { searchFn }); + assert.equal(stableStringify(first), stableStringify(second)); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('federation selection contract matrix test passed'); diff --git a/tests/retrieval/federation/strict-cache-key-separation.test.js b/tests/retrieval/federation/strict-cache-key-separation.test.js deleted file mode 100644 index 326945b75..000000000 --- a/tests/retrieval/federation/strict-cache-key-separation.test.js +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createError, ERROR_CODES } from '../../../src/shared/error-codes.js'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-strict-cache-key-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepo = async (repoRoot, modes = ['code']) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes - }, null, 2), 'utf8'); - for (const mode of modes) { - const indexDir = path.join(buildRoot, `index-${mode}`); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - } -}; - -await writeRepo(repoA); -await writeRepo(repoB); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "a", "priority": 10 }, - { "root": "./repo-b", "alias": "b", "priority": 1 } - ] -}`, 'utf8'); - -let searchCalls = 0; -const searchFn = async (repoRootCanonical) => { - searchCalls += 1; - if (path.basename(repoRootCanonical) === 'repo-a') { - throw createError(ERROR_CODES.NO_INDEX, 'missing index for strict cache-key test'); - } - return { - backend: 'memory', - code: [{ id: 'hit-b', file: 'src/b.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; -}; - -const baseRequest = { - workspacePath, - query: 'strict-cache-key-separation', - search: { mode: 'code', top: 5 }, - limits: { concurrency: 1 } -}; - -const nonStrict = await runFederatedSearch(baseRequest, { searchFn }); -assert.equal(nonStrict.ok, true); -assert.equal(nonStrict.code.length, 1, 'non-strict request should return partial success'); - -await assert.rejects( - runFederatedSearch({ ...baseRequest, strict: true }, { searchFn }), - (error) => error?.code === ERROR_CODES.NO_INDEX -); - -assert.ok( - searchCalls >= 3, - `strict request should not reuse non-strict cache entry (observed searchCalls=${searchCalls})` -); - -console.log('federation strict cache-key separation test passed'); diff --git a/tests/retrieval/federation/workspace-config-snapshot.test.js b/tests/retrieval/federation/workspace-config-snapshot.test.js deleted file mode 100644 index 967c0e8b2..000000000 --- a/tests/retrieval/federation/workspace-config-snapshot.test.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { loadWorkspaceConfig } from '../../../src/workspace/config.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-workspace-snapshot-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -const writeRepoWithCodeIndex = async (repoRoot) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - const indexDir = path.join(buildRoot, 'index-code'); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] - }, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' - }, null, 2), 'utf8'); -}; - -await writeRepoWithCodeIndex(repoA); -await writeRepoWithCodeIndex(repoB); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "alpha" } - ] -}`, 'utf8'); - -const validatedWorkspaceConfig = loadWorkspaceConfig(workspacePath); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-b", "alias": "beta" } - ] -}`, 'utf8'); - -const searchedRepos = []; -const response = await runFederatedSearch({ - workspacePath, - workspaceConfig: validatedWorkspaceConfig, - query: 'snapshot', - search: { mode: 'code', top: 5 } -}, { - trustedWorkspaceConfig: true, - searchFn: async (repoRootCanonical) => { - searchedRepos.push(path.basename(repoRootCanonical)); - return { - backend: 'memory', - code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; - } -}); - -assert.deepEqual( - searchedRepos, - ['repo-a'], - 'federated search should use validated workspace snapshot, not reloaded workspace file' -); -assert.equal(response.code[0]?.repoAlias, 'alpha'); - -console.log('federated workspace config snapshot test passed'); diff --git a/tests/retrieval/federation/workspace-config-trust-boundary.test.js b/tests/retrieval/federation/workspace-config-trust-boundary.test.js deleted file mode 100644 index 471dae34c..000000000 --- a/tests/retrieval/federation/workspace-config-trust-boundary.test.js +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runFederatedSearch } from '../../../src/retrieval/federation/coordinator.js'; -import { loadWorkspaceConfig } from '../../../src/workspace/config.js'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; -import { ERROR_CODES } from '../../../src/shared/error-codes.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-fed-workspace-trust-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspacePathPrimary = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); -const workspacePathAlt = path.join(tempRoot, '.pairofcleats-workspace-alt.jsonc'); - -const writeRepoWithCodeIndex = async (repoRoot) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); - const repoCacheRoot = getRepoCacheRoot(repoRoot); - const buildRoot = path.join(repoCacheRoot, 'builds', 'test-build'); - const indexDir = path.join(buildRoot, 'index-code'); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'test-build', - buildRoot, - modes: ['code'] - }, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-code' - }, null, 2), 'utf8'); -}; - -await writeRepoWithCodeIndex(repoA); -await writeRepoWithCodeIndex(repoB); - -await fs.writeFile(workspacePathPrimary, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-b", "alias": "beta" } - ] -}`, 'utf8'); - -await fs.writeFile(workspacePathAlt, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-a", "alias": "alpha" } - ] -}`, 'utf8'); - -const alternateConfig = loadWorkspaceConfig(workspacePathAlt); - -const searchedRepos = []; -await runFederatedSearch({ - workspacePath: workspacePathPrimary, - workspaceConfig: alternateConfig, - query: 'trust-boundary', - search: { mode: 'code', top: 5 } -}, { - // Without trustedWorkspaceConfig, request.workspaceConfig must be ignored. - searchFn: async (repoRootCanonical) => { - searchedRepos.push(path.basename(repoRootCanonical)); - return { - backend: 'memory', - code: [{ id: 'hit', file: 'src/file.js', start: 1, end: 1, score: 1 }], - prose: [], - extractedProse: [], - records: [] - }; - } -}); - -assert.deepEqual( - searchedRepos, - ['repo-b'], - 'untrusted request.workspaceConfig must not override workspacePath source' -); - -await assert.rejects( - runFederatedSearch({ - workspacePath: workspacePathPrimary, - workspaceConfig: alternateConfig, - query: 'trust-boundary', - search: { mode: 'code', top: 5 } - }, { - trustedWorkspaceConfig: true, - searchFn: async () => ({ - backend: 'memory', - code: [], - prose: [], - extractedProse: [], - records: [] - }) - }), - (error) => { - assert.equal(error?.code, ERROR_CODES.INVALID_REQUEST); - assert.match(String(error?.message || ''), /workspacepath does not match/i); - return true; - } -); - -console.log('federated workspace config trust boundary test passed'); diff --git a/tests/retrieval/filter-index/effective-lang-fallback.test.js b/tests/retrieval/filter-index/effective-lang-fallback.test.js deleted file mode 100644 index e8d0b80c8..000000000 --- a/tests/retrieval/filter-index/effective-lang-fallback.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; - -const chunks = [ - { - id: 0, - file: 'src/no-lang.js', - ext: '.js', - metaV2: {} - }, - { - id: 1, - file: 'src/invalid-lang.ts', - ext: '.ts', - metaV2: { - lang: { id: 'typescript' }, - effective: { languageId: '' } - }, - lang: ' ' - }, - { - id: 2, - file: 'src/valid.py', - ext: '.py', - metaV2: { - effective: { languageId: 'Python' } - } - } -]; - -const index = buildFilterIndex(chunks, { includeBitmaps: false }); -const unknown = index.byLang.get('unknown'); -assert.ok(unknown && unknown.has(0), 'missing language should fall back to unknown'); -assert.ok(unknown && unknown.has(1), 'invalid language should fall back to unknown'); - -const python = index.byLang.get('python'); -assert.ok(python && python.has(2), 'valid effective language should be normalized and indexed'); - -console.log('effective lang fallback test passed'); diff --git a/tests/retrieval/filters/active-filters.test.js b/tests/retrieval/filters/active-filters.test.js deleted file mode 100644 index 5dc373542..000000000 --- a/tests/retrieval/filters/active-filters.test.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { hasActiveFilters } from '../../../src/retrieval/filters.js'; - -assert.equal(hasActiveFilters(null), false); -assert.equal(hasActiveFilters(undefined), false); -assert.equal(hasActiveFilters({}), false); -assert.equal(hasActiveFilters({ filePrefilter: { enabled: true } }), false); -assert.equal(hasActiveFilters({ caseFile: true, caseTokens: true }), false); -assert.equal(hasActiveFilters({ excludeTokens: ['alpha'], excludePhrases: ['beta'] }), false); - -assert.equal(hasActiveFilters({ ext: ['.js'] }), true); -assert.equal(hasActiveFilters({ type: 'function' }), true); -assert.equal(hasActiveFilters({ meta: [{ key: 'owner', value: 'me' }] }), true); -assert.equal(hasActiveFilters({ churnMin: 0 }), true); - -console.log('hasActiveFilters guard tests passed.'); diff --git a/tests/retrieval/filters/behavioral.test.js b/tests/retrieval/filters/behavioral.test.js deleted file mode 100644 index 953ecd1a8..000000000 --- a/tests/retrieval/filters/behavioral.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const returns = await runSearch({ - query: 'update', - mode: 'code', - args: ['--returns'] -}); -if (!(returns.code || []).length) { - console.error('Search returns filter returned no results.'); - process.exit(1); -} - -const asyncPayload = await runSearch({ - query: 'load', - mode: 'code', - args: ['--async'] -}); -if (!(asyncPayload.code || []).length) { - console.error('Search async filter returned no results.'); - process.exit(1); -} - -console.log('Retrieval behavioral filters ok.'); diff --git a/tests/retrieval/filters/churn-filter.test.js b/tests/retrieval/filters/churn-filter.test.js deleted file mode 100644 index 01670cbd9..000000000 --- a/tests/retrieval/filters/churn-filter.test.js +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getGitMeta } from '../../../src/index/git.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'churn-filter'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -const gitCheck = spawnSync('git', ['--version'], { encoding: 'utf8' }); -if (gitCheck.status !== 0) { - console.log('[skip] git not available'); - process.exit(0); -} - -await rmDirRecursive(tempRoot, { retries: 8, delayMs: 150 }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const runGit = (args, label) => { - const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } -}; - -runGit(['init'], 'git init'); -runGit(['config', 'user.email', 'test@example.com'], 'git config email'); -runGit(['config', 'user.name', 'Test User'], 'git config name'); - -const sourcePath = path.join(repoRoot, 'notes.md'); -await fsPromises.writeFile( - sourcePath, - [ - 'alpha', - 'beta' - ].join('\n') -); - -runGit(['add', '.'], 'git add initial'); -runGit(['commit', '-m', 'initial'], 'git commit initial'); - -await fsPromises.writeFile( - sourcePath, - [ - 'alpha', - 'gamma', - 'delta' - ].join('\n') -); - -runGit(['add', '.'], 'git add update'); -runGit(['commit', '-m', 'update'], 'git commit update'); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -const repoArgs = ['--repo', repoRoot]; - -const gitMeta = await getGitMeta('notes.md', 1, 2, { blame: false, baseDir: repoRoot }); -const expectedChurn = 5; -if (gitMeta.churn !== expectedChurn) { - console.error(`Expected churn ${expectedChurn}, got ${gitMeta.churn}`); - process.exit(1); -} - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', ...repoArgs], - { cwd: repoRoot, env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - console.error('Failed: build_index'); - process.exit(buildResult.status ?? 1); -} - -const searchPath = path.join(root, 'search.js'); - -function runSearch(args, label) { - const result = spawnSync( - process.execPath, - [searchPath, 'alpha', '--mode', 'prose', '--json', '--no-ann', ...args, ...repoArgs], - { cwd: repoRoot, env, encoding: 'utf8' } - ); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - console.error(result.stderr || result.stdout || ''); - process.exit(result.status ?? 1); - } - return JSON.parse(result.stdout || '{}'); -} - -const defaultPayload = runSearch([], 'search churn default'); -if (!Array.isArray(defaultPayload.prose) || defaultPayload.prose.length === 0) { - console.error('Expected results for --churn default.'); - process.exit(1); -} - -const zeroPayload = runSearch(['--churn', '0'], 'search churn 0'); -if (!Array.isArray(zeroPayload.prose) || zeroPayload.prose.length === 0) { - console.error('Expected results for --churn 0.'); - process.exit(1); -} - -const highPayload = runSearch(['--churn', '999999'], 'search churn 999999'); -if (Array.isArray(highPayload.prose) && highPayload.prose.length > 0) { - console.error('Expected no results for --churn 999999.'); - process.exit(1); -} - -const badResult = spawnSync( - process.execPath, - [searchPath, 'alpha', '--mode', 'prose', '--json', '--churn', 'not-a-number', ...repoArgs], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (badResult.status === 0) { - console.error('Expected --churn not-a-number to fail.'); - process.exit(1); -} - -console.log('Churn filter test passed'); - diff --git a/tests/retrieval/filters/control-flow.test.js b/tests/retrieval/filters/control-flow.test.js deleted file mode 100644 index 3492ee85a..000000000 --- a/tests/retrieval/filters/control-flow.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const payload = await runSearch({ - query: 'load', - mode: 'code', - args: ['--branches', '1'] -}); - -const hits = payload.code || []; -if (!hits.length) { - console.error('Search branches filter returned no results.'); - process.exit(1); -} -const hasBranches = hits.some((hit) => (hit.docmeta?.controlFlow?.branches || 0) >= 1); -if (!hasBranches) { - console.error('Search branches filter missing controlFlow.branches metadata.'); - process.exit(1); -} - -console.log('Retrieval control-flow filter ok.'); diff --git a/tests/retrieval/filters/ext-filter.test.js b/tests/retrieval/filters/ext-filter.test.js deleted file mode 100644 index 97aeb6c62..000000000 --- a/tests/retrieval/filters/ext-filter.test.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -import { normalizeExtFilter } from '../../../src/retrieval/filters.js'; - -const result = normalizeExtFilter(['*.js', 'JS', '.Md']); -const expected = ['.js', '.md']; - -const sorted = (result || []).slice().sort(); -const expectedSorted = expected.slice().sort(); - -const sameLength = sorted.length === expectedSorted.length; -const sameValues = sorted.every((value, idx) => value === expectedSorted[idx]); -if (!sameLength || !sameValues) { - console.error(`normalizeExtFilter failed: expected ${expectedSorted.join(', ')}, got ${sorted.join(', ')}`); - process.exit(1); -} - -console.log('ext filter test passed'); diff --git a/tests/retrieval/filters/ext-path.test.js b/tests/retrieval/filters/ext-path.test.js deleted file mode 100644 index a7e716e85..000000000 --- a/tests/retrieval/filters/ext-path.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName: 'fixture-sample', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const extScoped = await runSearch({ - query: 'message', - mode: 'code', - args: ['--backend', 'memory', '--ext', '.py'] -}); -const extHits = extScoped.code || []; -if (!extHits.length || extHits.some((hit) => hit.ext !== '.py')) { - console.error('Fixture ext filter returned unexpected results.'); - process.exit(1); -} - -const pathScoped = await runSearch({ - query: 'message', - mode: 'code', - args: ['--backend', 'memory', '--path', 'src/sample.py'] -}); -const pathHits = pathScoped.code || []; -if (!pathHits.length || pathHits.some((hit) => hit.file !== 'src/sample.py')) { - console.error('Fixture path filter returned unexpected results.'); - process.exit(1); -} - -console.log('Fixture ext/path filters ok.'); diff --git a/tests/retrieval/filters/file-and-token/file-selector-case.test.js b/tests/retrieval/filters/file-and-token/file-selector-case.test.js deleted file mode 100644 index 503c50fde..000000000 --- a/tests/retrieval/filters/file-and-token/file-selector-case.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; -const extractFiles = (payload, key = 'prose') => - new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); - -const caseInsensitive = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--file', 'casefile.txt'] -}); -if (!extractFiles(caseInsensitive).has('CaseFile.TXT')) { - console.error('case-insensitive file filter failed.'); - process.exit(1); -} - -const caseSensitive = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--file', 'casefile.txt', '--case-file'] -}); -if (extractFiles(caseSensitive).has('CaseFile.TXT')) { - console.error('case-sensitive file filter should not match.'); - process.exit(1); -} - -const regexFile = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--file', '/casefile\\.txt/'] -}); -if (!extractFiles(regexFile).has('CaseFile.TXT')) { - console.error('regex file filter failed.'); - process.exit(1); -} - -console.log('File selector case sensitivity ok.'); diff --git a/tests/retrieval/filters/file-and-token/punctuation-tokenization.test.js b/tests/retrieval/filters/file-and-token/punctuation-tokenization.test.js deleted file mode 100644 index 82a4a089d..000000000 --- a/tests/retrieval/filters/file-and-token/punctuation-tokenization.test.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; -const extractFiles = (payload, key) => - new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); - -const punctuationSearch = runFilterSearch({ - repoRoot, - env, - query: '&&', - mode: 'code' -}); -if (!extractFiles(punctuationSearch, 'code').has('sample.js')) { - console.error('punctuation token match failed.'); - process.exit(1); -} - -console.log('Punctuation tokenization ok.'); diff --git a/tests/retrieval/filters/file-and-token/selector-contract-matrix.test.js b/tests/retrieval/filters/file-and-token/selector-contract-matrix.test.js new file mode 100644 index 000000000..6fd224e64 --- /dev/null +++ b/tests/retrieval/filters/file-and-token/selector-contract-matrix.test.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; + +const context = await ensureSearchFiltersRepo(); +if (!context) process.exit(0); + +const { repoRoot, env } = context; +const extractFiles = (payload, key = 'prose') => + new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); + +const cases = [ + { + name: 'punctuation tokens remain searchable in code mode', + run() { + const payload = runFilterSearch({ + repoRoot, + env, + query: '&&', + mode: 'code' + }); + assert.equal(extractFiles(payload, 'code').has('sample.js'), true); + } + }, + { + name: 'token case sensitivity toggles prose matches', + run() { + const insensitive = runFilterSearch({ + repoRoot, + env, + query: 'AlphaCase' + }); + assert.equal(extractFiles(insensitive).has('CaseFile.TXT'), true); + + const sensitive = runFilterSearch({ + repoRoot, + env, + query: 'AlphaCase', + args: ['--case-tokens'] + }); + assert.equal(extractFiles(sensitive).has('CaseFile.TXT'), false); + } + }, + { + name: 'file selectors support case-insensitive, strict, and regex matching', + run() { + const insensitive = runFilterSearch({ + repoRoot, + env, + query: 'alpha', + args: ['--file', 'casefile.txt'] + }); + assert.equal(extractFiles(insensitive).has('CaseFile.TXT'), true); + + const sensitive = runFilterSearch({ + repoRoot, + env, + query: 'alpha', + args: ['--file', 'casefile.txt', '--case-file'] + }); + assert.equal(extractFiles(sensitive).has('CaseFile.TXT'), false); + + const regex = runFilterSearch({ + repoRoot, + env, + query: 'alpha', + args: ['--file', '/casefile\\.txt/'] + }); + assert.equal(extractFiles(regex).has('CaseFile.TXT'), true); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('file and token selector contract matrix test passed'); diff --git a/tests/retrieval/filters/file-and-token/token-case.test.js b/tests/retrieval/filters/file-and-token/token-case.test.js deleted file mode 100644 index e382b8f2b..000000000 --- a/tests/retrieval/filters/file-and-token/token-case.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; -const extractFiles = (payload, key = 'prose') => - new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); - -const caseInsensitiveToken = runFilterSearch({ - repoRoot, - env, - query: 'AlphaCase' -}); -if (!extractFiles(caseInsensitiveToken).has('CaseFile.TXT')) { - console.error('case-insensitive token match failed.'); - process.exit(1); -} - -const caseSensitiveToken = runFilterSearch({ - repoRoot, - env, - query: 'AlphaCase', - args: ['--case-tokens'] -}); -if (extractFiles(caseSensitiveToken).has('CaseFile.TXT')) { - console.error('case-sensitive token match should not match.'); - process.exit(1); -} - -console.log('Token case sensitivity ok.'); diff --git a/tests/retrieval/filters/file-case-sensitive.test.js b/tests/retrieval/filters/file-case-sensitive.test.js deleted file mode 100644 index 7464b6193..000000000 --- a/tests/retrieval/filters/file-case-sensitive.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunks } from '../../../src/retrieval/output.js'; - -const chunkMeta = [ - { id: 0, file: 'src/Foo.js', ext: '.js', metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } }, - { id: 1, file: 'src/foo.js', ext: '.js', metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } } -]; - -const filterIndex = buildFilterIndex(chunkMeta, { fileChargramN: 3 }); - -const strictFilters = { - file: 'Foo.js', - caseFile: true, - filePrefilter: { enabled: true, chargramN: 3 } -}; -const strictHits = filterChunks(chunkMeta, strictFilters, filterIndex); -assert.equal(strictHits.length, 1); -assert.equal(strictHits[0].file, 'src/Foo.js'); - -const looseFilters = { - file: 'Foo.js', - caseFile: false, - filePrefilter: { enabled: true, chargramN: 3 } -}; -const looseHits = filterChunks(chunkMeta, looseFilters, filterIndex); -assert.equal(looseHits.length, 2); - -console.log('file filter case sensitivity test passed'); diff --git a/tests/retrieval/filters/file-selector.test.js b/tests/retrieval/filters/file-selector.test.js deleted file mode 100644 index 781005baf..000000000 --- a/tests/retrieval/filters/file-selector.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const payload = await runSearch({ - query: 'buildAliases', - mode: 'code', - args: ['--file', '/javascript_advanced\\.js$/'] -}); - -const hits = payload.code || []; -if (!hits.length) { - console.error('Search file selector returned no results.'); - process.exit(1); -} -const hasMatch = hits.some((hit) => hit.file && hit.file.endsWith('javascript_advanced.js')); -if (!hasMatch) { - console.error('Search file selector did not match javascript_advanced.js.'); - process.exit(1); -} - -console.log('Retrieval file selector filter ok.'); diff --git a/tests/retrieval/filters/filter-core-contract-matrix.test.js b/tests/retrieval/filters/filter-core-contract-matrix.test.js new file mode 100644 index 000000000..dd7c58689 --- /dev/null +++ b/tests/retrieval/filters/filter-core-contract-matrix.test.js @@ -0,0 +1,367 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildSerializedFilterIndex } from '../../../src/index/build/artifacts/filter-index.js'; +import { discoverSegments, chunkSegments, assignSegmentUids } from '../../../src/index/segments.js'; +import { buildMetaV2 } from '../../../src/index/metadata-v2.js'; +import { getLanguageForFile } from '../../../src/index/language-registry.js'; +import { buildFilterIndex, serializeFilterIndex } from '../../../src/retrieval/filter-index.js'; +import { hasActiveFilters } from '../../../src/retrieval/filters.js'; +import { filterChunks } from '../../../src/retrieval/output.js'; +import { applyBranchFilter } from '../../../src/retrieval/cli/branch-filter.js'; +import { buildLineIndex } from '../../../src/shared/lines.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; + +const cases = [ + { + name: 'active filter detection ignores cosmetic toggles and recognizes real narrowing', + run() { + assert.equal(hasActiveFilters(null), false); + assert.equal(hasActiveFilters(undefined), false); + assert.equal(hasActiveFilters({}), false); + assert.equal(hasActiveFilters({ filePrefilter: { enabled: true } }), false); + assert.equal(hasActiveFilters({ caseFile: true, caseTokens: true }), false); + assert.equal(hasActiveFilters({ excludeTokens: ['alpha'], excludePhrases: ['beta'] }), false); + + assert.equal(hasActiveFilters({ ext: ['.js'] }), true); + assert.equal(hasActiveFilters({ type: 'function' }), true); + assert.equal(hasActiveFilters({ meta: [{ key: 'owner', value: 'me' }] }), true); + assert.equal(hasActiveFilters({ churnMin: 0 }), true); + } + }, + { + name: 'serialized filter index config hash ignores api token churn', + run() { + const chunk = { + id: 0, + file: 'src/example.js', + lang: 'javascript' + }; + const resolvedConfig = { + chargramMinN: 3 + }; + const previousToken = process.env.PAIROFCLEATS_API_TOKEN; + const runWithToken = (token) => { + if (token === null) { + delete process.env.PAIROFCLEATS_API_TOKEN; + } else { + process.env.PAIROFCLEATS_API_TOKEN = token; + } + return buildSerializedFilterIndex({ + chunks: [chunk], + resolvedConfig, + userConfig: {}, + root: process.cwd() + }).configHash; + }; + + try { + const hashA = runWithToken('token-a'); + const hashB = runWithToken('token-b'); + assert.ok(hashA); + assert.equal(hashA, hashB); + } finally { + if (previousToken === undefined) { + delete process.env.PAIROFCLEATS_API_TOKEN; + } else { + process.env.PAIROFCLEATS_API_TOKEN = previousToken; + } + } + } + }, + { + name: 'effective language fallback normalizes unknown and valid language ids', + run() { + const chunks = [ + { + id: 0, + file: 'src/no-lang.js', + ext: '.js', + metaV2: {} + }, + { + id: 1, + file: 'src/invalid-lang.ts', + ext: '.ts', + metaV2: { + lang: { id: 'typescript' }, + effective: { languageId: '' } + }, + lang: ' ' + }, + { + id: 2, + file: 'src/valid.py', + ext: '.py', + metaV2: { + effective: { languageId: 'Python' } + } + } + ]; + + const index = buildFilterIndex(chunks, { includeBitmaps: false }); + const unknown = index.byLang.get('unknown'); + assert.ok(unknown && unknown.has(0), 'missing language should fall back to unknown'); + assert.ok(unknown && unknown.has(1), 'invalid language should fall back to unknown'); + + const python = index.byLang.get('python'); + assert.ok(python && python.has(2), 'valid effective language should be normalized and indexed'); + } + }, + { + name: 'filter index buckets and serialization stay deterministic', + run() { + const meta = [ + { + id: 0, + file: 'docs/guide.md', + ext: '.md', + kind: 'Paragraph', + last_author: 'Dana', + docmeta: { visibility: 'public' }, + metaV2: { lang: 'typescript', effective: { languageId: 'typescript' } } + }, + { + id: 1, + file: 'src/a.js', + ext: '.js', + kind: 'FunctionDeclaration', + last_author: 'Alice', + chunk_authors: ['Alice'], + docmeta: { visibility: 'public' }, + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 2, + file: 'src/b.py', + ext: '.py', + kind: 'ClassDeclaration', + last_author: 'Bob', + chunk_authors: ['Bob', 'Alice'], + docmeta: { visibility: 'private' }, + metaV2: { lang: 'python', effective: { languageId: 'python' } } + }, + { + id: 3, + file: 'src/c.py', + ext: '.py', + kind: 'FunctionDeclaration', + last_author: 'Carol', + chunk_authors: ['Carol'], + docmeta: { visibility: 'public' }, + metaV2: { lang: 'python', effective: { languageId: 'python' } } + } + ]; + + const index = buildFilterIndex(meta); + assert.ok(index.byLang?.get('typescript')?.has(0)); + assert.ok(index.byLang?.get('javascript')?.has(1)); + + const serializedA = serializeFilterIndex(buildFilterIndex(meta)); + const serializedB = serializeFilterIndex(buildFilterIndex(meta)); + assert.equal(stableStringify(serializedA), stableStringify(serializedB)); + + const expectIds = (filters, expected, label) => { + const actual = filterChunks(meta, filters, index) + .map((entry) => entry.id) + .sort((a, b) => a - b); + assert.deepEqual(actual, expected.slice().sort((a, b) => a - b), label); + }; + + expectIds({ ext: '.py', author: 'bob' }, [2], 'author+ext'); + expectIds({ chunkAuthor: 'alice' }, [1, 2], 'chunkAuthor'); + expectIds({ chunkAuthor: 'dana' }, [0], 'chunkAuthor fallback to last_author'); + expectIds({ visibility: 'public', type: 'FunctionDeclaration' }, [1, 3], 'visibility+type'); + } + }, + { + name: 'strict filter semantics cover signatures, relations, file matching, and unknown language fallback', + run() { + const meta = [ + { + id: 0, + kind: 'FunctionDeclaration', + last_author: 'Alice', + docmeta: { signature: 'foo(bar)', params: ['bar'] }, + codeRelations: { calls: [['foo', 'fetch']], usages: ['config'] }, + file: 'src/a.js', + ext: '.js', + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 1, + kind: 'FunctionDeclaration', + docmeta: {}, + codeRelations: {}, + file: 'src/b.js', + ext: '.js', + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 2, + kind: 'ClassDeclaration', + last_author: 'Bob', + docmeta: { signature: 'baz()', params: ['baz'] }, + codeRelations: { calls: [['baz', 'other']], usages: ['other'] }, + file: 'src/c.js', + ext: '.js', + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 3, + docmeta: {}, + codeRelations: {}, + file: 'docs/readme.md', + ext: '.md', + metaV2: { lang: 'unknown', effective: { languageId: 'unknown' } } + }, + { + id: 4, + kind: ['FunctionDeclaration', 'MethodDefinition'], + last_author: ['Carol', 'Dana'], + docmeta: { signature: 'qux()', params: ['qux'] }, + codeRelations: {}, + file: 'src/nested/util.ts', + ext: '.ts', + metaV2: { lang: 'typescript', effective: { languageId: 'typescript' } } + }, + { + id: 5, + docmeta: {}, + codeRelations: {}, + file: 'docs/changelog.txt', + ext: '.txt' + }, + { + id: 6, + docmeta: {}, + codeRelations: {}, + file: 'docs/notes.md', + ext: '.md', + lang: ' ' + } + ]; + const filterIndex = buildFilterIndex(meta, { fileChargramN: 3 }); + + const expectIds = (filters, expected, label) => { + const actual = filterChunks(meta, filters, filterIndex).map((entry) => entry.id).sort((a, b) => a - b); + assert.deepEqual(actual, expected.slice().sort((a, b) => a - b), label); + }; + + expectIds({ signature: 'foo' }, [0], 'signature'); + expectIds({ param: 'bar' }, [0], 'param'); + expectIds({ calls: 'fetch' }, [0], 'calls'); + expectIds({ uses: 'config' }, [0], 'uses'); + expectIds({ type: 'FunctionDeclaration' }, [0, 1, 4], 'type strict'); + expectIds({ type: 'FunctionDeclaration ClassDeclaration' }, [0, 1, 2, 4], 'type multi'); + expectIds({ author: 'Alice' }, [0], 'author strict'); + expectIds({ author: 'car' }, [4], 'author substring'); + expectIds({ file: 'src/b.js', filePrefilter: { enabled: true, chargramN: 3 } }, [1], 'file substring'); + expectIds({ file: '/util\\.ts$/i', filePrefilter: { enabled: true, chargramN: 3 } }, [4], 'file regex'); + expectIds({ lang: 'unknown' }, [3, 5, 6], 'unknown language fallback'); + } + }, + { + name: 'lang filter matches embedded code segments rather than only container language', + async run() { + const text = [ + '# Guide', + '', + '```tsx', + 'export function greet(name: string) {', + ' return name;', + '}', + '```', + '' + ].join('\n'); + const relPath = 'docs/guide.md'; + const ext = '.md'; + const segments = discoverSegments({ + text, + ext, + relPath, + mode: 'prose', + segmentsConfig: { inlineCodeSpans: false } + }); + await assignSegmentUids({ text, segments, ext, mode: 'prose' }); + const chunks = chunkSegments({ + text, + ext, + relPath, + mode: 'prose', + segments, + lineIndex: buildLineIndex(text), + context: {} + }); + + const containerLang = getLanguageForFile(ext, relPath); + const chunkMeta = chunks.map((chunk, id) => { + const effectiveExt = chunk.segment?.ext || ext; + const effectiveLang = getLanguageForFile(effectiveExt, relPath); + const containerLanguageId = containerLang?.id || null; + const lang = effectiveLang?.id || chunk.segment?.languageId || containerLanguageId || 'unknown'; + return { + id, + file: relPath, + ext, + kind: chunk.kind || null, + name: chunk.name || null, + metaV2: buildMetaV2({ + chunk: { + ...chunk, + file: relPath, + ext, + lang, + containerLanguageId, + effectiveExt + }, + docmeta: {}, + toolInfo: { tool: 'pairofcleats', version: '0.0.0-test' }, + analysisPolicy: { metadata: { enabled: true } } + }) + }; + }); + + const hits = filterChunks(chunkMeta, { lang: 'typescript' }, buildFilterIndex(chunkMeta)); + assert.ok(hits.length > 0); + assert.equal(hits.every((hit) => hit.metaV2?.lang === 'typescript'), true); + } + }, + { + name: 'branch mismatch produces an empty typed payload while recording metrics', + async run() { + let recorded = null; + const backendPolicy = { reason: 'auto', backendLabel: 'sqlite' }; + const result = await applyBranchFilter({ + branchFilter: 'main', + caseSensitive: false, + repoBranch: 'dev', + backendLabel: 'sqlite', + backendPolicy, + emitOutput: false, + jsonOutput: true, + recordSearchMetrics: (status) => { + recorded = status; + } + }); + + assert.equal(result.matched, false); + assert.equal(recorded, 'ok'); + assert.ok(result.payload); + assert.equal(result.payload.backend, 'sqlite'); + assert.deepEqual(result.payload.prose, []); + assert.deepEqual(result.payload.code, []); + assert.deepEqual(result.payload.records, []); + assert.equal(result.payload.stats.branch, 'dev'); + assert.equal(result.payload.stats.branchFilter, 'main'); + assert.equal(result.payload.stats.branchMatch, false); + assert.deepEqual(result.payload.stats.backendPolicy, backendPolicy); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('filter core contract matrix test passed'); diff --git a/tests/retrieval/filters/filter-index-artifact.test.js b/tests/retrieval/filters/filter-index-artifact.test.js index a0b86e4e3..e3a94364b 100644 --- a/tests/retrieval/filters/filter-index-artifact.test.js +++ b/tests/retrieval/filters/filter-index-artifact.test.js @@ -2,13 +2,13 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { getEnvConfig } from '../../../src/shared/env.js'; +import { getEnvConfig } from '../../../src/shared/env/runtime.js'; import { buildContentConfigHash } from '../../../src/index/build/runtime/hash.js'; import { MAX_JSON_BYTES, readJsonFile, loadJsonObjectArtifact } from '../../../src/shared/artifact-io.js'; import { loadIndex } from '../../../src/retrieval/cli-index.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { rmDirRecursive } from '../../helpers/temp.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -27,17 +27,25 @@ const env = applyTestEnv({ embeddings: 'stub', testConfig: { indexing: { - scm: { provider: 'none' } + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } } } }); -const buildResult = spawnSync(process.execPath, [ +const buildResult = runNode([ path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot -], { encoding: 'utf8', env }); +], 'filter index artifact build index', repoRoot, env, { stdio: 'pipe', encoding: 'utf8', allowFailure: true }); if (buildResult.status !== 0) { console.error(buildResult.stderr || buildResult.stdout || 'build_index failed'); process.exit(buildResult.status ?? 1); diff --git a/tests/retrieval/filters/filter-index-bylang.test.js b/tests/retrieval/filters/filter-index-bylang.test.js deleted file mode 100644 index db65ee5ad..000000000 --- a/tests/retrieval/filters/filter-index-bylang.test.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; - -const meta = [ - { - id: 0, - file: 'docs/guide.md', - ext: '.md', - metaV2: { lang: 'typescript', effective: { languageId: 'typescript' } } - }, - { - id: 1, - file: 'src/app.js', - ext: '.js', - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - } -]; - -const index = buildFilterIndex(meta); -assert.ok(index.byLang, 'expected byLang to be present'); -assert.ok(index.byLang.get('typescript')?.has(0), 'expected typescript bucket to include chunk 0'); -assert.ok(index.byLang.get('javascript')?.has(1), 'expected javascript bucket to include chunk 1'); - -console.log('filter index byLang test passed'); diff --git a/tests/retrieval/filters/filter-index-config-hash.test.js b/tests/retrieval/filters/filter-index-config-hash.test.js deleted file mode 100644 index 40c11d254..000000000 --- a/tests/retrieval/filters/filter-index-config-hash.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildSerializedFilterIndex } from '../../../src/index/build/artifacts/filter-index.js'; - -const chunk = { - id: 0, - file: 'src/example.js', - lang: 'javascript' -}; - -const resolvedConfig = { - chargramMinN: 3 -}; - -const runWithToken = (token) => { - if (token === null) { - delete process.env.PAIROFCLEATS_API_TOKEN; - } else { - process.env.PAIROFCLEATS_API_TOKEN = token; - } - const result = buildSerializedFilterIndex({ - chunks: [chunk], - resolvedConfig, - userConfig: {}, - root: process.cwd() - }); - return result.configHash; -}; - -const prevToken = process.env.PAIROFCLEATS_API_TOKEN; - -try { - const hashA = runWithToken('token-a'); - const hashB = runWithToken('token-b'); - assert.ok(hashA, 'expected configHash to be populated'); - assert.equal(hashA, hashB, 'configHash should ignore apiToken changes'); -} finally { - if (prevToken === undefined) { - delete process.env.PAIROFCLEATS_API_TOKEN; - } else { - process.env.PAIROFCLEATS_API_TOKEN = prevToken; - } -} - -console.log('filter index configHash token test passed'); diff --git a/tests/retrieval/filters/filter-index.test.js b/tests/retrieval/filters/filter-index.test.js deleted file mode 100644 index 2a4a67011..000000000 --- a/tests/retrieval/filters/filter-index.test.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -import { buildFilterIndex, serializeFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunks } from '../../../src/retrieval/output.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; - -const meta = [ - { - id: 0, - file: 'src/a.js', - ext: '.js', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 1, - file: 'src/b.py', - ext: '.py', - kind: 'ClassDeclaration', - last_author: 'Bob', - chunk_authors: ['Bob', 'Alice'], - docmeta: { visibility: 'private' }, - metaV2: { lang: 'python', effective: { languageId: 'python' } } - }, - { - id: 2, - file: 'src/c.py', - ext: '.py', - kind: 'FunctionDeclaration', - last_author: 'Carol', - chunk_authors: ['Carol'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'python', effective: { languageId: 'python' } } - }, - { - id: 3, - file: 'docs/d.md', - ext: '.md', - kind: 'Paragraph', - last_author: 'Dana', - docmeta: { visibility: 'public' }, - metaV2: { lang: 'unknown', effective: { languageId: 'unknown' } } - } -]; - -const index = buildFilterIndex(meta); -const serializedA = serializeFilterIndex(buildFilterIndex(meta)); -const serializedB = serializeFilterIndex(buildFilterIndex(meta)); -if (stableStringify(serializedA) !== stableStringify(serializedB)) { - console.error('Filter index serialization should be deterministic for identical inputs.'); - process.exit(1); -} - -const expectIds = (filters, expected, label) => { - const results = filterChunks(meta, filters, index).map((entry) => entry.id).sort(); - const expectedSorted = expected.slice().sort(); - const same = results.length === expectedSorted.length - && results.every((id, i) => id === expectedSorted[i]); - if (!same) { - console.error(`${label} failed: expected ${expectedSorted.join(', ')} got ${results.join(', ')}`); - process.exit(1); - } -}; - -expectIds({ ext: '.py', author: 'bob' }, [1], 'author+ext'); -expectIds({ chunkAuthor: 'alice' }, [0, 1], 'chunkAuthor'); -expectIds({ chunkAuthor: 'dana' }, [3], 'chunkAuthor fallback to last_author'); -expectIds({ visibility: 'public', type: 'FunctionDeclaration' }, [0, 2], 'visibility+type'); - -console.log('Filter index test passed'); diff --git a/tests/retrieval/filters/filter-strictness.test.js b/tests/retrieval/filters/filter-strictness.test.js deleted file mode 100644 index d6303f443..000000000 --- a/tests/retrieval/filters/filter-strictness.test.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -import { filterChunks } from '../../../src/retrieval/output.js'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; - -const meta = [ - { - id: 0, - kind: 'FunctionDeclaration', - last_author: 'Alice', - docmeta: { signature: 'foo(bar)', params: ['bar'] }, - codeRelations: { calls: [['foo', 'fetch']], usages: ['config'] }, - file: 'src/a.js', - ext: '.js', - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 1, - kind: 'FunctionDeclaration', - docmeta: {}, - codeRelations: {}, - file: 'src/b.js', - ext: '.js', - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 2, - kind: 'ClassDeclaration', - last_author: 'Bob', - docmeta: { signature: 'baz()', params: ['baz'] }, - codeRelations: { calls: [['baz', 'other']], usages: ['other'] }, - file: 'src/c.js', - ext: '.js', - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 3, - docmeta: {}, - codeRelations: {}, - file: 'docs/readme.md', - ext: '.md', - metaV2: { lang: 'unknown', effective: { languageId: 'unknown' } } - }, - { - id: 4, - kind: ['FunctionDeclaration', 'MethodDefinition'], - last_author: ['Carol', 'Dana'], - docmeta: { signature: 'qux()', params: ['qux'] }, - codeRelations: {}, - file: 'src/nested/util.ts', - ext: '.ts', - metaV2: { lang: 'typescript', effective: { languageId: 'typescript' } } - }, - { - id: 5, - docmeta: {}, - codeRelations: {}, - file: 'docs/changelog.txt', - ext: '.txt' - }, - { - id: 6, - docmeta: {}, - codeRelations: {}, - file: 'docs/notes.md', - ext: '.md', - lang: ' ' - } -]; -const filterIndex = buildFilterIndex(meta, { fileChargramN: 3 }); - -const expectIds = (filters, expected, label) => { - const result = filterChunks(meta, filters, filterIndex).map((entry) => entry.id).sort(); - const expectedSorted = expected.slice().sort(); - const ok = result.length === expectedSorted.length - && result.every((value, idx) => value === expectedSorted[idx]); - if (!ok) { - console.error(`${label} failed: expected [${expectedSorted.join(', ')}], got [${result.join(', ')}]`); - process.exit(1); - } -}; - -expectIds({ signature: 'foo' }, [0], 'signature filter'); -expectIds({ param: 'bar' }, [0], 'param filter'); -expectIds({ calls: 'fetch' }, [0], 'calls filter'); -expectIds({ uses: 'config' }, [0], 'uses filter'); -expectIds({ type: 'FunctionDeclaration' }, [0, 1, 4], 'type filter strict'); -expectIds({ type: 'FunctionDeclaration ClassDeclaration' }, [0, 1, 2, 4], 'type multi filter'); -expectIds({ author: 'Alice' }, [0], 'author filter strict'); -expectIds({ author: 'car' }, [4], 'author filter substring'); -expectIds({ file: 'src/b.js', filePrefilter: { enabled: true, chargramN: 3 } }, [1], 'file filter substring'); -expectIds({ file: '/util\\.ts$/i', filePrefilter: { enabled: true, chargramN: 3 } }, [4], 'file filter regex'); -expectIds({ lang: 'unknown' }, [3, 5, 6], 'unknown language fallback'); - -console.log('filter strictness test passed'); diff --git a/tests/retrieval/filters/git-metadata/branch.test.js b/tests/retrieval/filters/git-metadata/branch.test.js deleted file mode 100644 index db158ae69..000000000 --- a/tests/retrieval/filters/git-metadata/branch.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env, branchName } = context; -if (!branchName) { - console.log('Skipping branch filter test (branch name unavailable).'); - process.exit(0); -} - -const branchMatch = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--branch', branchName] -}); -if (!(branchMatch.prose || []).length) { - console.error('branch filter returned no results for current branch.'); - process.exit(1); -} - -const branchMiss = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--branch', 'no-such-branch'] -}); -if ((branchMiss.prose || []).length) { - console.error('branch mismatch should return no results.'); - process.exit(1); -} - -console.log('Git metadata branch filter ok.'); diff --git a/tests/retrieval/filters/git-metadata/chunk-author.test.js b/tests/retrieval/filters/git-metadata/chunk-author.test.js deleted file mode 100644 index 7336c8f13..000000000 --- a/tests/retrieval/filters/git-metadata/chunk-author.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; -const extractFiles = (payload, key = 'prose') => - new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); - -const chunkAuthorAlice = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--chunk-author', 'Alice'] -}); -const aliceFiles = extractFiles(chunkAuthorAlice); -if (!aliceFiles.has('alpha.txt') || aliceFiles.has('beta.txt')) { - console.error('Chunk author filter for Alice failed.'); - process.exit(1); -} - -const chunkAuthorBob = runFilterSearch({ - repoRoot, - env, - query: 'alpha', - args: ['--chunk-author', 'Bob'] -}); -const bobFiles = extractFiles(chunkAuthorBob); -if (!bobFiles.has('beta.txt') || bobFiles.has('alpha.txt')) { - console.error('Chunk author filter for Bob failed.'); - process.exit(1); -} - -console.log('Git metadata chunk author filter ok.'); diff --git a/tests/retrieval/filters/git-metadata/modified-time.test.js b/tests/retrieval/filters/git-metadata/modified-time.test.js index e351fbc4a..7f6ae566f 100644 --- a/tests/retrieval/filters/git-metadata/modified-time.test.js +++ b/tests/retrieval/filters/git-metadata/modified-time.test.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; -const context = await ensureSearchFiltersRepo(); +const context = await ensureSearchFiltersRepo({ cacheScope: 'isolated' }); if (!context) process.exit(0); const { repoRoot, env } = context; diff --git a/tests/retrieval/filters/lang-filter-matches-embedded-segments.test.js b/tests/retrieval/filters/lang-filter-matches-embedded-segments.test.js deleted file mode 100644 index b3a60ae39..000000000 --- a/tests/retrieval/filters/lang-filter-matches-embedded-segments.test.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -import { discoverSegments, chunkSegments, assignSegmentUids } from '../../../src/index/segments.js'; -import { buildMetaV2 } from '../../../src/index/metadata-v2.js'; -import { getLanguageForFile } from '../../../src/index/language-registry.js'; -import { buildLineIndex } from '../../../src/shared/lines.js'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunks } from '../../../src/retrieval/output.js'; - -const fail = (message) => { - console.error(message); - process.exit(1); -}; - -const text = [ - '# Guide', - '', - '```tsx', - 'export function greet(name: string) {', - ' return name;', - '}', - '```', - '' -].join('\n'); - -const relPath = 'docs/guide.md'; -const ext = '.md'; -const segments = discoverSegments({ - text, - ext, - relPath, - mode: 'prose', - segmentsConfig: { inlineCodeSpans: false } -}); -await assignSegmentUids({ text, segments, ext, mode: 'prose' }); -const chunks = chunkSegments({ - text, - ext, - relPath, - mode: 'prose', - segments, - lineIndex: buildLineIndex(text), - context: {} -}); - -const containerLang = getLanguageForFile(ext, relPath); -const chunkMeta = chunks.map((chunk, id) => { - const effectiveExt = chunk.segment?.ext || ext; - const effectiveLang = getLanguageForFile(effectiveExt, relPath); - const containerLanguageId = containerLang?.id || null; - const lang = effectiveLang?.id || chunk.segment?.languageId || containerLanguageId || 'unknown'; - const metaV2 = buildMetaV2({ - chunk: { - ...chunk, - file: relPath, - ext, - lang, - containerLanguageId, - effectiveExt - }, - docmeta: {}, - toolInfo: { tool: 'pairofcleats', version: '0.0.0-test' }, - analysisPolicy: { metadata: { enabled: true } } - }); - return { - id, - file: relPath, - ext, - kind: chunk.kind || null, - name: chunk.name || null, - metaV2 - }; -}); - -const filterIndex = buildFilterIndex(chunkMeta); -const hits = filterChunks(chunkMeta, { lang: 'typescript' }, filterIndex); -if (!hits.length) fail('Expected lang filter to return embedded TypeScript chunks.'); -if (!hits.every((hit) => hit.metaV2?.lang === 'typescript')) { - fail('Expected all lang-filtered hits to have metaV2.lang=typescript.'); -} - -console.log('lang filter matches embedded segments'); diff --git a/tests/retrieval/filters/lang-filter.test.js b/tests/retrieval/filters/lang-filter.test.js deleted file mode 100644 index 4565917c4..000000000 --- a/tests/retrieval/filters/lang-filter.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import { mergeExtFilters, mergeLangFilters, normalizeLangFilter } from '../../../src/retrieval/filters.js'; - -const js = normalizeLangFilter('js'); -assert.ok(js && js.includes('javascript'), 'expected js to include javascript'); - -const mixed = normalizeLangFilter('ts,python'); -assert.ok(mixed && mixed.includes('typescript'), 'expected mixed to include typescript'); -assert.ok(mixed && mixed.includes('python'), 'expected mixed to include python'); - -const extFilterInfo = mergeExtFilters(['.ts'], ['.tsx']); -assert.equal(extFilterInfo.impossible, true, 'expected ext filter intersection to be impossible'); -assert.equal(extFilterInfo.values, null, 'expected ext filter values to be null on conflict'); - -const langFilterInfo = mergeLangFilters(normalizeLangFilter('typescript'), normalizeLangFilter('ts')); -assert.equal(langFilterInfo.impossible, false, 'expected lang filter to be possible'); -assert.ok(langFilterInfo.values && langFilterInfo.values.length === 1 && langFilterInfo.values[0] === 'typescript', 'expected lang filter to dedupe'); - -const unknown = normalizeLangFilter('unknown'); -assert.ok(unknown && unknown.includes('unknown'), 'expected unknown to pass through'); - -console.log('lang filter test passed'); diff --git a/tests/retrieval/filters/query-syntax/negative-terms.test.js b/tests/retrieval/filters/query-syntax/negative-terms.test.js deleted file mode 100644 index ce76fc3fa..000000000 --- a/tests/retrieval/filters/query-syntax/negative-terms.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; -const extractFiles = (payload, key = 'prose') => - new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); - -const negativeToken = runFilterSearch({ repoRoot, env, query: 'alpha -gamma' }); -const negativeTokenFiles = extractFiles(negativeToken); -if (!negativeTokenFiles.has('alpha.txt') || negativeTokenFiles.has('beta.txt')) { - console.error('Negative token filter failed.'); - process.exit(1); -} - -const negativePhrase = runFilterSearch({ repoRoot, env, query: 'alpha -"alpha beta"' }); -const negativePhraseFiles = extractFiles(negativePhrase); -if (!negativePhraseFiles.has('beta.txt') || negativePhraseFiles.has('alpha.txt')) { - console.error('Negative phrase filter failed.'); - process.exit(1); -} - -console.log('Query syntax negative terms ok.'); diff --git a/tests/retrieval/filters/query-syntax/phrases-and-scorebreakdown.test.js b/tests/retrieval/filters/query-syntax/phrases-and-scorebreakdown.test.js deleted file mode 100644 index d2e1b76f1..000000000 --- a/tests/retrieval/filters/query-syntax/phrases-and-scorebreakdown.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import { ensureSearchFiltersRepo, runFilterSearch } from '../../../helpers/search-filters-repo.js'; - -const context = await ensureSearchFiltersRepo(); -if (!context) process.exit(0); - -const { repoRoot, env } = context; - -const phraseSearch = runFilterSearch({ - repoRoot, - env, - query: '"alpha beta"', - args: ['--explain'] -}); -const phraseHits = phraseSearch.prose || []; -if (!phraseHits.length) { - console.error('Phrase search returned no results.'); - process.exit(1); -} -const phraseMatch = phraseHits[0]?.scoreBreakdown?.phrase?.matches || 0; -if (phraseMatch <= 0) { - console.error('Expected phrase match score breakdown for quoted phrase.'); - process.exit(1); -} - -console.log('Query syntax phrase breakdown ok.'); diff --git a/tests/retrieval/filters/retrieval-branch-filter.test.js b/tests/retrieval/filters/retrieval-branch-filter.test.js deleted file mode 100644 index 1afba5e03..000000000 --- a/tests/retrieval/filters/retrieval-branch-filter.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { applyBranchFilter } from '../../../src/retrieval/cli/branch-filter.js'; - -let recorded = null; -const backendPolicy = { reason: 'auto', backendLabel: 'sqlite' }; -const result = await applyBranchFilter({ - branchFilter: 'main', - caseSensitive: false, - repoBranch: 'dev', - backendLabel: 'sqlite', - backendPolicy, - emitOutput: false, - jsonOutput: true, - recordSearchMetrics: (status) => { - recorded = status; - } -}); - -assert.equal(result.matched, false, 'expected branch mismatch to be reported'); -assert.equal(recorded, 'ok', 'expected search metrics to be recorded'); -assert.ok(result.payload, 'expected payload for branch mismatch'); -assert.equal(result.payload.backend, 'sqlite'); -assert.deepEqual(result.payload.prose, []); -assert.deepEqual(result.payload.code, []); -assert.deepEqual(result.payload.records, []); -assert.equal(result.payload.stats.branch, 'dev'); -assert.equal(result.payload.stats.branchFilter, 'main'); -assert.equal(result.payload.stats.branchMatch, false); -assert.deepEqual(result.payload.stats.backendPolicy, backendPolicy); - -console.log('retrieval branch filter test passed'); diff --git a/tests/retrieval/filters/risk.test.js b/tests/retrieval/filters/risk.test.js deleted file mode 100644 index bf30b0bdf..000000000 --- a/tests/retrieval/filters/risk.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { skipIfNativeGrammarsUnavailable } from '../../indexing/tree-sitter/native-availability.js'; - -if (skipIfNativeGrammarsUnavailable(['javascript'], 'retrieval risk filters')) { - process.exit(0); -} - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture', - requireRiskTags: true, - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const riskTag = await runSearch({ - query: 'exec', - mode: 'code', - args: ['--risk', 'command-exec'] -}); -if (!(riskTag.code || []).length) { - console.log('risk tags unavailable in fixture index; skipping retrieval risk filters test.'); - process.exit(0); -} - -const riskFlow = await runSearch({ - query: 'req', - mode: 'code', - args: ['--risk-flow', 'req.body->exec'] -}); -if (!(riskFlow.code || []).length) { - console.log('risk flows unavailable in fixture index; skipping retrieval risk filters test.'); - process.exit(0); -} - -console.log('Retrieval risk filters ok.'); diff --git a/tests/retrieval/filters/search-filter-contract-matrix.test.js b/tests/retrieval/filters/search-filter-contract-matrix.test.js new file mode 100644 index 000000000..af39b348d --- /dev/null +++ b/tests/retrieval/filters/search-filter-contract-matrix.test.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; +import { + mergeExtFilters, + mergeLangFilters, + normalizeExtFilter, + normalizeLangFilter +} from '../../../src/retrieval/filters.js'; +import { filterChunks } from '../../../src/retrieval/output.js'; +import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; + +const languageFixture = await ensureFixtureIndex({ + fixtureName: 'languages', + cacheName: 'language-fixture', + cacheScope: 'shared', + requiredModes: ['code'] +}); +const sampleFixture = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName: 'fixture-sample', + cacheScope: 'shared', + requiredModes: ['code'] +}); + +const runLanguageSearch = createInProcessSearchRunner({ + fixtureRoot: languageFixture.fixtureRoot, + env: languageFixture.env +}); +const runSampleSearch = createInProcessSearchRunner({ + fixtureRoot: sampleFixture.fixtureRoot, + env: sampleFixture.env +}); + +const cases = [ + { + name: 'ext filter normalization lowercases and dedupes extensions', + async run() { + const result = normalizeExtFilter(['*.js', 'JS', '.Md']); + assert.deepEqual((result || []).slice().sort(), ['.js', '.md']); + } + }, + { + name: 'lang filter normalization and merging preserve canonical values', + async run() { + const js = normalizeLangFilter('js'); + assert.ok(js && js.includes('javascript')); + + const mixed = normalizeLangFilter('ts,python'); + assert.ok(mixed && mixed.includes('typescript')); + assert.ok(mixed && mixed.includes('python')); + + const extFilterInfo = mergeExtFilters(['.ts'], ['.tsx']); + assert.equal(extFilterInfo.impossible, true); + assert.equal(extFilterInfo.values, null); + + const langFilterInfo = mergeLangFilters(normalizeLangFilter('typescript'), normalizeLangFilter('ts')); + assert.equal(langFilterInfo.impossible, false); + assert.deepEqual(langFilterInfo.values, ['typescript']); + + const unknown = normalizeLangFilter('unknown'); + assert.ok(unknown && unknown.includes('unknown')); + } + }, + { + name: 'file filter case sensitivity preserves strict versus loose matches', + async run() { + const chunkMeta = [ + { id: 0, file: 'src/Foo.js', ext: '.js', metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } }, + { id: 1, file: 'src/foo.js', ext: '.js', metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } } + ]; + const filterIndex = buildFilterIndex(chunkMeta, { fileChargramN: 3 }); + + const strictHits = filterChunks(chunkMeta, { + file: 'Foo.js', + caseFile: true, + filePrefilter: { enabled: true, chargramN: 3 } + }, filterIndex); + assert.equal(strictHits.length, 1); + assert.equal(strictHits[0].file, 'src/Foo.js'); + + const looseHits = filterChunks(chunkMeta, { + file: 'Foo.js', + caseFile: false, + filePrefilter: { enabled: true, chargramN: 3 } + }, filterIndex); + assert.equal(looseHits.length, 2); + } + }, + { + name: 'behavioral returns filter', + async run() { + const payload = await runLanguageSearch({ + query: 'update', + mode: 'code', + args: ['--returns'] + }); + assert.ok((payload.code || []).length > 0); + } + }, + { + name: 'behavioral async filter', + async run() { + const payload = await runLanguageSearch({ + query: 'load', + mode: 'code', + args: ['--async'] + }); + assert.ok((payload.code || []).length > 0); + } + }, + { + name: 'control-flow branches filter exposes matching metadata', + async run() { + const payload = await runLanguageSearch({ + query: 'load', + mode: 'code', + args: ['--branches', '1'] + }); + const hits = payload.code || []; + assert.ok(hits.length > 0); + assert.ok(hits.some((hit) => (hit.docmeta?.controlFlow?.branches || 0) >= 1)); + } + }, + { + name: 'file selector supports regex file filters', + async run() { + const payload = await runLanguageSearch({ + query: 'buildAliases', + mode: 'code', + args: ['--file', '/javascript_advanced\\.js$/'] + }); + const hits = payload.code || []; + assert.ok(hits.length > 0); + assert.ok(hits.some((hit) => hit.file && hit.file.endsWith('javascript_advanced.js'))); + } + }, + { + name: 'ext filter narrows results to matching extension', + async run() { + const payload = await runSampleSearch({ + query: 'message', + mode: 'code', + args: ['--backend', 'memory', '--ext', '.py'] + }); + const hits = payload.code || []; + assert.ok(hits.length > 0); + assert.ok(hits.every((hit) => hit.ext === '.py')); + } + }, + { + name: 'path filter narrows results to matching file path', + async run() { + const payload = await runSampleSearch({ + query: 'message', + mode: 'code', + args: ['--backend', 'memory', '--path', 'src/sample.py'] + }); + const hits = payload.code || []; + assert.ok(hits.length > 0); + assert.ok(hits.every((hit) => hit.file === 'src/sample.py')); + } + }, + { + name: 'type filter returns matching declarations', + async run() { + const payload = await runSampleSearch({ + query: 'sayHello', + mode: 'code', + args: ['--backend', 'memory', '--type', 'MethodDeclaration'] + }); + assert.ok((payload.code || []).length > 0); + } + }, + { + name: 'signature filter returns matching declarations', + async run() { + const payload = await runSampleSearch({ + query: 'sayHello', + mode: 'code', + args: ['--backend', 'memory', '--signature', 'func sayHello'] + }); + assert.ok((payload.code || []).length > 0); + } + }, + { + name: 'decorator filter returns matching declarations', + async run() { + const payload = await runSampleSearch({ + query: 'sayHello', + mode: 'code', + args: ['--backend', 'memory', '--decorator', 'available'] + }); + assert.ok((payload.code || []).length > 0); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('retrieval search filter contract matrix test passed'); diff --git a/tests/retrieval/filters/search-filter-git-prose-contract.test.js b/tests/retrieval/filters/search-filter-git-prose-contract.test.js new file mode 100644 index 000000000..07174424f --- /dev/null +++ b/tests/retrieval/filters/search-filter-git-prose-contract.test.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { createInProcessFilterSearch, ensureSearchFiltersRepo } from '../../helpers/search-filters-repo.js'; + +const filterRepoContext = await ensureSearchFiltersRepo(); +if (!filterRepoContext) process.exit(0); + +const { repoRoot: filterRepoRoot, env: filterRepoEnv } = filterRepoContext; +const runFilterSearch = createInProcessFilterSearch({ + repoRoot: filterRepoRoot, + env: filterRepoEnv +}); +const extractFiles = (payload, key = 'prose') => new Set((payload[key] || []).map((hit) => path.basename(hit.file || ''))); + +const cases = [ + { + name: 'negative token and phrase syntax filters prose hits', + async run() { + const negativeToken = await runFilterSearch({ query: 'alpha -gamma' }); + const negativeTokenFiles = extractFiles(negativeToken); + assert.equal(negativeTokenFiles.has('alpha.txt'), true); + assert.equal(negativeTokenFiles.has('beta.txt'), false); + + const negativePhrase = await runFilterSearch({ + query: 'alpha -"alpha beta"' + }); + const negativePhraseFiles = extractFiles(negativePhrase); + assert.equal(negativePhraseFiles.has('beta.txt'), true); + assert.equal(negativePhraseFiles.has('alpha.txt'), false); + } + }, + { + name: 'quoted phrase explain output carries phrase score breakdown', + async run() { + const phraseSearch = await runFilterSearch({ + query: '"alpha beta"', + args: ['--explain'] + }); + const phraseHits = phraseSearch.prose || []; + assert.ok(phraseHits.length > 0); + assert.equal((phraseHits[0]?.scoreBreakdown?.phrase?.matches || 0) > 0, true); + } + }, + { + name: 'git metadata branch and chunk-author filters narrow prose hits correctly', + async run() { + if (!filterRepoContext.branchName) { + return; + } + + const branchMatch = await runFilterSearch({ + query: 'alpha', + args: ['--branch', filterRepoContext.branchName] + }); + assert.ok((branchMatch.prose || []).length > 0); + + const branchMiss = await runFilterSearch({ + query: 'alpha', + args: ['--branch', 'no-such-branch'] + }); + assert.equal((branchMiss.prose || []).length, 0); + + const chunkAuthorAlice = await runFilterSearch({ + query: 'alpha', + args: ['--chunk-author', 'Alice'] + }); + const aliceFiles = extractFiles(chunkAuthorAlice); + assert.equal(aliceFiles.has('alpha.txt'), true); + assert.equal(aliceFiles.has('beta.txt'), false); + + const chunkAuthorBob = await runFilterSearch({ + query: 'alpha', + args: ['--chunk-author', 'Bob'] + }); + const bobFiles = extractFiles(chunkAuthorBob); + assert.equal(bobFiles.has('beta.txt'), true); + assert.equal(bobFiles.has('alpha.txt'), false); + } + }, + { + name: 'churn filter accepts numeric thresholds and rejects invalid values', + async run() { + const defaultPayload = await runFilterSearch({ + query: 'alpha' + }); + assert.ok((defaultPayload.prose || []).length > 0); + + const zeroPayload = await runFilterSearch({ + query: 'alpha', + args: ['--churn', '0'] + }); + assert.ok((zeroPayload.prose || []).length > 0); + + const highPayload = await runFilterSearch({ + query: 'alpha', + args: ['--churn', '999999'] + }); + assert.equal((highPayload.prose || []).length, 0); + + const invalidResult = runNode( + [ + path.join(filterRepoContext.root, 'search.js'), + 'alpha', + '--mode', + 'prose', + '--json', + '--no-ann', + '--repo', + filterRepoRoot, + '--backend', + 'memory', + '--churn', + 'not-a-number' + ], + 'invalid churn filter search', + filterRepoRoot, + filterRepoEnv, + { stdio: 'pipe', encoding: 'utf8', timeoutMs: 30 * 1000, allowFailure: true } + ); + assert.notEqual(invalidResult.status, 0); + assert.match(`${invalidResult.stdout || ''}\n${invalidResult.stderr || ''}`, /churn/i); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('retrieval search filter git/prose contract test passed'); diff --git a/tests/retrieval/filters/semantic-filter-contract-matrix.test.js b/tests/retrieval/filters/semantic-filter-contract-matrix.test.js new file mode 100644 index 000000000..e8e51c7bc --- /dev/null +++ b/tests/retrieval/filters/semantic-filter-contract-matrix.test.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { skipIfNativeGrammarsUnavailable } from '../../indexing/tree-sitter/native-availability.js'; + +if (skipIfNativeGrammarsUnavailable(['javascript', 'typescript'], 'retrieval semantic filters')) { + process.exit(0); +} + +const typeFixtureConfig = { + indexing: { + typeInference: true, + typeInferenceCrossFile: true + } +}; + +const typeFixture = await ensureFixtureIndex({ + fixtureName: 'type-filters', + cacheName: 'type-filters', + envOverrides: { PAIROFCLEATS_TEST_CONFIG: JSON.stringify(typeFixtureConfig) }, + cacheScope: 'isolated', + requiredModes: ['code'] +}); +const riskFixture = await ensureFixtureIndex({ + fixtureName: 'languages', + cacheName: 'language-fixture', + requireRiskTags: true, + cacheScope: 'isolated', + requiredModes: ['code'] +}); + +const runTypeSearch = createInProcessSearchRunner({ + fixtureRoot: typeFixture.fixtureRoot, + env: typeFixture.env +}); +const runRiskSearch = createInProcessSearchRunner({ + fixtureRoot: riskFixture.fixtureRoot, + env: riskFixture.env +}); + +const cases = [ + { + name: 'type filters return inferred-type and return-type matches when metadata is available', + async run() { + const inferred = await runTypeSearch({ + query: 'makeWidget', + mode: 'code', + args: ['--backend', 'memory', '--inferred-type', 'widget'] + }); + const returns = await runTypeSearch({ + query: 'makeWidget', + mode: 'code', + args: ['--backend', 'memory', '--return-type', 'Widget'] + }); + + if (!(inferred.code || []).length || !(returns.code || []).length) { + return; + } + + assert.ok((inferred.code || []).length > 0); + assert.ok((returns.code || []).length > 0); + } + }, + { + name: 'risk filters return tagged and flow-linked matches when metadata is available', + async run() { + const riskTag = await runRiskSearch({ + query: 'exec', + mode: 'code', + args: ['--risk', 'command-exec'] + }); + const riskFlow = await runRiskSearch({ + query: 'req', + mode: 'code', + args: ['--risk-flow', 'req.body->exec'] + }); + + if (!(riskTag.code || []).length || !(riskFlow.code || []).length) { + return; + } + + assert.ok((riskTag.code || []).length > 0); + assert.ok((riskFlow.code || []).length > 0); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('semantic filter contract matrix test passed'); diff --git a/tests/retrieval/filters/type-signature-decorator.test.js b/tests/retrieval/filters/type-signature-decorator.test.js deleted file mode 100644 index d25a121c4..000000000 --- a/tests/retrieval/filters/type-signature-decorator.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName: 'fixture-sample', - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const typeScoped = await runSearch({ - query: 'sayHello', - mode: 'code', - args: ['--backend', 'memory', '--type', 'MethodDeclaration'] -}); -if (!(typeScoped.code || []).length) { - console.error('Fixture type filter returned no results.'); - process.exit(1); -} - -const signatureScoped = await runSearch({ - query: 'sayHello', - mode: 'code', - args: ['--backend', 'memory', '--signature', 'func sayHello'] -}); -if (!(signatureScoped.code || []).length) { - console.error('Fixture signature filter returned no results.'); - process.exit(1); -} - -const decoratorScoped = await runSearch({ - query: 'sayHello', - mode: 'code', - args: ['--backend', 'memory', '--decorator', 'available'] -}); -if (!(decoratorScoped.code || []).length) { - console.error('Fixture decorator filter returned no results.'); - process.exit(1); -} - -console.log('Fixture type/signature/decorator filters ok.'); diff --git a/tests/retrieval/filters/types.test.js b/tests/retrieval/filters/types.test.js deleted file mode 100644 index fc646e01f..000000000 --- a/tests/retrieval/filters/types.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import { createInProcessSearchRunner, ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { skipIfNativeGrammarsUnavailable } from '../../indexing/tree-sitter/native-availability.js'; - -if (skipIfNativeGrammarsUnavailable(['javascript', 'typescript'], 'retrieval type filters')) { - process.exit(0); -} - -const testConfig = { - indexing: { - typeInference: true, - typeInferenceCrossFile: true - } -}; - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'languages', - cacheName: 'language-fixture-types', - envOverrides: { PAIROFCLEATS_TEST_CONFIG: JSON.stringify(testConfig) }, - cacheScope: 'shared', - requiredModes: ['code'] -}); -const runSearch = createInProcessSearchRunner({ fixtureRoot, env }); - -const inferred = await runSearch({ - query: 'makeWidget', - mode: 'code', - args: ['--inferred-type', 'object'] -}); -if (!(inferred.code || []).length) { - console.log('inferred-type metadata unavailable in fixture index; skipping retrieval type filters test.'); - process.exit(0); -} - -const returns = await runSearch({ - query: 'makeWidget', - mode: 'code', - args: ['--return-type', 'Widget'] -}); -if (!(returns.code || []).length) { - console.log('return-type metadata unavailable in fixture index; skipping retrieval type filters test.'); - process.exit(0); -} - -console.log('Retrieval type filters ok.'); diff --git a/tests/retrieval/graph/context-pack-basic.test.js b/tests/retrieval/graph/context-pack-basic.test.js deleted file mode 100644 index 4dddce1ba..000000000 --- a/tests/retrieval/graph/context-pack-basic.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildGraphContextPack } from '../../../src/graph/context-pack.js'; -import { validateGraphContextPack } from '../../../src/contracts/validators/analysis.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'context-pack', - 'basic.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const pack = buildGraphContextPack({ - seed: { type: 'chunk', chunkUid: 'chunk-a' }, - graphRelations, - direction: 'out', - depth: 1, - caps: { - maxDepth: 2, - maxFanoutPerNode: 10, - maxNodes: 10, - maxEdges: 10, - maxPaths: 5, - maxCandidates: 5, - maxWorkUnits: 100 - }, - indexCompatKey: 'compat-basic' -}); - -const validation = validateGraphContextPack(pack); -if (!validation.ok) { - console.error(`GraphContextPack validation failed: ${validation.errors.join('; ')}`); - process.exit(1); -} - -const nodeIds = pack.nodes.map((node) => node.ref?.chunkUid); -if (!nodeIds.includes('chunk-a') || !nodeIds.includes('chunk-b')) { - console.error('Expected graph context pack to include seed and neighbor nodes.'); - process.exit(1); -} - -if (pack.edges.length !== 1) { - console.error('Expected exactly one call edge in graph context pack.'); - process.exit(1); -} - -console.log('graph context pack basic test passed'); diff --git a/tests/retrieval/graph/context-pack-caps.test.js b/tests/retrieval/graph/context-pack-caps.test.js deleted file mode 100644 index f36701af6..000000000 --- a/tests/retrieval/graph/context-pack-caps.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildGraphContextPack } from '../../../src/graph/context-pack.js'; -import { validateGraphContextPack } from '../../../src/contracts/validators/analysis.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'context-pack', - 'caps.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const pack = buildGraphContextPack({ - seed: { type: 'chunk', chunkUid: 'seed' }, - graphRelations, - direction: 'out', - depth: 1, - caps: { - maxDepth: 1, - maxFanoutPerNode: 2, - maxNodes: 3, - maxEdges: 2, - maxPaths: 1, - maxCandidates: 5, - maxWorkUnits: 100 - }, - indexCompatKey: 'compat-caps' -}); - -const validation = validateGraphContextPack(pack); -if (!validation.ok) { - console.error(`GraphContextPack validation failed: ${validation.errors.join('; ')}`); - process.exit(1); -} - -if (!Array.isArray(pack.truncation) || !pack.truncation.length) { - console.error('Expected truncation metadata when caps are exceeded.'); - process.exit(1); -} - -const caps = new Set(pack.truncation.map((entry) => entry.cap)); -if (!caps.has('maxFanoutPerNode') && !caps.has('maxEdges') && !caps.has('maxNodes')) { - console.error('Expected truncation record for graph caps.'); - process.exit(1); -} - -console.log('graph context pack caps test passed'); diff --git a/tests/retrieval/graph/context-pack-contract-matrix.test.js b/tests/retrieval/graph/context-pack-contract-matrix.test.js new file mode 100644 index 000000000..23af07feb --- /dev/null +++ b/tests/retrieval/graph/context-pack-contract-matrix.test.js @@ -0,0 +1,441 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { assembleCompositeContextPack, assembleCompositeContextPackStreaming } from '../../../src/context-pack/assemble.js'; +import { buildGraphContextPack } from '../../../src/graph/context-pack.js'; +import { validateGraphContextPack } from '../../../src/contracts/validators/analysis.js'; +import { CONTEXT_PACK_RISK_CONTRACT_VERSION } from '../../../src/contracts/context-pack-risk-contract.js'; +import { validateCompositeContextPack } from '../../../src/contracts/validators/analysis.js'; +import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; +import { + loadChunkMeta, + loadJsonArrayArtifactSync, + MAX_JSON_BYTES, + readCompatibilityKey +} from '../../../src/shared/artifact-io.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; + +const root = process.cwd(); +const graphFixturePath = (...parts) => path.join( + root, + 'tests', + 'fixtures', + 'graph', + 'context-pack', + ...parts +); + +const loadFixture = (name) => JSON.parse(fs.readFileSync(graphFixturePath(name), 'utf8')); + +const withStreamingFixture = async (suffix, build) => { + applyTestEnv({ testing: '1' }); + const tempRoot = resolveTestCachePath(root, suffix); + const repoRoot = path.join(tempRoot, 'repo'); + const indexDir = path.join(tempRoot, 'index-code'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + try { + await build({ tempRoot, repoRoot, indexDir }); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } +}; + +const commonGraphCaps = { + maxDepth: 2, + maxFanoutPerNode: 10, + maxNodes: 10, + maxEdges: 10, + maxPaths: 5, + maxCandidates: 5, + maxWorkUnits: 100 +}; + +const cases = [ + { + name: 'composite context packs assemble excerpts, graph slices, and type facts', + async run() { + const repoRoot = process.cwd(); + const samplePath = path.join(repoRoot, 'tests', 'fixtures', 'context-pack', 'sample.js'); + const fileText = fs.readFileSync(samplePath, 'utf8'); + const start = fileText.indexOf('function alpha'); + const end = fileText.indexOf('}', start) + 1; + const chunkMeta = [ + { + id: 0, + file: 'tests/fixtures/context-pack/sample.js', + chunkUid: 'chunk-alpha', + start, + end, + startLine: 1, + endLine: 3, + docmeta: { + inferredTypes: { + returns: [{ type: 'number', source: 'heur', confidence: 0.9 }] + } + } + } + ]; + const graphRelations = { + callGraph: { nodes: [{ id: 'chunk-alpha', out: [] }] }, + usageGraph: { nodes: [] }, + importGraph: { nodes: [] } + }; + + const pack = assembleCompositeContextPack({ + seed: { type: 'chunk', chunkUid: 'chunk-alpha' }, + chunkMeta, + repoRoot, + graphRelations, + includeGraph: true, + includeTypes: true, + includeRisk: false, + depth: 1, + maxBytes: 200, + indexCompatKey: 'compat-context-pack' + }); + + assert.ok(pack.primary.excerpt.includes('function alpha')); + assert.ok(pack.graph); + assert.ok((pack.types?.facts?.length || 0) > 0); + } + }, + { + name: 'basic graph context pack validates and includes neighbor edge', + async run() { + const graphRelations = loadFixture('basic.json'); + const pack = buildGraphContextPack({ + seed: { type: 'chunk', chunkUid: 'chunk-a' }, + graphRelations, + direction: 'out', + depth: 1, + caps: commonGraphCaps, + indexCompatKey: 'compat-basic' + }); + const validation = validateGraphContextPack(pack); + assert.equal(validation.ok, true, validation.errors?.join('; ')); + const nodeIds = pack.nodes.map((node) => node.ref?.chunkUid); + assert.equal(nodeIds.includes('chunk-a'), true); + assert.equal(nodeIds.includes('chunk-b'), true); + assert.equal(pack.edges.length, 1); + } + }, + { + name: 'risk slices assemble and validate inside composite context packs', + async run() { + applyTestEnv(); + const { fixtureRoot, codeDir } = await ensureFixtureIndex({ + fixtureName: 'risk-interprocedural/js-simple', + cacheName: 'retrieval-context-pack-risk', + cacheScope: 'isolated', + requireRiskTags: true, + requiredModes: ['code'] + }); + + const summaries = loadJsonArrayArtifactSync(codeDir, 'risk_summaries', { + maxBytes: MAX_JSON_BYTES, + strict: true + }); + const seedSummary = Array.isArray(summaries) + ? summaries.find((entry) => typeof entry?.chunkUid === 'string' && entry.chunkUid) + : null; + assert.ok(seedSummary?.chunkUid); + + const chunkMeta = await loadChunkMeta(codeDir, { + maxBytes: MAX_JSON_BYTES, + strict: true + }); + const indexCompatKey = readCompatibilityKey(codeDir, { + maxBytes: MAX_JSON_BYTES, + strict: true + }).key; + const indexSignature = await buildIndexSignature(codeDir); + + const pack = assembleCompositeContextPack({ + seed: { type: 'chunk', chunkUid: seedSummary.chunkUid }, + chunkMeta, + repoRoot: fixtureRoot, + indexDir: codeDir, + includeGraph: false, + includeTypes: false, + includeRisk: true, + includeImports: false, + includeUsages: false, + includeCallersCallees: false, + indexCompatKey, + indexSignature + }); + + assert.equal(pack.risk?.status, 'ok'); + assert.equal(pack.risk?.contractVersion, CONTEXT_PACK_RISK_CONTRACT_VERSION); + assert.ok(Array.isArray(pack.risk?.flows) && pack.risk.flows.length > 0); + assert.ok(typeof pack.primary?.excerptHash === 'string' && pack.primary.excerptHash.length > 0); + assert.equal(pack.risk?.provenance?.indexSignature, pack.provenance?.indexSignature); + assert.equal(pack.risk?.provenance?.indexCompatKey, pack.provenance?.indexCompatKey); + assert.match(pack.risk?.provenance?.ruleBundle?.fingerprint || '', /^sha1:/); + assert.ok(pack.risk?.provenance?.artifactRefs?.stats?.entrypoint); + + const validation = validateCompositeContextPack(pack); + assert.equal(validation.ok, true, validation.errors.join(', ')); + } + }, + { + name: 'graph caps emit truncation metadata when exceeded', + async run() { + const graphRelations = loadFixture('caps.json'); + const pack = buildGraphContextPack({ + seed: { type: 'chunk', chunkUid: 'seed' }, + graphRelations, + direction: 'out', + depth: 1, + caps: { + maxDepth: 1, + maxFanoutPerNode: 2, + maxNodes: 3, + maxEdges: 2, + maxPaths: 1, + maxCandidates: 5, + maxWorkUnits: 100 + }, + indexCompatKey: 'compat-caps' + }); + const validation = validateGraphContextPack(pack); + assert.equal(validation.ok, true, validation.errors?.join('; ')); + assert.ok(Array.isArray(pack.truncation) && pack.truncation.length > 0); + const caps = new Set(pack.truncation.map((entry) => entry.cap)); + assert.equal( + caps.has('maxFanoutPerNode') || caps.has('maxEdges') || caps.has('maxNodes'), + true + ); + } + }, + { + name: 'graph context pack output is deterministic apart from stats', + async run() { + const graphRelations = loadFixture('basic.json'); + const buildOnce = () => buildGraphContextPack({ + seed: { type: 'chunk', chunkUid: 'chunk-a' }, + graphRelations, + direction: 'out', + depth: 1, + caps: commonGraphCaps, + indexCompatKey: 'compat-determinism', + now: () => '2026-02-01T00:00:00.000Z' + }); + const stripStats = (value) => { + const cloned = JSON.parse(JSON.stringify(value)); + delete cloned.stats; + return cloned; + }; + assert.equal( + JSON.stringify(stripStats(buildOnce())), + JSON.stringify(stripStats(buildOnce())) + ); + } + }, + { + name: 'streaming assembly resolves chunk and file seeds deterministically', + async run() { + await withStreamingFixture('context-pack-streaming-assembly-matrix', async ({ repoRoot, indexDir }) => { + const repoFile = path.join(repoRoot, 'src', 'file.js'); + const repoText = [ + 'export function greet(name) {', + ' return `hi ${name}`;', + '}', + '' + ].join('\n'); + await fsPromises.writeFile(repoFile, repoText, 'utf8'); + const repoBytes = Buffer.byteLength(repoText, 'utf8'); + const chunkUid = 'chunk-000001'; + await fsPromises.writeFile( + path.join(indexDir, 'chunk_uid_map.jsonl'), + `${JSON.stringify({ + docId: 0, + chunkId: '0', + chunkUid, + file: 'src/file.js', + start: 0, + end: repoBytes + })}\n`, + 'utf8' + ); + await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { + fields: { + artifactSurfaceVersion: 'test', + buildId: 'streaming-assembly', + mode: 'code', + compatibilityKey: 'compat-test' + }, + atomic: true + }); + await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { + fields: { + fields: { + version: 2, + artifactSurfaceVersion: 'test', + compatibilityKey: 'compat-test', + generatedAt: new Date().toISOString(), + mode: 'code', + stage: 'streaming-assembly', + pieces: [ + { name: 'chunk_uid_map', path: 'chunk_uid_map.jsonl', format: 'jsonl' } + ] + } + }, + atomic: true + }); + const stripStats = (value) => { + const cloned = JSON.parse(JSON.stringify(value)); + delete cloned.stats; + return cloned; + }; + const fixedNow = () => '2026-02-01T00:00:00.000Z'; + const common = { + repoRoot, + indexDir, + strict: true, + indexCompatKey: 'compat-test', + now: fixedNow, + includeGraph: false, + includeTypes: false, + includeRisk: false, + includeImports: false, + includeUsages: false, + includeCallersCallees: false + }; + const payloadChunk = await assembleCompositeContextPackStreaming({ + ...common, + seed: { type: 'chunk', chunkUid } + }); + assert.equal(payloadChunk?.primary?.file, 'src/file.js'); + assert.ok(payloadChunk.primary.excerpt.includes('export function greet')); + assert.equal( + Array.isArray(payloadChunk.warnings) + ? payloadChunk.warnings.some((w) => w?.code === 'CHUNK_UID_MAP_MISS') + : false, + false + ); + + const payloadFile = await assembleCompositeContextPackStreaming({ + ...common, + seed: { type: 'file', path: 'src/file.js' } + }); + assert.equal(payloadFile?.primary?.file, 'src/file.js'); + assert.ok(payloadFile.primary.excerpt.includes('export function greet')); + + const payloadRepeat = await assembleCompositeContextPackStreaming({ + ...common, + seed: { type: 'chunk', chunkUid } + }); + assert.deepEqual(stripStats(payloadRepeat), stripStats(payloadChunk)); + }); + } + }, + { + name: 'seed indexing reports indexed rows and lookup strategy', + async run() { + await withStreamingFixture('context-pack-seed-indexing-matrix', async ({ repoRoot, indexDir }) => { + const repoFile = path.join(repoRoot, 'src', 'main.js'); + const repoText = [ + 'export function main(name) {', + ' return `hello ${name}`;', + '}', + '' + ].join('\n'); + await fsPromises.writeFile(repoFile, repoText, 'utf8'); + const repoBytes = Buffer.byteLength(repoText, 'utf8'); + const targetChunkUid = 'chunk-target-037'; + const rowCount = 50; + const lines = []; + for (let i = 0; i < rowCount; i += 1) { + const isTarget = i === 37; + const chunkUid = isTarget ? targetChunkUid : `chunk-${String(i).padStart(3, '0')}`; + lines.push(JSON.stringify({ + docId: i, + chunkId: String(i), + chunkUid, + file: isTarget ? 'src/main.js' : `src/file-${i}.js`, + start: 0, + end: isTarget ? repoBytes : 16 + })); + } + await fsPromises.writeFile(path.join(indexDir, 'chunk_uid_map.jsonl'), `${lines.join('\n')}\n`, 'utf8'); + await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { + fields: { + artifactSurfaceVersion: 'test', + buildId: 'seed-indexing', + mode: 'code', + compatibilityKey: 'compat-seed-indexing' + }, + atomic: true + }); + await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { + fields: { + fields: { + version: 2, + artifactSurfaceVersion: 'test', + compatibilityKey: 'compat-seed-indexing', + generatedAt: new Date().toISOString(), + mode: 'code', + stage: 'seed-indexing', + pieces: [ + { name: 'chunk_uid_map', path: 'chunk_uid_map.jsonl', format: 'jsonl' } + ] + } + }, + atomic: true + }); + const common = { + repoRoot, + indexDir, + strict: true, + indexCompatKey: 'compat-seed-indexing', + now: () => '2026-02-01T00:00:00.000Z', + includeGraph: false, + includeTypes: false, + includeRisk: false, + includeImports: false, + includeUsages: false, + includeCallersCallees: false + }; + const envelopeSeedPayload = await assembleCompositeContextPackStreaming({ + ...common, + seed: { + v: 1, + status: 'resolved', + resolved: { type: 'chunk', chunkUid: targetChunkUid }, + candidates: [ + { type: 'chunk', chunkUid: 'missing-chunk' }, + { type: 'file', path: 'src/missing.js' } + ] + } + }); + assert.equal(envelopeSeedPayload?.primary?.file, 'src/main.js'); + assert.ok(envelopeSeedPayload.primary.excerpt.includes('export function main')); + assert.equal(envelopeSeedPayload?.stats?.seedResolution?.strategy, 'chunk_uid_map_index'); + assert.equal(envelopeSeedPayload?.stats?.seedResolution?.rowsIndexed, rowCount); + assert.equal(envelopeSeedPayload?.stats?.seedResolution?.hit, true); + + const fileSeedPayload = await assembleCompositeContextPackStreaming({ + ...common, + seed: { type: 'file', path: 'src/main.js' } + }); + assert.equal(fileSeedPayload?.primary?.file, 'src/main.js'); + assert.equal(fileSeedPayload?.stats?.seedResolution?.rowsIndexed, rowCount); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('graph context pack contract matrix test passed'); diff --git a/tests/retrieval/graph/context-pack-determinism.test.js b/tests/retrieval/graph/context-pack-determinism.test.js deleted file mode 100644 index c738cba75..000000000 --- a/tests/retrieval/graph/context-pack-determinism.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildGraphContextPack } from '../../../src/graph/context-pack.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'context-pack', - 'basic.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const buildOnce = () => buildGraphContextPack({ - seed: { type: 'chunk', chunkUid: 'chunk-a' }, - graphRelations, - direction: 'out', - depth: 1, - caps: { - maxDepth: 2, - maxFanoutPerNode: 10, - maxNodes: 10, - maxEdges: 10, - maxPaths: 5, - maxCandidates: 5, - maxWorkUnits: 100 - }, - indexCompatKey: 'compat-determinism', - now: () => '2026-02-01T00:00:00.000Z' -}); - -const stripStats = (value) => { - if (!value || typeof value !== 'object') return value; - const cloned = JSON.parse(JSON.stringify(value)); - delete cloned.stats; - return cloned; -}; - -const first = JSON.stringify(stripStats(buildOnce())); -const second = JSON.stringify(stripStats(buildOnce())); - -if (first !== second) { - console.error('Expected deterministic graph context pack output.'); - process.exit(1); -} - -console.log('graph context pack determinism test passed'); diff --git a/tests/retrieval/graph/context-pack-seed-indexing.test.js b/tests/retrieval/graph/context-pack-seed-indexing.test.js deleted file mode 100644 index d328cf92f..000000000 --- a/tests/retrieval/graph/context-pack-seed-indexing.test.js +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { assembleCompositeContextPackStreaming } from '../../../src/context-pack/assemble.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'context-pack-seed-indexing'); -const repoRoot = path.join(tempRoot, 'repo'); -const indexDir = path.join(tempRoot, 'index-code'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -const repoFile = path.join(repoRoot, 'src', 'main.js'); -const repoText = [ - 'export function main(name) {', - ' return `hello ${name}`;', - '}', - '' -].join('\n'); -await fs.writeFile(repoFile, repoText, 'utf8'); -const repoBytes = Buffer.byteLength(repoText, 'utf8'); - -const targetChunkUid = 'chunk-target-037'; -const rowCount = 50; -const lines = []; -for (let i = 0; i < rowCount; i += 1) { - const isTarget = i === 37; - const chunkUid = isTarget ? targetChunkUid : `chunk-${String(i).padStart(3, '0')}`; - lines.push(JSON.stringify({ - docId: i, - chunkId: String(i), - chunkUid, - file: isTarget ? 'src/main.js' : `src/file-${i}.js`, - start: 0, - end: isTarget ? repoBytes : 16 - })); -} -await fs.writeFile(path.join(indexDir, 'chunk_uid_map.jsonl'), `${lines.join('\n')}\n`, 'utf8'); - -await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { - fields: { - artifactSurfaceVersion: 'test', - buildId: 'seed-indexing', - mode: 'code', - compatibilityKey: 'compat-seed-indexing' - }, - atomic: true -}); - -await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { - fields: { - fields: { - version: 2, - artifactSurfaceVersion: 'test', - compatibilityKey: 'compat-seed-indexing', - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'seed-indexing', - pieces: [ - { name: 'chunk_uid_map', path: 'chunk_uid_map.jsonl', format: 'jsonl' } - ] - } - }, - atomic: true -}); - -const commonOptions = { - repoRoot, - indexDir, - strict: true, - indexCompatKey: 'compat-seed-indexing', - now: () => '2026-02-01T00:00:00.000Z', - includeGraph: false, - includeTypes: false, - includeRisk: false, - includeImports: false, - includeUsages: false, - includeCallersCallees: false -}; - -const envelopeSeedPayload = await assembleCompositeContextPackStreaming({ - ...commonOptions, - seed: { - v: 1, - status: 'resolved', - resolved: { type: 'chunk', chunkUid: targetChunkUid }, - candidates: [ - { type: 'chunk', chunkUid: 'missing-chunk' }, - { type: 'file', path: 'src/missing.js' } - ] - } -}); - -assert.equal(envelopeSeedPayload?.primary?.file, 'src/main.js', 'expected indexed seed resolution to hit target file'); -assert.ok( - envelopeSeedPayload.primary.excerpt.includes('export function main'), - 'expected excerpt from indexed chunk seed' -); -assert.equal( - envelopeSeedPayload?.stats?.seedResolution?.strategy, - 'chunk_uid_map_index', - 'expected indexed seed resolution strategy' -); -assert.equal( - envelopeSeedPayload?.stats?.seedResolution?.rowsIndexed, - rowCount, - 'expected seed index stats to report indexed chunk_uid_map rows' -); -assert.equal( - envelopeSeedPayload?.stats?.seedResolution?.hit, - true, - 'expected indexed seed resolution to report hit=true' -); - -const fileSeedPayload = await assembleCompositeContextPackStreaming({ - ...commonOptions, - seed: { type: 'file', path: 'src/main.js' } -}); - -assert.equal(fileSeedPayload?.primary?.file, 'src/main.js', 'expected file seed to resolve via indexed map'); -assert.equal( - fileSeedPayload?.stats?.seedResolution?.rowsIndexed, - rowCount, - 'expected file seed lookup to reuse indexed rows accounting' -); - -console.log('context pack seed indexing test passed'); diff --git a/tests/retrieval/graph/context-pack-streaming-assembly.test.js b/tests/retrieval/graph/context-pack-streaming-assembly.test.js deleted file mode 100644 index e6395d61d..000000000 --- a/tests/retrieval/graph/context-pack-streaming-assembly.test.js +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { assembleCompositeContextPackStreaming } from '../../../src/context-pack/assemble.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'context-pack-streaming-assembly'); -const repoRoot = path.join(tempRoot, 'repo'); -const indexDir = path.join(tempRoot, 'index-code'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -const repoFile = path.join(repoRoot, 'src', 'file.js'); -const repoText = [ - 'export function greet(name) {', - ' return `hi ${name}`;', - '}', - '' -].join('\n'); -await fs.writeFile(repoFile, repoText, 'utf8'); -const repoBytes = Buffer.byteLength(repoText, 'utf8'); - -const chunkUid = 'chunk-000001'; -await fs.writeFile( - path.join(indexDir, 'chunk_uid_map.jsonl'), - `${JSON.stringify({ - docId: 0, - chunkId: '0', - chunkUid, - file: 'src/file.js', - start: 0, - end: repoBytes - })}\n`, - 'utf8' -); - -await writeJsonObjectFile(path.join(indexDir, 'index_state.json'), { - fields: { - artifactSurfaceVersion: 'test', - buildId: 'streaming-assembly', - mode: 'code', - compatibilityKey: 'compat-test' - }, - atomic: true -}); - -await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { - fields: { - fields: { - version: 2, - artifactSurfaceVersion: 'test', - compatibilityKey: 'compat-test', - generatedAt: new Date().toISOString(), - mode: 'code', - stage: 'streaming-assembly', - pieces: [ - { name: 'chunk_uid_map', path: 'chunk_uid_map.jsonl', format: 'jsonl' } - ] - } - }, - atomic: true -}); - -const stripStats = (value) => { - const cloned = JSON.parse(JSON.stringify(value)); - delete cloned.stats; - return cloned; -}; - -const fixedNow = () => '2026-02-01T00:00:00.000Z'; - -const payloadChunk = await assembleCompositeContextPackStreaming({ - seed: { type: 'chunk', chunkUid }, - repoRoot, - indexDir, - strict: true, - indexCompatKey: 'compat-test', - now: fixedNow, - includeGraph: false, - includeTypes: false, - includeRisk: false, - includeImports: false, - includeUsages: false, - includeCallersCallees: false -}); - -assert.equal(payloadChunk?.primary?.file, 'src/file.js'); -assert.ok(payloadChunk.primary.excerpt.includes('export function greet'), 'expected excerpt to be populated'); -assert.ok(!payloadChunk.warnings?.some((w) => w?.code === 'CHUNK_UID_MAP_MISS'), 'expected chunk_uid_map to resolve seed'); - -const payloadFile = await assembleCompositeContextPackStreaming({ - seed: { type: 'file', path: 'src/file.js' }, - repoRoot, - indexDir, - strict: true, - indexCompatKey: 'compat-test', - now: fixedNow, - includeGraph: false, - includeTypes: false, - includeRisk: false, - includeImports: false, - includeUsages: false, - includeCallersCallees: false -}); -assert.equal(payloadFile?.primary?.file, 'src/file.js'); -assert.ok(payloadFile.primary.excerpt.includes('export function greet'), 'expected excerpt via file seed'); - -const payloadRepeat = await assembleCompositeContextPackStreaming({ - seed: { type: 'chunk', chunkUid }, - repoRoot, - indexDir, - strict: true, - indexCompatKey: 'compat-test', - now: fixedNow, - includeGraph: false, - includeTypes: false, - includeRisk: false, - includeImports: false, - includeUsages: false, - includeCallersCallees: false -}); - -assert.deepStrictEqual(stripStats(payloadRepeat), stripStats(payloadChunk), 'expected streaming assembly to be deterministic'); - -console.log('context pack streaming assembly test passed'); diff --git a/tests/retrieval/graph/impact-analysis-caps-and-truncation.test.js b/tests/retrieval/graph/impact-analysis-caps-and-truncation.test.js deleted file mode 100644 index 9d62e6b1f..000000000 --- a/tests/retrieval/graph/impact-analysis-caps-and-truncation.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'impact', - 'caps.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const impact = buildImpactAnalysis({ - seed: { type: 'chunk', chunkUid: 'seed' }, - graphRelations, - direction: 'downstream', - depth: 1, - caps: { maxFanoutPerNode: 2, maxWorkUnits: 100 }, - indexCompatKey: 'compat-impact-caps' -}); - -const truncation = impact.truncation || []; -const hasCap = truncation.some((record) => record.cap === 'maxFanoutPerNode'); -assert(hasCap, 'expected truncation record for maxFanoutPerNode'); - -console.log('impact analysis caps/truncation test passed'); diff --git a/tests/retrieval/graph/impact-analysis-changed-set.test.js b/tests/retrieval/graph/impact-analysis-changed-set.test.js deleted file mode 100644 index 25dceae8d..000000000 --- a/tests/retrieval/graph/impact-analysis-changed-set.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'impact', - 'changed.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const impact = buildImpactAnalysis({ - changed: ['src/changed.js'], - graphRelations, - direction: 'downstream', - depth: 1, - caps: { maxWorkUnits: 100 }, - indexCompatKey: 'compat-impact-changed' -}); - -assert(impact.seed?.type === 'file', 'expected file seed derived from changed list'); -assert(impact.seed?.path === 'src/changed.js', 'expected seed path to match changed input'); - -const impacted = impact.impacted.map((entry) => entry.ref?.path).filter(Boolean); -assert(impacted.includes('src/target.js'), 'expected changed set to impact target file'); - -console.log('impact analysis changed-set test passed'); diff --git a/tests/retrieval/graph/impact-analysis-contract-matrix.test.js b/tests/retrieval/graph/impact-analysis-contract-matrix.test.js new file mode 100644 index 000000000..cf8a18015 --- /dev/null +++ b/tests/retrieval/graph/impact-analysis-contract-matrix.test.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { buildImpactAnalysis } from '../../../src/graph/impact.js'; + +const loadFixture = (name) => JSON.parse(fs.readFileSync(path.join( + process.cwd(), + 'tests', + 'fixtures', + 'graph', + 'impact', + name +), 'utf8')); + +const cases = [ + { + name: 'downstream analysis reports impacted chunks with witness paths', + run() { + const impact = buildImpactAnalysis({ + seed: { type: 'chunk', chunkUid: 'chunk-a' }, + graphRelations: loadFixture('basic.json'), + direction: 'downstream', + depth: 1, + caps: { maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-basic' + }); + + const impacted = impact.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); + assert.ok(impacted.includes('chunk-b')); + const entry = impact.impacted.find((item) => item.ref?.chunkUid === 'chunk-b'); + assert.ok((entry?.witnessPath?.nodes?.length || 0) >= 2); + } + }, + { + name: 'upstream analysis traces reverse dependencies', + run() { + const impact = buildImpactAnalysis({ + seed: { type: 'chunk', chunkUid: 'chunk-b' }, + graphRelations: loadFixture('basic.json'), + direction: 'upstream', + depth: 1, + caps: { maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-upstream' + }); + + const impacted = impact.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); + assert.ok(impacted.includes('chunk-a')); + } + }, + { + name: 'both direction traces upstream and downstream dependencies', + run() { + const impact = buildImpactAnalysis({ + seed: { type: 'chunk', chunkUid: 'chunk-b' }, + graphRelations: { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + callGraph: { + nodeCount: 3, + edgeCount: 2, + nodes: [ + { id: 'chunk-a', file: 'src/a.js', name: 'alpha', kind: 'function', chunkId: 'a', out: ['chunk-b'], in: [] }, + { id: 'chunk-b', file: 'src/b.js', name: 'beta', kind: 'function', chunkId: 'b', out: ['chunk-c'], in: ['chunk-a'] }, + { id: 'chunk-c', file: 'src/c.js', name: 'gamma', kind: 'function', chunkId: 'c', out: [], in: ['chunk-b'] } + ] + }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } + }, + direction: 'both', + depth: 1, + caps: { maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-both' + }); + + assert.equal(impact.direction, 'both'); + const impacted = impact.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); + assert.ok(impacted.includes('chunk-a')); + assert.ok(impacted.includes('chunk-c')); + } + }, + { + name: 'changed file lists synthesize file seeds and reach impacted files', + run() { + const impact = buildImpactAnalysis({ + changed: ['src/changed.js'], + graphRelations: loadFixture('changed.json'), + direction: 'downstream', + depth: 1, + caps: { maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-changed' + }); + + assert.equal(impact.seed?.type, 'file'); + assert.equal(impact.seed?.path, 'src/changed.js'); + const impacted = impact.impacted.map((entry) => entry.ref?.path).filter(Boolean); + assert.ok(impacted.includes('src/target.js')); + } + }, + { + name: 'fanout caps emit truncation records', + run() { + const impact = buildImpactAnalysis({ + seed: { type: 'chunk', chunkUid: 'seed' }, + graphRelations: loadFixture('caps.json'), + direction: 'downstream', + depth: 1, + caps: { maxFanoutPerNode: 2, maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-caps' + }); + + const truncation = impact.truncation || []; + assert.ok(truncation.some((record) => record.cap === 'maxFanoutPerNode')); + } + }, + { + name: 'output remains deterministic across identical runs', + run() { + const buildOnce = () => buildImpactAnalysis({ + seed: { type: 'chunk', chunkUid: 'chunk-a' }, + graphRelations: loadFixture('basic.json'), + direction: 'downstream', + depth: 1, + caps: { maxWorkUnits: 100 }, + indexCompatKey: 'compat-impact-determinism', + now: () => '2026-02-01T00:00:00.000Z' + }); + + const stripStats = (value) => { + const cloned = JSON.parse(JSON.stringify(value)); + delete cloned.stats; + return cloned; + }; + + assert.equal(JSON.stringify(stripStats(buildOnce())), JSON.stringify(stripStats(buildOnce()))); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('impact analysis contract matrix test passed'); diff --git a/tests/retrieval/graph/impact-analysis-determinism.test.js b/tests/retrieval/graph/impact-analysis-determinism.test.js deleted file mode 100644 index 519a94c6b..000000000 --- a/tests/retrieval/graph/impact-analysis-determinism.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'impact', - 'basic.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const buildOnce = () => buildImpactAnalysis({ - seed: { type: 'chunk', chunkUid: 'chunk-a' }, - graphRelations, - direction: 'downstream', - depth: 1, - caps: { maxWorkUnits: 100 }, - indexCompatKey: 'compat-impact-determinism', - now: () => '2026-02-01T00:00:00.000Z' -}); - -const stripStats = (value) => { - if (!value || typeof value !== 'object') return value; - const cloned = JSON.parse(JSON.stringify(value)); - delete cloned.stats; - return cloned; -}; - -const first = JSON.stringify(stripStats(buildOnce())); -const second = JSON.stringify(stripStats(buildOnce())); - -if (first !== second) { - console.error('Expected deterministic impact analysis output.'); - process.exit(1); -} - -console.log('impact analysis determinism test passed'); diff --git a/tests/retrieval/graph/impact-analysis-downstream.test.js b/tests/retrieval/graph/impact-analysis-downstream.test.js deleted file mode 100644 index e3c546d79..000000000 --- a/tests/retrieval/graph/impact-analysis-downstream.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'impact', - 'basic.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const impact = buildImpactAnalysis({ - seed: { type: 'chunk', chunkUid: 'chunk-a' }, - graphRelations, - direction: 'downstream', - depth: 1, - caps: { maxWorkUnits: 100 }, - indexCompatKey: 'compat-impact-basic' -}); - -const impacted = impact.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); -assert(impacted.includes('chunk-b'), 'expected chunk-b to be impacted downstream'); - -const entry = impact.impacted.find((item) => item.ref?.chunkUid === 'chunk-b'); -assert(entry?.witnessPath?.nodes?.length >= 2, 'expected witness path for impacted node'); - -console.log('impact analysis downstream test passed'); diff --git a/tests/retrieval/graph/impact-analysis-upstream.test.js b/tests/retrieval/graph/impact-analysis-upstream.test.js deleted file mode 100644 index 1dfc1c698..000000000 --- a/tests/retrieval/graph/impact-analysis-upstream.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const fixturePath = path.join( - process.cwd(), - 'tests', - 'fixtures', - 'graph', - 'impact', - 'basic.json' -); -const graphRelations = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - -const impact = buildImpactAnalysis({ - seed: { type: 'chunk', chunkUid: 'chunk-b' }, - graphRelations, - direction: 'upstream', - depth: 1, - caps: { maxWorkUnits: 100 }, - indexCompatKey: 'compat-impact-upstream' -}); - -const impacted = impact.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); -assert(impacted.includes('chunk-a'), 'expected chunk-a to be impacted upstream'); - -console.log('impact analysis upstream test passed'); diff --git a/tests/retrieval/helpers/search-output-fixture.js b/tests/retrieval/helpers/search-output-fixture.js new file mode 100644 index 000000000..05fd48ac8 --- /dev/null +++ b/tests/retrieval/helpers/search-output-fixture.js @@ -0,0 +1,140 @@ +import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; + +const createAnnState = () => ({ + code: { available: false }, + prose: { available: false }, + records: { available: false }, + 'extracted-prose': { available: false } +}); + +const createAnnUsed = () => ({ + code: false, + prose: false, + records: false, + 'extracted-prose': false +}); + +const createLanceAnnState = () => ({ + code: { available: false, metric: null }, + prose: { available: false, metric: null }, + records: { available: false, metric: null }, + 'extracted-prose': { available: false, metric: null } +}); + +const createModelIds = () => ({ + code: 'test-model', + prose: 'test-model', + extractedProse: 'test-model', + records: 'test-model' +}); + +const createContextExpansionStats = () => ({ + enabled: false, + code: { added: 0, workUnitsUsed: 0, truncation: null }, + prose: { added: 0, workUnitsUsed: 0, truncation: null }, + 'extracted-prose': { added: 0, workUnitsUsed: 0, truncation: null }, + records: { added: 0, workUnitsUsed: 0, truncation: null } +}); + +export const createSearchOutputHitState = ({ + proseHits = [], + extractedProseHits = [], + codeHits = [], + recordHits = [] +} = {}) => ({ + expandedHits: { + prose: { hits: proseHits, contextHits: [] }, + extractedProse: { hits: extractedProseHits, contextHits: [] }, + code: { hits: codeHits, contextHits: [] }, + records: { hits: recordHits, contextHits: [] } + }, + baseHits: { + proseHits, + extractedProseHits, + codeHits, + recordHits + }, + idxProse: { chunkMeta: proseHits }, + idxExtractedProse: { chunkMeta: extractedProseHits }, + idxCode: { chunkMeta: codeHits }, + idxRecords: { chunkMeta: recordHits } +}); + +export const createSearchOutputOptions = (overrides = {}) => ({ + emitOutput: false, + jsonOutput: true, + jsonCompact: true, + explain: true, + color: {}, + rootDir: process.cwd(), + backendLabel: 'memory', + backendPolicyInfo: { backendLabel: 'memory', reason: 'test' }, + routingPolicy: { byMode: { code: { desired: 'sparse', route: 'sparse' } } }, + runCode: true, + runProse: false, + runExtractedProse: false, + runRecords: false, + topN: 5, + queryTokens: ['alpha'], + highlightRegex: null, + contextExpansionEnabled: false, + ...createSearchOutputHitState(), + annEnabled: false, + annActive: false, + annBackend: 'none', + vectorExtension: { annMode: 'none', provider: 'none', table: null }, + vectorAnnEnabled: false, + vectorAnnState: createAnnState(), + vectorAnnUsed: createAnnUsed(), + hnswConfig: { enabled: false }, + hnswAnnState: createAnnState(), + lanceAnnState: createLanceAnnState(), + modelIds: createModelIds(), + embeddingProvider: 'stub', + embeddingOnnx: {}, + cacheInfo: { enabled: false, hit: false, key: null }, + profileInfo: null, + intentInfo: { type: 'keyword' }, + resolvedDenseVectorMode: 'auto', + fieldWeights: null, + contextExpansionStats: createContextExpansionStats(), + showStats: false, + showMatched: false, + verboseCache: false, + elapsedMs: 5, + stageTracker: null, + ...overrides +}); + +export const renderSearchOutputForTest = (overrides = {}) => ( + renderSearchOutput(createSearchOutputOptions(overrides)) +); + +export const captureSearchOutputStreams = async (callback) => { + const stdoutChunks = []; + const stderrChunks = []; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }; + process.stderr.write = (chunk) => { + stderrChunks.push(String(chunk)); + return true; + }; + try { + await callback(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } + return { + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join('') + }; +}; + +export const captureSearchOutputStdout = async (callback) => ( + (await captureSearchOutputStreams(callback)).stdout +); diff --git a/tests/retrieval/helpers/search-pipeline-fixture.js b/tests/retrieval/helpers/search-pipeline-fixture.js new file mode 100644 index 000000000..d674328f8 --- /dev/null +++ b/tests/retrieval/helpers/search-pipeline-fixture.js @@ -0,0 +1,89 @@ +import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; + +export const makeAnnState = () => ({ + code: { available: false }, + prose: { available: false }, + records: { available: false }, + 'extracted-prose': { available: false } +}); + +export const makeAnnUsed = () => ({ + code: false, + prose: false, + records: false, + 'extracted-prose': false +}); + +export const createAlphaTokenIndex = (docCount = 1) => ({ + vocab: ['alpha'], + vocabIndex: new Map([['alpha', 0]]), + postings: [[[0, 1]]], + docLengths: new Array(docCount).fill(1), + totalDocs: docCount, + avgDocLen: 1 +}); + +export const createAlphaSearchIndex = ({ + chunks = [{ id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }], + tokenIndex = createAlphaTokenIndex(chunks.length), + fileRelations = null, + repoMap = null, + denseVec = null +} = {}) => ({ + chunkMeta: chunks, + tokenIndex, + filterIndex: null, + fileRelations, + repoMap, + phraseNgrams: null, + minhash: null, + denseVec +}); + +export const createSearchPipelineFixture = (overrides = {}) => createSearchPipeline({ + useSqlite: false, + sqliteFtsRequested: false, + sqliteFtsRoutingByMode: { byMode: {} }, + sqliteFtsVariantConfig: { + explicitTrigram: false, + substringMode: false, + stemming: false + }, + sqliteFtsNormalize: false, + sqliteFtsProfile: 'balanced', + sqliteFtsWeights: [0, 1, 1, 1, 1, 1, 1, 1], + query: 'alpha', + queryTokens: ['alpha'], + queryAst: null, + bm25K1: 1.2, + bm25B: 0.75, + fieldWeights: null, + postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, + phraseNgramSet: null, + phraseRange: null, + explain: true, + symbolBoost: { enabled: false }, + filters: {}, + filtersActive: false, + topN: 5, + annEnabled: false, + annBackend: 'auto', + scoreBlend: null, + minhashMaxDocs: null, + sparseBackend: 'auto', + vectorAnnState: makeAnnState(), + vectorAnnUsed: makeAnnUsed(), + hnswAnnState: makeAnnState(), + hnswAnnUsed: makeAnnUsed(), + lanceAnnState: makeAnnState(), + lanceAnnUsed: makeAnnUsed(), + lancedbConfig: {}, + buildCandidateSetSqlite: () => null, + getTokenIndexForQuery: () => null, + rankSqliteFts: () => [], + rankVectorAnnSqlite: () => [], + sqliteHasFts: () => false, + signal: null, + rrf: { enabled: false }, + ...overrides +}); diff --git a/tests/retrieval/hit-comparison-summary.test.js b/tests/retrieval/hit-comparison-summary.test.js new file mode 100644 index 000000000..06a3bd76b --- /dev/null +++ b/tests/retrieval/hit-comparison-summary.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { summarizeRetrievalHitComparison } from '../../src/retrieval/hit-comparison.js'; + +const baseHits = [ + { id: 'a', score: 0.9 }, + { id: 'b', score: 0.7 }, + { id: 'c', score: 0.3 } +]; +const otherHits = [ + { id: 'b', score: 0.6 }, + { id: 'd', score: 0.5 }, + { id: 'e', score: 0.4 } +]; + +const summary = summarizeRetrievalHitComparison(baseHits, otherHits, { + topN: 3, + missingLimit: 1 +}); +assert.equal(summary.overlap, 1 / 3, 'expected overlap to use the top-N hit intersection'); +assert.ok(Math.abs(summary.avgDelta - 0.1) < Number.EPSILON, 'expected score delta to come from shared comparator'); +assert.deepEqual(summary.missingFromOther, ['a'], 'expected missing-from-other list to honor the report limit'); +assert.deepEqual(summary.missingFromBase, ['d'], 'expected missing-from-base list to honor the report limit'); +assert.equal(summary.zeroHits, false, 'non-empty comparisons should not be marked zero-hit'); + +const emptyDefault = summarizeRetrievalHitComparison([], [], { topN: 5 }); +assert.equal(emptyDefault.overlap, 0, 'default empty comparison behavior should match compareRetrievalHitLists'); +assert.equal(emptyDefault.zeroHits, true, 'empty comparisons should expose a zero-hit marker'); + +const emptyParity = summarizeRetrievalHitComparison([], [], { + topN: 5, + treatBothEmptyAsPerfect: true +}); +assert.equal(emptyParity.overlap, 1, 'parity-style empty comparisons can opt into perfect overlap'); +assert.deepEqual(emptyParity.baseKeys, [], 'empty comparisons should keep top key arrays empty'); +assert.deepEqual(emptyParity.otherKeys, [], 'empty comparisons should keep top key arrays empty'); + +console.log('hit comparison summary test passed'); diff --git a/tests/retrieval/index-hydration-contract.test.js b/tests/retrieval/index-hydration-contract.test.js new file mode 100644 index 000000000..db08759f7 --- /dev/null +++ b/tests/retrieval/index-hydration-contract.test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildFileMetaById, + hydrateChunksFromFileMeta, + hydrateSearchIndexPostProcessing, + requireFileMetaForFileIdChunks +} from '../../src/retrieval/index-hydration.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +applyTestEnv(); + +const fileMetaById = buildFileMetaById([ + null, + { id: null, file: 'ignored.js' }, + { + id: 7, + file: 'src/example.js', + ext: 'js', + externalDocs: ['docs/example.md'], + last_modified: 123, + last_author: 'Ada', + churn: 11, + churn_added: 12, + churn_deleted: 13, + churn_commits: 14 + } +]); + +assert.equal(buildFileMetaById(null), null); +assert.equal(fileMetaById.size, 1); + +assert.throws( + () => requireFileMetaForFileIdChunks([{ fileId: 7 }]), + /file_meta\.json is required/ +); +assert.doesNotThrow(() => requireFileMetaForFileIdChunks([{ fileId: 7, file: 'src/example.js' }])); + +{ + const chunks = [{ + fileId: 7, + file: '', + ext: '', + churn: 0, + churn_added: 0, + churn_deleted: 0, + churn_commits: 0 + }]; + hydrateChunksFromFileMeta(chunks, fileMetaById, { churnAssignment: 'falsy-or-nullish' }); + assert.deepEqual(chunks[0], { + fileId: 7, + file: 'src/example.js', + ext: 'js', + externalDocs: ['docs/example.md'], + last_modified: 123, + last_author: 'Ada', + churn: 11, + churn_added: 12, + churn_deleted: 13, + churn_commits: 14 + }); +} + +{ + const chunks = [{ + fileId: 7, + file: '', + ext: '', + churn: 0, + churn_added: null, + churn_deleted: undefined, + churn_commits: 0 + }]; + hydrateChunksFromFileMeta(chunks, fileMetaById, { churnAssignment: 'nullish' }); + assert.equal(chunks[0].churn, 0); + assert.equal(chunks[0].churn_added, 12); + assert.equal(chunks[0].churn_deleted, 13); + assert.equal(chunks[0].churn_commits, 0); +} + +{ + const chunks = [{ + fileId: 7, + file: 'already.js', + ext: 'js', + churn: null + }]; + hydrateChunksFromFileMeta(chunks, fileMetaById, { + churnAssignment: 'nullish', + skipWhenFileAndExt: true + }); + assert.deepEqual(chunks[0], { + fileId: 7, + file: 'already.js', + ext: 'js', + churn: null + }); +} + +{ + const idx = { + phraseNgrams: { vocab: ['alpha'] }, + chargrams: { vocab: ['beta'] }, + fieldPostings: { + fields: { + file: { vocab: ['src/example.js'] } + } + } + }; + hydrateSearchIndexPostProcessing(idx, { includeFilterIndex: false }); + assert.deepEqual([...idx.phraseNgrams.vocabIndex], [['alpha', 0]]); + assert.deepEqual([...idx.chargrams.vocabIndex], [['beta', 0]]); + assert.deepEqual([...idx.fieldPostings.fields.file.vocabIndex], [['src/example.js', 0]]); + assert.equal(idx.filterIndex, null); +} + +console.log('retrieval index hydration contract test passed'); diff --git a/tests/retrieval/intent/intent-confidence-calibration.test.js b/tests/retrieval/intent/confidence-calibration.test.js similarity index 100% rename from tests/retrieval/intent/intent-confidence-calibration.test.js rename to tests/retrieval/intent/confidence-calibration.test.js diff --git a/tests/retrieval/output/clean-context-contract-matrix.test.js b/tests/retrieval/output/clean-context-contract-matrix.test.js new file mode 100644 index 000000000..d2fa0ec6f --- /dev/null +++ b/tests/retrieval/output/clean-context-contract-matrix.test.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { cleanContext } from '../../../src/retrieval/output/context.js'; + +const cases = [ + { + name: 'removes fence lines while preserving code', + run() { + const lines = [ + '```ts', + 'const x = 1;', + '```', + '', + 'function test() {}' + ]; + const cleaned = cleanContext(lines); + assert.equal(cleaned.some((line) => line.includes('```')), false); + assert.equal(cleaned.some((line) => line.includes('const x = 1')), true); + } + }, + { + name: 'drops non-string entries safely', + run() { + const cleaned = cleanContext([null, 42, 'ok line', { foo: 'bar' }, '```', 'another line']); + assert.deepEqual(cleaned, ['ok line', 'another line']); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('clean context contract matrix test passed'); diff --git a/tests/retrieval/output/clean-context-fences.test.js b/tests/retrieval/output/clean-context-fences.test.js deleted file mode 100644 index 69206d3c7..000000000 --- a/tests/retrieval/output/clean-context-fences.test.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { cleanContext } from '../../../src/retrieval/output/context.js'; - -const lines = [ - '```ts', - 'const x = 1;', - '```', - '', - 'function test() {}' -]; -const cleaned = cleanContext(lines); -assert(!cleaned.some((line) => line.includes('```')), 'expected fence lines to be removed'); -assert(cleaned.some((line) => line.includes('const x = 1')), 'expected code line to remain'); -console.log('cleanContext fence stripping test passed'); diff --git a/tests/retrieval/output/clean-context-nonstring-guard.test.js b/tests/retrieval/output/clean-context-nonstring-guard.test.js deleted file mode 100644 index c9230a6c5..000000000 --- a/tests/retrieval/output/clean-context-nonstring-guard.test.js +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { cleanContext } from '../../../src/retrieval/output/context.js'; - -const cleaned = cleanContext([null, 42, 'ok line', { foo: 'bar' }, '```', 'another line']); -assert.deepStrictEqual(cleaned, ['ok line', 'another line']); -console.log('cleanContext non-string guard test passed'); diff --git a/tests/retrieval/output/composite-context-pack-contract-matrix.test.js b/tests/retrieval/output/composite-context-pack-contract-matrix.test.js new file mode 100644 index 000000000..f8ab74bf0 --- /dev/null +++ b/tests/retrieval/output/composite-context-pack-contract-matrix.test.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + renderCompositeContextPack, + renderCompositeContextPackJson +} from '../../../src/retrieval/output/composite-context-pack.js'; +import { createRiskWatchStep } from '../../helpers/risk-explanation-fixtures.js'; + +const payload = { + primary: { + ref: { type: 'chunk', chunkUid: 'chunk-risk' }, + file: 'src/app.ts', + excerpt: 'query(req.body);', + evidence: { + state: 'file-backed', + source: 'file-range', + fileBacked: true, + substituted: false, + missing: false, + truncated: false, + truncatedBytes: false, + truncatedTokens: false, + warningCodes: [] + }, + provenance: { + excerptSource: 'repo-range', + excerptHash: 'sha1:excerpt', + excerptBytes: 16 + } + }, + types: { + facts: [{ role: 'return', type: 'Promise' }], + evidence: { + included: true, + state: 'partial', + count: 1, + truncated: true, + warningCodes: ['TYPES_TRUNCATED'] + } + }, + risk: { + status: 'ok', + filters: { + rule: [], + category: [], + severity: [], + tag: [], + source: [], + sink: [], + sourceRule: ['SRC'], + sinkRule: ['SNK'], + flowId: [] + }, + summary: { + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 1 }, + ruleRoles: { sources: 1, sinks: 1, sanitizers: 0 }, + propagatorLikeRoles: [{ role: 'callback', count: 1 }], + topCategories: [{ category: 'injection', count: 1 }], + topTags: [] + }, + provenance: { + generatedAt: '2026-03-12T00:00:00.000Z', + ruleBundle: { + version: '1.0.0', + fingerprint: 'sha1:bundle', + roleModel: { + version: '1.0.0', + directRoles: ['source', 'sink', 'sanitizer'], + propagatorLikeRoles: ['propagator', 'wrapper', 'builder', 'callback', 'asyncHandoff'], + propagatorLikeEncoding: 'watch-semantics' + } + }, + effectiveConfigFingerprint: 'sha1:config' + }, + flows: [ + { + flowId: 'flow-a', + confidence: 0.95, + category: 'injection', + source: { ruleId: 'SRC', ruleRole: 'source', tags: ['input', 'http'] }, + sink: { ruleId: 'SNK', ruleRole: 'sink', tags: ['sql'] }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-risk' }, + { type: 'chunk', chunkUid: 'chunk-sink' } + ], + watchByStep: [createRiskWatchStep()] + }, + evidence: { + callSitesByStep: [[{ + callSiteId: 'cs-1', + details: { + file: 'src/app.ts', + startLine: 14, + startCol: 3, + calleeNormalized: 'query', + args: ['req.body'] + } + }]] + } + } + ], + partialFlows: Array.from({ length: 5 }, (_, index) => ({ + partialFlowId: `partial-${String.fromCharCode(97 + index)}`, + confidence: 0.61 + (index * 0.01), + source: { ruleId: 'SRC', chunkUid: 'chunk-risk' }, + frontier: { + chunkUid: index === 4 ? 'chunk-tail' : 'chunk-mid', + terminalReason: 'maxDepth', + blockedExpansions: [ + { + reason: 'maxEdgeExpansions', + targetChunkUid: 'chunk-sink', + callSiteIds: ['cs-1'] + } + ] + }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-risk' }, + { type: 'chunk', chunkUid: index === 4 ? 'chunk-tail' : 'chunk-mid' } + ], + callSiteIdsByStep: [['cs-1']], + watchByStep: [createRiskWatchStep()] + }, + evidence: { + callSitesByStep: [[{ + callSiteId: 'cs-1', + details: { + file: 'src/app.ts', + startLine: 14, + startCol: 3, + calleeNormalized: 'query', + args: ['req.body'] + } + }]] + }, + notes: { + terminalReason: 'maxDepth', + hopCount: 1, + capsHit: ['maxDepth'] + } + })), + truncation: [{ cap: 'maxFlows', limit: 5, observed: 6, omitted: 1 }], + analysisStatus: { + status: 'ok', + code: 'ok', + degradedReasons: [] + } + }, + evidence: { + schemaVersion: 1, + policy: { strictEvidence: true }, + primary: { + state: 'file-backed', + source: 'file-range', + fileBacked: true, + substituted: false, + missing: false, + truncated: false, + truncatedBytes: false, + truncatedTokens: false, + warningCodes: [] + }, + types: { + included: true, + state: 'partial', + count: 1, + truncated: true, + warningCodes: ['TYPES_TRUNCATED'] + }, + complete: false + }, + truncation: [{ cap: 'maxBytes', limit: 2048, observed: 4096, omitted: 2048 }], + warnings: [{ code: 'PACK_WARN', message: 'warning emitted' }] +}; + +const cases = [ + { + name: 'markdown render includes pack evidence, truncation, warnings, and risk sections', + run() { + const markdown = renderCompositeContextPack(payload); + assert.match(markdown, /Primary/); + assert.match(markdown, /Provenance: source=repo-range, hash=sha1:excerpt, bytes=16/); + assert.match(markdown, /Risk/); + assert.match(markdown, /summary: sources 1, sinks 1, sanitizers 0, localFlows 1/); + assert.match(markdown, /Partial Risk Flows/); + assert.match(markdown, /partial-e/); + assert.match(markdown, /Evidence\nPrimary: file-backed \(file-range\)/); + assert.match(markdown, /Types: partial \(count=1\)/); + assert.match(markdown, /strict evidence policy enabled/); + assert.match(markdown, /Truncation\n- maxBytes limit=2048 observed=4096 omitted=2048/); + assert.match(markdown, /Warnings\n- PACK_WARN: warning emitted/); + } + }, + { + name: 'JSON render preserves pack evidence and risk detail contracts', + run() { + const jsonPayload = renderCompositeContextPackJson(payload); + assert.deepEqual(jsonPayload.rendered.evidence, payload.evidence); + assert.deepEqual(jsonPayload.rendered.truncation, payload.truncation); + assert.deepEqual(jsonPayload.rendered.warnings, payload.warnings); + assert.equal(jsonPayload.rendered.risk.subject.chunkUid, 'chunk-risk'); + assert.equal(jsonPayload.rendered.risk.subject.file, 'src/app.ts'); + assert.deepEqual(jsonPayload.rendered.risk.summary.ruleRoles, { sources: 1, sinks: 1, sanitizers: 0 }); + assert.deepEqual(jsonPayload.rendered.risk.summary.propagatorLikeRoles, [{ role: 'callback', count: 1 }]); + assert.equal(jsonPayload.rendered.risk.flowSelection.totalFlows, 1); + assert.equal(jsonPayload.rendered.risk.flows[0].flowId, 'flow-a'); + assert.equal(jsonPayload.rendered.risk.flows[0].source.ruleRole, 'source'); + assert.deepEqual(jsonPayload.rendered.risk.flows[0].source.tags, ['input', 'http']); + assert.equal(jsonPayload.rendered.risk.flows[0].sink.ruleRole, 'sink'); + assert.deepEqual(jsonPayload.rendered.risk.flows[0].sink.tags, ['sql']); + assert.equal(jsonPayload.rendered.risk.flows[0].steps[0].watchWindow.calleeNormalized, 'query'); + assert.deepEqual(jsonPayload.rendered.risk.flows[0].steps[0].watchWindow.semanticIds, ['sem.callback.register-handler-payload']); + assert.deepEqual(jsonPayload.rendered.risk.flows[0].steps[0].watchWindow.semanticKinds, ['callback']); + assert.equal(jsonPayload.rendered.risk.partialFlowSelection.totalPartialFlows, 5); + assert.equal(jsonPayload.rendered.risk.partialFlowSelection.shownPartialFlows, 5); + assert.equal(jsonPayload.rendered.risk.partialFlowSelection.maxPartialFlows, 5); + assert.equal(jsonPayload.rendered.risk.partialFlows.length, 5); + assert.equal(jsonPayload.rendered.risk.partialFlows[4].partialFlowId, 'partial-e'); + assert.equal(jsonPayload.rendered.risk.partialFlows[0].steps[0].watchWindow.calleeNormalized, 'query'); + assert.deepEqual(jsonPayload.rendered.risk.partialFlows[0].steps[0].watchWindow.semanticIds, ['sem.callback.register-handler-payload']); + assert.deepEqual(jsonPayload.rendered.risk.partialFlows[0].steps[0].watchWindow.semanticKinds, ['callback']); + assert.equal(jsonPayload.rendered.risk.filters.sourceRule[0], 'SRC'); + assert.equal(jsonPayload.rendered.sarif.runs[0].automationDetails.id, 'context-pack'); + assert.equal(jsonPayload.rendered.risk.provenance.ruleBundle.roleModel.propagatorLikeEncoding, 'watch-semantics'); + assert.equal(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.packWarnings[0].code, 'PACK_WARN'); + assert.equal(jsonPayload.rendered.sarif.runs[0].results[0].partialFingerprints.pairOfCleatsFlowId, 'flow-a'); + } + }, + { + name: 'SARIF export carries provenance, pack truncation, and partial-flow metadata', + run() { + const jsonPayload = renderCompositeContextPackJson(payload); + assert.ok(jsonPayload.rendered.sarif); + assert.equal(jsonPayload.rendered.sarif.runs[0].results.length, 1); + assert.equal( + jsonPayload.rendered.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0] + .location.physicalLocation.artifactLocation.uri, + 'src/app.ts' + ); + assert.deepEqual(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.provenance, payload.risk.provenance); + assert.equal(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.packProvenance, null); + assert.deepEqual(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.packTruncation, payload.truncation); + assert.equal(jsonPayload.rendered.sarif.runs[0].results[0].properties.pairOfCleats.flowId, 'flow-a'); + assert.equal( + jsonPayload.rendered.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.calleeNormalized, + 'query' + ); + assert.deepEqual( + jsonPayload.rendered.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.semanticIds, + ['sem.callback.register-handler-payload'] + ); + assert.deepEqual( + jsonPayload.rendered.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.semanticKinds, + ['callback'] + ); + assert.equal(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.partialFlowSelection.totalPartialFlows, 5); + assert.equal(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.partialFlows[0].partialFlowId, 'partial-a'); + assert.equal(jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].calleeNormalized, 'query'); + assert.deepEqual( + jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].semanticIds, + ['sem.callback.register-handler-payload'] + ); + assert.deepEqual( + jsonPayload.rendered.sarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].semanticKinds, + ['callback'] + ); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('composite context pack contract matrix test passed'); diff --git a/tests/retrieval/output/diagnostics-footer.test.js b/tests/retrieval/output/diagnostics-footer.test.js new file mode 100644 index 000000000..e12786e94 --- /dev/null +++ b/tests/retrieval/output/diagnostics-footer.test.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { + captureSearchOutputStreams, + createSearchOutputOptions +} from '../helpers/search-output-fixture.js'; + +applyTestEnv(); + +const captured = await withTemporaryEnv({ COLUMNS: '104' }, async () => await captureSearchOutputStreams(async () => { + renderSearchOutput(createSearchOutputOptions({ + emitOutput: true, + jsonOutput: false, + jsonCompact: false, + explain: false, + color, + backendLabel: 'sqlite', + backendPolicyInfo: null, + routingPolicy: null, + topN: 3, + highlightRegex: /alpha/g, + annBackend: 'js', + vectorExtension: { annMode: 'dense', provider: null, table: null }, + modelIds: { code: null, prose: null, extractedProse: null, records: null }, + embeddingProvider: null, + embeddingOnnx: { modelPath: null, tokenizerId: null }, + profileInfo: { + warnings: [ + 'Sparse-only request overridden for vector_only mode(s): code. ANN fallback was used.', + '[ops-resource] code=op_resource_retrieval_memory_growth_abnormal component=retrieval metric=rss baselineMiB=64.0 currentMiB=160.0 deltaMiB=96.0 ratio=2.50 next=\"Inspect retrieval memory growth.\"' + ] + }, + intentInfo: null, + resolvedDenseVectorMode: 'merged', + contextExpansionStats: { enabled: false }, + elapsedMs: 12 + })); +})); + +const cleanStdout = stripAnsi(captured.stdout); +assert.match(cleanStdout, /Diagnostics \(2\) ─/); +assert.match(cleanStdout, /! Sparse-only request overridden/); +assert.match(cleanStdout, /! Resource: component retrieval • metric rss • growth 2.50x • delta \+96.0 MiB/); +assert.match(cleanStdout, /64.0 MiB -> 160.0 MiB/); +assert.match(cleanStdout, /next Inspect retrieval memory growth\./); +assert.doesNotMatch(cleanStdout, /\[ops-resource\]/); +assert.equal(captured.stderr.trim(), '', 'expected diagnostics footer to stay on stdout in human mode'); + +console.log('diagnostics footer test passed'); diff --git a/tests/retrieval/output/explain-color-fallback.test.js b/tests/retrieval/output/explain-color-fallback.test.js deleted file mode 100644 index 8f1c79614..000000000 --- a/tests/retrieval/output/explain-color-fallback.test.js +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { formatScoreBreakdown } from '../../../src/retrieval/output/explain.js'; - -const breakdown = { - selected: { type: 'bm25', score: 1.23 } -}; -const lines = formatScoreBreakdown(breakdown, {}); -assert(lines.length === 1, 'expected a score breakdown line'); -assert(lines[0].includes('Scores'), 'expected score label without color dependency'); -console.log('explain color fallback test passed'); diff --git a/tests/retrieval/output/explain-output-includes-routing-and-fts-match.test.js b/tests/retrieval/output/explain-includes-routing-and-fts-match.test.js similarity index 100% rename from tests/retrieval/output/explain-output-includes-routing-and-fts-match.test.js rename to tests/retrieval/output/explain-includes-routing-and-fts-match.test.js diff --git a/tests/retrieval/output/explain-tier-render.test.js b/tests/retrieval/output/explain-tier-render.test.js new file mode 100644 index 000000000..54de12719 --- /dev/null +++ b/tests/retrieval/output/explain-tier-render.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { formatFullChunk } from '../../../src/retrieval/output/format/full.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const chunk = { + file: 'src/retrieval/output/explain.js', + name: 'formatScoreBreakdown', + kind: 'Function', + start: 0, + end: 1, + startLine: 159, + endLine: 224, + last_modified: '2026-02-17T19:53:00.000Z', + chunk_authors: ['doublemover', '2xmvr'], + last_author: '2xmvr', + docmeta: { + signature: 'formatScoreBreakdown(scoreBreakdown, color)', + commentExcerpt: 'Parse trust/confidence surface while ignoring unknown forward fields.', + inferredTypes: { + params: { + scoreBreakdown: [{ type: 'any', source: 'tooling', confidence: 0.5 }] + }, + locals: { + parts: [{ type: 'array', source: 'literal', confidence: 0.6 }] + } + }, + dataflow: { + reads: ['scoreBreakdown', 'selected', 'Number', 'color', 'formatScorePiece'], + writes: ['parts', 'selected', 'entry', 'piece', 'sparse', 'ann'], + aliases: ['selected->entry'] + } + }, + codeRelations: { + calls: [ + ['formatScoreBreakdown', 'entry.push'], + ['formatScoreBreakdown', 'entry.push'], + ['formatScoreBreakdown', 'formatScorePiece'] + ], + callSummaries: [ + { name: 'entry.push', args: ['selected.type'], returnTypes: [] } + ] + }, + scoreBreakdown: { + selected: { type: 'fts', score: 15.985 }, + sparse: { type: 'fts', score: 13.321 }, + symbol: { definition: true, export: false, factor: 1.2 } + } +}; + +const summary = stripAnsi(formatFullChunk({ + chunk, + index: 0, + mode: 'code', + score: 1, + scoreType: 'fts', + explain: true, + explainTier: 'summary', + color, + layout: { columns: 72, contentWidth: 68, cacheKey: 'cols:72' }, + _skipCache: true +})); + +const full = stripAnsi(formatFullChunk({ + chunk, + index: 0, + mode: 'code', + score: 1, + scoreType: 'fts', + explain: true, + explainTier: 'full', + color, + layout: { columns: 72, contentWidth: 68, cacheKey: 'cols:72' }, + _skipCache: true +})); + +assert.match(summary, /Scores: Score=fts,15\.985/); +assert.match(summary, /Reads:/); +assert.doesNotMatch(summary, /Authors:/); +assert.doesNotMatch(summary, /Inferred Locals:/); +assert.doesNotMatch(summary, /Aliases:/); + +assert.match(full, /Authors:/); +assert.match(full, /Inferred Locals:/); +assert.match(full, /Aliases:/); +assert.match(full, /Call Summary:/); + +console.log('explain tier render test passed'); diff --git a/tests/retrieval/output/explain-vector-only-warnings.test.js b/tests/retrieval/output/explain-vector-only-warnings.test.js index d262d6c91..5e2ef1187 100644 --- a/tests/retrieval/output/explain-vector-only-warnings.test.js +++ b/tests/retrieval/output/explain-vector-only-warnings.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { renderSearchOutputForTest } from '../helpers/search-output-fixture.js'; const profileInfo = { byMode: { @@ -16,95 +16,14 @@ const profileInfo = { ] }; -const payload = renderSearchOutput({ - emitOutput: false, - jsonOutput: true, - jsonCompact: true, - explain: true, - color: {}, - rootDir: process.cwd(), - backendLabel: 'memory', - backendPolicyInfo: { backendLabel: 'memory', reason: 'test' }, +const payload = renderSearchOutputForTest({ routingPolicy: { byMode: { code: { desired: 'sparse', reason: 'test' } } }, - runCode: true, - runProse: false, - runExtractedProse: false, - runRecords: false, - topN: 5, queryTokens: ['alpha'], - highlightRegex: null, - contextExpansionEnabled: false, - expandedHits: { - prose: { hits: [], contextHits: [] }, - extractedProse: { hits: [], contextHits: [] }, - code: { hits: [], contextHits: [] }, - records: { hits: [], contextHits: [] } - }, - baseHits: { - proseHits: [], - extractedProseHits: [], - codeHits: [], - recordHits: [] - }, annEnabled: true, annActive: true, annBackend: 'js', - vectorExtension: { annMode: 'none', provider: 'none', table: null }, - vectorAnnEnabled: false, - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswConfig: { enabled: false }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnState: { - code: { available: false, metric: null }, - prose: { available: false, metric: null }, - records: { available: false, metric: null }, - 'extracted-prose': { available: false, metric: null } - }, - modelIds: { - code: 'test-model', - prose: 'test-model', - extractedProse: 'test-model', - records: 'test-model' - }, - embeddingProvider: 'stub', - embeddingOnnx: {}, - cacheInfo: { enabled: false, hit: false, key: null }, profileInfo, - intentInfo: { type: 'keyword' }, - resolvedDenseVectorMode: 'auto', - fieldWeights: null, - contextExpansionStats: { - enabled: false, - code: { added: 0, workUnitsUsed: 0, truncation: null }, - prose: { added: 0, workUnitsUsed: 0, truncation: null }, - 'extracted-prose': { added: 0, workUnitsUsed: 0, truncation: null }, - records: { added: 0, workUnitsUsed: 0, truncation: null } - }, - idxProse: { chunkMeta: [] }, - idxExtractedProse: { chunkMeta: [] }, - idxCode: { chunkMeta: [] }, - idxRecords: { chunkMeta: [] }, - showStats: false, - showMatched: false, - verboseCache: false, - elapsedMs: 5, - stageTracker: null + intentInfo: { type: 'keyword' } }); assert.equal(payload?.stats?.profile?.byMode?.code?.profileId, 'vector_only'); diff --git a/tests/retrieval/output/format-responsive-layout.test.js b/tests/retrieval/output/format-responsive-layout.test.js new file mode 100644 index 000000000..25df6e7be --- /dev/null +++ b/tests/retrieval/output/format-responsive-layout.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { formatShortChunk } from '../../../src/retrieval/output/format.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const chunk = { + file: 'src/retrieval/cli/render.js', + name: 'renderSearchOutput', + kind: 'Function', + start: 0, + end: 1, + startLine: 20, + endLine: 60, + last_modified: '2026-03-28T11:00:00.000Z', + headline: 'renderSearchOutput emits JSON and human-facing search output.', + docmeta: { + signature: 'renderSearchOutput(options)' + } +}; + +const narrow = formatShortChunk({ + chunk, + index: 1, + mode: 'code', + score: 0.9, + scoreType: 'rrf', + explain: false, + color, + queryTokens: ['renderSearchOutput'], + rx: /renderSearchOutput/g, + matched: false, + layout: { columns: 72, isNarrow: true, cacheKey: 'cols:72' }, + _skipCache: true +}); + +const wide = formatShortChunk({ + chunk, + index: 1, + mode: 'code', + score: 0.9, + scoreType: 'rrf', + explain: false, + color, + queryTokens: ['renderSearchOutput'], + rx: /renderSearchOutput/g, + matched: false, + hyperlinkMode: 'file', + rootDir: 'C:\\Users\\sneak\\Development\\DOUBLECLEAT', + layout: { columns: 188, isNarrow: false, cacheKey: 'cols:188' }, + _skipCache: true +}); + +assert.notEqual(narrow, wide, 'expected width-specific rendering to differ'); +assert.match(narrow, /\n .*src\/retrieval\/cli\/render\.js/u); +assert.match(stripAnsi(wide), /\n .*src\/retrieval\/cli\/render\.js.*(?:just now|ago|AM|PM)/u); +assert.match(stripAnsi(wide), /\n .*renderSearchOutput\(options\)/u); +assert.doesNotMatch(stripAnsi(narrow), /src\/retrieval\/cli\/render\.js.* • .*?(?:just now|ago|AM|PM)/u); +assert.doesNotMatch(stripAnsi(wide), /\n\s*\n/u, 'expected compact formatter output to avoid blank spacer lines'); + +console.log('responsive short-format layout test passed'); diff --git a/tests/retrieval/output/human-render-layout.test.js b/tests/retrieval/output/human-render-layout.test.js new file mode 100644 index 000000000..e79973cd7 --- /dev/null +++ b/tests/retrieval/output/human-render-layout.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { + captureSearchOutputStreams, + createSearchOutputHitState, + createSearchOutputOptions +} from '../helpers/search-output-fixture.js'; + +applyTestEnv(); + +const codeHits = [ + { + file: 'src/retrieval/cli/render.js', + name: 'renderSearchOutput', + kind: 'Function', + start: 0, + end: 1, + startLine: 20, + endLine: 60, + score: 0.92, + scoreType: 'rrf', + docmeta: { + signature: 'renderSearchOutput(options)', + returnsValue: true + } + } +]; + +const proseHits = [ + { + file: 'docs/guides/search.md', + name: 'Search Pipeline', + kind: 'Section', + start: 0, + end: 1, + startLine: 12, + endLine: 20, + score: 0.81, + scoreType: 'bm25', + headline: 'Search Pipeline covers indexing, ranking, and result rendering.' + } +]; + +const captured = await withTemporaryEnv({ COLUMNS: '72' }, async () => await captureSearchOutputStreams(async () => { + renderSearchOutput(createSearchOutputOptions({ + emitOutput: true, + jsonOutput: false, + jsonCompact: false, + explain: false, + color, + backendLabel: 'sqlite', + backendPolicyInfo: null, + routingPolicy: null, + runCode: true, + runProse: true, + runExtractedProse: true, + runRecords: true, + topN: 3, + queryTokens: ['renderSearchOutput'], + highlightRegex: /renderSearchOutput/g, + ...createSearchOutputHitState({ proseHits, codeHits }), + annBackend: 'js', + vectorExtension: { annMode: 'dense', provider: null, table: null }, + modelIds: { code: null, prose: null, extractedProse: null, records: null }, + embeddingProvider: null, + embeddingOnnx: { modelPath: null, tokenizerId: null }, + intentInfo: null, + resolvedDenseVectorMode: 'merged', + contextExpansionStats: { enabled: false }, + elapsedMs: 17 + })); +})); + +const cleanStdout = stripAnsi(captured.stdout); + +assert.match(cleanStdout, /Search Results/); +assert.match(cleanStdout, /^Search Results\s+elapsed 17ms$/m); +assert.match(cleanStdout, /^query renderSearchOutput$/m); +assert.match(cleanStdout, /Code Results \(1\)/); +assert.match(cleanStdout, /^Code Results \(1\) ─/m); +assert.match(cleanStdout, /Text Results \(1\)/); +assert.doesNotMatch(cleanStdout, /Code Comments Results/); +assert.match(cleanStdout, /backend sqlite/); +assert.match(cleanStdout, /hits 1 code/); +assert.doesNotMatch(cleanStdout, /modes .*records/u, 'expected header modes to omit suppressed empty sections'); +assert.equal(captured.stderr.trim(), '', 'expected diagnostics stderr to remain empty for normal human output'); + +console.log('human render layout test passed'); diff --git a/tests/retrieval/output/json-diagnostics-routing.test.js b/tests/retrieval/output/json-diagnostics-routing.test.js new file mode 100644 index 000000000..850446f92 --- /dev/null +++ b/tests/retrieval/output/json-diagnostics-routing.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + captureSearchOutputStreams, + createSearchOutputHitState, + createSearchOutputOptions +} from '../helpers/search-output-fixture.js'; + +applyTestEnv(); + +const codeHit = { + id: 1, + file: 'src/a.js', + start: 0, + end: 1, + score: 1, + scoreType: 'bm25' +}; + +const sharedInput = createSearchOutputOptions({ + emitOutput: true, + jsonOutput: true, + jsonCompact: false, + explain: false, + color, + backendPolicyInfo: null, + routingPolicy: null, + topN: 1, + highlightRegex: /alpha/g, + ...createSearchOutputHitState({ codeHits: [codeHit] }), + annBackend: 'js', + vectorExtension: { annMode: 'dense', provider: null, table: null }, + modelIds: { code: null, prose: null, extractedProse: null, records: null }, + embeddingProvider: null, + embeddingOnnx: { modelPath: null, tokenizerId: null }, + profileInfo: { + warnings: [ + 'Sparse-only request overridden for vector_only mode(s): code. ANN fallback was used.', + '[ops-resource] code=op_resource_retrieval_memory_growth_abnormal component=retrieval metric=rss baselineMiB=64.0 currentMiB=160.0 deltaMiB=96.0 ratio=2.50 next="Inspect retrieval memory growth."' + ] + }, + intentInfo: null, + resolvedDenseVectorMode: 'merged', + contextExpansionStats: { enabled: false }, + idxProse: null, + idxExtractedProse: null, + idxRecords: null, + showStats: true, + elapsedMs: 5 +}); + +for (const streamJson of [false, true]) { + const captured = await captureSearchOutputStreams(async () => { + renderSearchOutput({ + ...sharedInput, + streamJson + }); + }); + assert.equal(captured.stderr.trim(), '', `expected stderr to stay empty for jsonOutput streamJson=${streamJson}`); + assert.doesNotMatch(captured.stdout, /\[ops-resource\]/u, 'expected raw diagnostics to stay out of JSON stdout'); + const parsed = JSON.parse(captured.stdout); + assert.equal(parsed.backend, 'memory'); + assert.equal(parsed.code.length, 1); + assert.equal(Array.isArray(parsed.stats?.profile?.warnings), true, 'expected structured warnings to remain in JSON payload'); +} + +console.log('json diagnostics routing test passed'); diff --git a/tests/retrieval/output/osc8-hyperlinks.test.js b/tests/retrieval/output/osc8-hyperlinks.test.js new file mode 100644 index 000000000..6df43d370 --- /dev/null +++ b/tests/retrieval/output/osc8-hyperlinks.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildFileHyperlink, + hyperlinkFileLabel, + resolveHyperlinkMode +} from '../../../src/retrieval/output/format/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; + +const fakeTty = { isTTY: true }; +const fakePipe = { isTTY: false }; + +assert.equal( + resolveHyperlinkMode({ configuredMode: 'off', stdout: fakeTty }), + 'off', + 'expected explicit config off override' +); +assert.equal( + resolveHyperlinkMode({ configuredMode: 'auto', env: { TERM_PROGRAM: 'vscode' }, stdout: fakeTty }), + 'vscode', + 'expected VS Code terminals to prefer vscode hyperlinks' +); +assert.equal( + resolveHyperlinkMode({ env: {}, stdout: fakePipe }), + 'off', + 'expected non-tty output to disable hyperlinks by default' +); + +const fileHref = buildFileHyperlink({ + filePath: 'src/app.js', + line: 12, + rootDir: 'C:\\Users\\sneak\\Development\\DOUBLECLEAT', + mode: 'file' +}); +assert.match(fileHref, /^file:\/\/\/C:\/Users\/sneak\/Development\/DOUBLECLEAT\/src\/app\.js$/u); + +const vscodeHref = buildFileHyperlink({ + filePath: 'src/app.js', + line: 12, + column: 3, + rootDir: 'C:\\Users\\sneak\\Development\\DOUBLECLEAT', + mode: 'vscode' +}); +assert.equal( + vscodeHref, + 'vscode://file/C:/Users/sneak/Development/DOUBLECLEAT/src/app.js:12:3' +); + +const linked = hyperlinkFileLabel({ + label: 'src/app.js', + filePath: 'src/app.js', + line: 12, + rootDir: 'C:\\Users\\sneak\\Development\\DOUBLECLEAT', + mode: 'file' +}); +assert.match(linked, /\x1b\]8;;file:\/\/\/C:\/Users\/sneak\/Development\/DOUBLECLEAT\/src\/app\.js\x1b\\/u); +assert.match(linked, /src\/app\.js/u); +assert.match(linked, /\x1b\]8;;\x1b\\/u); +assert.equal(stripAnsi(linked), 'src/app.js', 'expected visible-width stripping to remove OSC8 escapes'); + +console.log('osc8 hyperlink helpers test passed'); diff --git a/tests/retrieval/output/prose-comment-record-rendering.test.js b/tests/retrieval/output/prose-comment-record-rendering.test.js new file mode 100644 index 000000000..7692c4864 --- /dev/null +++ b/tests/retrieval/output/prose-comment-record-rendering.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { formatFullChunk } from '../../../src/retrieval/output/format/full.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const prose = stripAnsi(formatFullChunk({ + chunk: { + file: 'docs/guides/search.md', + name: 'Search Pipeline', + kind: 'Section', + start: 0, + end: 1, + startLine: 1, + endLine: 4, + headline: 'Search Pipeline' + }, + index: 0, + mode: 'prose', + color, + layout: { columns: 88, contentWidth: 84, cacheKey: 'cols:88' }, + _skipCache: true +})); + +const extracted = stripAnsi(formatFullChunk({ + chunk: { + file: 'src/shared/identity.js', + start: 0, + end: 1, + startLine: 31, + endLine: 35, + headline: 'chunk ref build record param null memory' + }, + index: 0, + mode: 'extracted-prose', + color, + layout: { columns: 88, contentWidth: 84, cacheKey: 'cols:88' }, + _skipCache: true +})); + +const record = stripAnsi(formatFullChunk({ + chunk: { + file: 'tests/fixtures/bench-runtime-canaries/gopls-blocked-partitions.log', + name: 'gopls-blocked-partitions', + kind: 'Record', + start: 0, + end: 1, + startLine: 1, + endLine: 3, + docmeta: { + doc: 'Language-server startup failed in a blocked partition canary.', + record: { + recordType: 'log', + status: 'open' + } + } + }, + index: 0, + mode: 'records', + color, + layout: { columns: 88, contentWidth: 84, cacheKey: 'cols:88' }, + _skipCache: true +})); + +assert.match(prose, /\[section\] Search Pipeline/); +assert.match(extracted, /\[keywords\] chunk ref build record param null memory/); +assert.match(record, /\[type=log\] \[status=open\]/); +assert.match(record, /\[summary\] Language-server startup failed in a blocked partition canary\./); + +console.log('prose/comment/record rendering test passed'); diff --git a/tests/retrieval/output/raw-query-header.test.js b/tests/retrieval/output/raw-query-header.test.js new file mode 100644 index 000000000..6d8092bdd --- /dev/null +++ b/tests/retrieval/output/raw-query-header.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; +import { color } from '../../../src/retrieval/cli/ansi.js'; +import { stripAnsi } from '../../../src/shared/cli/ansi-utils.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { + captureSearchOutputStdout, + createSearchOutputHitState, + createSearchOutputOptions +} from '../helpers/search-output-fixture.js'; + +applyTestEnv(); + +const proseHit = { + file: 'docs/guides/search.md', + start: 0, + end: 1, + startLine: 10, + endLine: 18, + name: 'Search Pipeline', + kind: 'Section', + score: 1 +}; + +const output = await withTemporaryEnv({ COLUMNS: '88' }, async () => await captureSearchOutputStdout(async () => { + renderSearchOutput(createSearchOutputOptions({ + emitOutput: true, + jsonOutput: false, + jsonCompact: false, + explain: false, + color, + backendLabel: 'sqlite-fts', + backendPolicyInfo: null, + routingPolicy: null, + runCode: false, + runProse: true, + topN: 1, + rawQuery: '"Search Pipeline"', + queryTokens: ['search', 'pipeline'], + highlightRegex: /Search Pipeline/giu, + ...createSearchOutputHitState({ proseHits: [proseHit] }), + annBackend: 'js', + vectorExtension: { annMode: 'dense', provider: null, table: null }, + modelIds: { code: null, prose: null, extractedProse: null, records: null }, + embeddingProvider: null, + embeddingOnnx: { modelPath: null, tokenizerId: null }, + intentInfo: null, + resolvedDenseVectorMode: 'merged', + contextExpansionStats: { enabled: false }, + elapsedMs: 11 + })); +})); + +assert.match(stripAnsi(output), /^query "Search Pipeline"$/m, 'expected header to preserve the raw query text'); + +console.log('raw query header test passed'); diff --git a/tests/retrieval/output/retrieval-metadata.test.js b/tests/retrieval/output/retrieval-metadata.test.js new file mode 100644 index 000000000..751d7469d --- /dev/null +++ b/tests/retrieval/output/retrieval-metadata.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildRetrievalMetadata } from '../../../src/retrieval/output/retrieval-metadata.js'; + +const metadata = buildRetrievalMetadata({ + backendLabel: 'memory', + backendPolicyInfo: { + requested: 'auto', + defaultBackend: 'sqlite', + reason: 'sqlite unavailable; using memory', + backendDisabled: false, + backendForcedSqlite: false, + backendForcedLmdb: false, + backendForcedMemory: false, + backendForcedTantivy: false + }, + cacheInfo: { + hit: true, + strategy: 'disk-first', + memoryHotPath: false + }, + idxCode: { + state: { + buildId: 'state-code-build', + artifactSurfaceVersion: 'surface-state', + profile: { id: 'default' } + } + }, + idxProse: { + state: { + buildId: 'state-prose-build', + artifactSurfaceVersion: 'surface-prose', + profile: { id: 'vector-only' } + } + }, + indexSignaturePayload: { + modes: { + code: 'code-signature', + prose: 'prose-signature' + }, + generationByMode: { + code: { + buildId: 'payload-code-build', + artifactSurfaceVersion: 'surface-code', + generationKey: 'gen-code', + activeBuildRoot: '/tmp/builds/code' + } + } + }, + generationContext: { + buildId: 'active-build', + activeBuildRoot: '/tmp/builds/active', + buildGenerationKey: 'active-generation-key' + }, + asOfContext: { + ref: 'HEAD~1', + type: 'commit', + identityHash: 'abc123' + } +}); + +assert.equal(metadata.backend.selected, 'memory'); +assert.equal(metadata.backend.requested, 'auto'); +assert.equal(metadata.backend.defaultBackend, 'sqlite'); +assert.equal(metadata.backend.selectionKind, 'policy'); +assert.equal(metadata.backend.selectionReason, 'sqlite unavailable; using memory'); +assert.equal(metadata.backend.availabilityDriven, true); +assert.equal(metadata.fidelity.status, 'fallback-derived'); +assert.equal(metadata.fidelity.fallbackDerived, true); +assert.equal(metadata.cache.hit, true); +assert.equal(metadata.cache.strategy, 'disk-first'); +assert.equal(metadata.freshness.activeGeneration.buildId, 'active-build'); +assert.equal(metadata.freshness.activeGeneration.activeBuildRoot, '/tmp/builds/active'); +assert.equal(metadata.freshness.activeGeneration.buildGenerationKey, 'active-generation-key'); +assert.equal(metadata.freshness.byMode.code.buildId, 'payload-code-build'); +assert.equal(metadata.freshness.byMode.code.artifactSurfaceVersion, 'surface-code'); +assert.equal(metadata.freshness.byMode.code.generationKey, 'gen-code'); +assert.equal(metadata.freshness.byMode.code.activeBuildRoot, '/tmp/builds/code'); +assert.equal(metadata.freshness.byMode.code.profileId, 'default'); +assert.equal(metadata.freshness.byMode.code.signature, 'code-signature'); +assert.equal(metadata.freshness.byMode.prose.buildId, 'state-prose-build'); +assert.equal(metadata.freshness.byMode.prose.signature, 'prose-signature'); +assert.equal(metadata.freshness.asOf.ref, 'HEAD~1'); +assert.equal(metadata.freshness.asOf.identityHash, 'abc123'); + +console.log('retrieval metadata contract ok'); diff --git a/tests/retrieval/output/risk-explain-render.test.js b/tests/retrieval/output/risk-explain-render.test.js deleted file mode 100644 index 2fd059677..000000000 --- a/tests/retrieval/output/risk-explain-render.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { renderRiskExplain } from '../../../src/retrieval/output/risk-explain.js'; - -const flows = [ - { - flowId: 'flow-1', - confidence: 0.88, - category: 'injection', - source: { ruleId: 'SRC1' }, - sink: { ruleId: 'SNK1' }, - path: { - nodes: [ - { type: 'chunk', chunkUid: 'chunk-a' }, - { type: 'chunk', chunkUid: 'chunk-b' } - ], - callSiteIdsByStep: [["cs-1", "cs-2"]] - } - } -]; - -const output = renderRiskExplain(flows, { maxFlows: 1, maxEvidencePerFlow: 2 }); -assert(output.includes('flow-1'), 'expected flow id in output'); -assert(output.includes('chunk:chunk-a'), 'expected path nodes in output'); -assert(output.includes('cs-1'), 'expected call site evidence in output'); -console.log('risk explain render test passed'); diff --git a/tests/retrieval/output/risk-explanation-contract-matrix.test.js b/tests/retrieval/output/risk-explanation-contract-matrix.test.js new file mode 100644 index 000000000..1c5eb309d --- /dev/null +++ b/tests/retrieval/output/risk-explanation-contract-matrix.test.js @@ -0,0 +1,476 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { renderCompositeContextPack } from '../../../src/retrieval/output/composite-context-pack.js'; +import { formatScoreBreakdown } from '../../../src/retrieval/output/explain.js'; +import { + buildRiskExplanationModelFromRiskSlice, + buildRiskExplanationModelFromStandalone, + buildRiskExplanationPresentationFromRiskSlice, + buildRiskExplanationPresentationFromStandalone, + getRiskExplanationSurfaceOptions, + renderRiskExplain, + renderRiskExplanation, + renderRiskExplanationJson +} from '../../../src/retrieval/output/risk-explain.js'; +import { renderRiskExplanationSarif } from '../../../src/retrieval/output/risk-sarif.js'; +import { + createCappedRiskSliceInput, + createFullRiskSliceInput, + createFullRiskStandaloneInput, + createMinimalRiskStandaloneInput +} from '../../helpers/risk-explanation-fixtures.js'; + +const cases = [ + { + name: 'score breakdown formatting works without color helpers', + run() { + const fallbackLines = formatScoreBreakdown({ + selected: { type: 'bm25', score: 1.23 } + }, {}); + assert.equal(fallbackLines.length, 1); + assert.match(fallbackLines[0], /Scores/); + } + }, + { + name: 'surface defaults and subject visibility remain stable', + run() { + const standaloneDefaults = getRiskExplanationSurfaceOptions('standalone'); + assert.equal(standaloneDefaults.title, 'Risk Explain'); + assert.equal(standaloneDefaults.includeSubject, true); + assert.equal(standaloneDefaults.includeFilters, true); + assert.equal(standaloneDefaults.maxFlows, 20); + assert.equal(standaloneDefaults.maxPartialFlows, 20); + assert.equal(standaloneDefaults.maxEvidencePerFlow, 20); + + const contextPackDefaults = getRiskExplanationSurfaceOptions('contextPack'); + assert.equal(contextPackDefaults.title, 'Risk'); + assert.equal(contextPackDefaults.includeSubject, false); + assert.equal(contextPackDefaults.includeAnchor, false); + assert.equal(contextPackDefaults.includeFilters, true); + assert.equal(contextPackDefaults.maxFlows, 5); + assert.equal(contextPackDefaults.maxPartialFlows, 5); + assert.equal(contextPackDefaults.maxEvidencePerFlow, 3); + + const standalonePresentation = buildRiskExplanationPresentationFromStandalone({ + chunk: { chunkUid: 'chunk-risk', file: 'src/app.ts', name: 'risky', kind: 'function' }, + summary: { + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 0 }, + signals: { sources: [], sinks: [], sanitizers: [], localFlows: [] } + }, + stats: { + status: 'ok', + counts: { flowsEmitted: 1, partialFlowsEmitted: 0, summariesEmitted: 1, uniqueCallSitesReferenced: 1 }, + capsHit: [] + }, + flows: [] + }, { surface: 'standalone' }); + assert.equal(standalonePresentation.json.subject.chunkUid, 'chunk-risk'); + assert.match(standalonePresentation.markdown, /Risk Explain/); + + const contextPackPresentation = buildRiskExplanationPresentationFromRiskSlice({ + summary: { + chunkUid: 'chunk-risk', + file: 'src/app.ts', + symbol: { name: 'risky', kind: 'function' }, + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 0 } + }, + analysisStatus: { status: 'ok', code: 'ok' }, + flows: [] + }, { + surface: 'contextPack', + subject: { chunkUid: 'chunk-risk', file: 'src/app.ts', name: 'risky', kind: 'function' } + }); + assert.equal(contextPackPresentation.json.subject.chunkUid, 'chunk-risk'); + assert.equal(contextPackPresentation.json.subject.name, 'risky'); + assert.match(contextPackPresentation.markdown, /^Risk\n/m); + assert.doesNotMatch(contextPackPresentation.markdown, /- chunkUid:/); + } + }, + { + name: 'standalone and context-pack presentations stay aligned', + run() { + const flows = [{ + flowId: 'flow-1', + confidence: 0.88, + category: 'injection', + source: { chunkUid: 'chunk-a', ruleId: 'SRC1', category: 'input' }, + sink: { chunkUid: 'chunk-b', ruleId: 'SNK1', category: 'injection' }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-a' }, + { type: 'chunk', chunkUid: 'chunk-b' } + ], + callSiteIdsByStep: [['cs-1', 'cs-2']] + }, + evidence: { + callSitesByStep: [[ + { callSiteId: 'cs-1', details: { file: 'src/app.ts' } }, + { callSiteId: 'cs-2', details: { file: 'src/app.ts' } } + ]] + } + }]; + const summary = { + chunkUid: 'chunk-a', + file: 'src/app.ts', + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 0 }, + topCategories: [{ category: 'injection', count: 1 }, { category: 'input', count: 1 }], + topTags: [{ tag: 'sql', count: 2 }] + }; + const stats = { + status: 'ok', + flowsEmitted: 1, + summariesEmitted: 1, + uniqueCallSitesReferenced: 2, + capsHit: [] + }; + const provenance = { + generatedAt: '2026-03-12T00:00:00.000Z', + ruleBundle: { version: '1.0.0', fingerprint: 'sha1:rules' }, + effectiveConfigFingerprint: 'sha1:config', + artifactRefs: { flows: { entrypoint: 'risk_flows.jsonl' } } + }; + const standalone = buildRiskExplanationPresentationFromStandalone({ + chunk: { chunkUid: 'chunk-a', file: 'src/app.ts', name: 'risky', kind: 'function' }, + summary: { + chunkUid: 'chunk-a', + file: 'src/app.ts', + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 0 }, + signals: { + sources: [{ category: 'input', tags: ['sql'] }], + sinks: [{ category: 'injection', tags: ['sql'] }], + sanitizers: [], + localFlows: [] + } + }, + stats: { + status: 'ok', + counts: { flowsEmitted: 1, summariesEmitted: 1, uniqueCallSitesReferenced: 2 }, + capsHit: [], + provenance + }, + flows, + filters: {} + }, { + surface: 'standalone', + title: null, + includeSubject: false, + includeFilters: false, + maxFlows: 1 + }); + const fromPack = buildRiskExplanationPresentationFromRiskSlice({ + summary, + stats, + provenance, + analysisStatus: { status: 'ok', code: 'ok' }, + flows + }, { + surface: 'standalone', + title: null, + includeSubject: false, + includeFilters: false, + maxFlows: 1 + }); + assert.equal(standalone.markdown, fromPack.markdown); + assert.deepEqual(standalone.json.flows, fromPack.json.flows); + } + }, + { + name: 'context-pack rendering reuses shared risk explain output', + run() { + const callSiteDetails = { + file: 'src/app.js', + startLine: 27, + startCol: 5, + calleeNormalized: 'dangerousSink', + args: ['req.body'] + }; + const cliFlows = [{ + flowId: 'flow-parity', + confidence: 0.91, + category: 'injection', + source: { ruleId: 'SRC-1' }, + sink: { ruleId: 'SNK-1' }, + path: { + labels: ['chunk:chunk-a', 'chunk:chunk-b'], + callSiteIdsByStep: [['cs-1']] + }, + callSitesByStep: [[{ callSiteId: 'cs-1', details: callSiteDetails }]] + }]; + const contextPack = { + primary: { + ref: { type: 'chunk', chunkUid: 'chunk-a' }, + file: 'src/app.js', + excerpt: 'dangerousSink(req.body);' + }, + risk: { + status: 'ok', + summary: { + totals: { sources: 1, sinks: 1, sanitizers: 0, localFlows: 1 } + }, + flows: [{ + flowId: 'flow-parity', + confidence: 0.91, + category: 'injection', + source: { ruleId: 'SRC-1' }, + sink: { ruleId: 'SNK-1' }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-a' }, + { type: 'chunk', chunkUid: 'chunk-b' } + ], + callSiteIdsByStep: [['cs-1']] + }, + evidence: { + callSitesByStep: [[{ callSiteId: 'cs-1', details: callSiteDetails }]] + } + }] + } + }; + const expectedFlowSection = renderRiskExplain(cliFlows, { maxFlows: 1, maxEvidencePerFlow: 3 }); + const expectedRiskSection = buildRiskExplanationPresentationFromRiskSlice( + contextPack.risk, + { + surface: 'contextPack', + subject: { chunkUid: 'chunk-a', file: 'src/app.js', name: null, kind: null } + } + ).markdown; + const compositeOutput = renderCompositeContextPack(contextPack); + assert(compositeOutput.includes(expectedFlowSection)); + assert(compositeOutput.includes(expectedRiskSection)); + assert(compositeOutput.includes('src/app.js:27:5 dangerousSink(req.body)')); + } + }, + { + name: 'renderRiskExplain formats path labels and evidence details', + run() { + const flows = [{ + flowId: 'flow-1', + confidence: 0.88, + category: 'injection', + source: { ruleId: 'SRC1' }, + sink: { ruleId: 'SNK1' }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-a' }, + { type: 'chunk', chunkUid: 'chunk-b' } + ], + callSiteIdsByStep: [['cs-1', 'cs-2']] + }, + evidence: { + callSitesByStep: [[{ + callSiteId: 'cs-1', + details: { + file: 'src/index.js', + startLine: 12, + startCol: 7, + calleeNormalized: 'sink', + args: ['req.body'], + excerpt: 'db.raw(req.body)' + } + }]] + } + }]; + const output = renderRiskExplain(flows, { maxFlows: 1, maxEvidencePerFlow: 2 }); + assert(output.includes('flow-1')); + assert(output.includes('chunk:chunk-a')); + assert(output.includes('src/index.js:12:7 sink(req.body) | db.raw(req.body)')); + } + }, + { + name: 'JSON and markdown contracts stay stable for minimal, full, and capped flows', + run() { + const minimalModel = buildRiskExplanationModelFromStandalone(createMinimalRiskStandaloneInput()); + const minimalJson = renderRiskExplanationJson(minimalModel, { + title: 'Risk Explain', + maxFlows: 1, + maxEvidencePerFlow: 2 + }); + assert.deepEqual(minimalJson.flowSelection, { + totalFlows: 0, + shownFlows: 0, + omittedFlows: 0, + maxFlows: 1, + maxEvidencePerFlow: 2 + }); + assert.deepEqual(minimalJson.partialFlowSelection, { + totalPartialFlows: 0, + shownPartialFlows: 0, + omittedPartialFlows: 0, + maxPartialFlows: 3, + maxEvidencePerFlow: 2 + }); + assert.deepEqual(minimalJson.flows, []); + assert.deepEqual(minimalJson.partialFlows, []); + assert.equal(minimalJson.summary?.totals?.sources, 0); + assert.match(renderRiskExplanation(minimalModel, { maxFlows: 1, maxEvidencePerFlow: 2 }), /Risk Flows\n- \(none\)/); + + const fullModel = buildRiskExplanationModelFromRiskSlice(createFullRiskSliceInput(), { + subject: { chunkUid: 'chunk-full', file: 'src/full.js', name: 'full', kind: 'function' } + }); + const fullJson = renderRiskExplanationJson(fullModel, { + title: 'Risk Explain', + maxFlows: 3, + maxPartialFlows: 5, + maxEvidencePerFlow: 2 + }); + assert.equal(fullJson.flows[0].flowId, 'flow-full'); + assert.equal(fullJson.flows[0].source?.ruleRole, 'source'); + assert.deepEqual(fullJson.flows[0].source?.tags, ['input', 'http']); + assert.equal(fullJson.flows[0].sink?.ruleRole, 'sink'); + assert.deepEqual(fullJson.flows[0].sink?.tags, ['sql']); + assert.deepEqual(fullJson.summary?.ruleRoles, { sources: 1, sinks: 1, sanitizers: 0 }); + assert.deepEqual(fullJson.summary?.propagatorLikeRoles, [{ role: 'callback', count: 1 }]); + assert.equal(fullJson.provenance?.ruleBundle?.roleModel?.propagatorLikeEncoding, 'watch-semantics'); + assert.equal(fullJson.flows[0].steps[0].step, 1); + assert.deepEqual(fullJson.flows[0].steps[0].evidence, ['src/full.js:18:4 query(req.body)']); + assert.equal(fullJson.flows[0].steps[0].watchWindow?.calleeNormalized, 'query'); + assert.deepEqual(fullJson.flows[0].steps[0].watchWindow?.boundParams, ['input']); + assert.deepEqual(fullJson.flows[0].steps[0].watchWindow?.semanticIds, ['sem.callback.register-handler-payload']); + assert.deepEqual(fullJson.flows[0].steps[0].watchWindow?.semanticKinds, ['callback']); + assert.deepEqual(fullJson.partialFlows[0].steps[0].watchWindow?.semanticIds, ['sem.callback.register-handler-payload']); + assert.deepEqual(fullJson.partialFlows[0].steps[0].watchWindow?.semanticKinds, ['callback']); + assert.equal(fullJson.sarif.runs[0].results[0].partialFingerprints.pairOfCleatsFlowId, 'flow-full'); + assert.equal( + fullJson.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].location.physicalLocation.artifactLocation.uri, + 'src/full.js' + ); + assert.equal( + fullJson.sarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.calleeNormalized, + 'query' + ); + assert.equal(fullJson.partialFlowSelection.totalPartialFlows, 1); + assert.equal(fullJson.partialFlows[0].partialFlowId, 'partial-a'); + assert.equal(fullJson.partialFlows[0].terminalReason, 'maxDepth'); + assert.equal(fullJson.partialFlows[0].frontierChunkUid, 'chunk-mid'); + assert.deepEqual(fullJson.partialFlows[0].steps[0].evidence, ['src/full.js:18:4 query(req.body)']); + + const fullMarkdown = renderRiskExplanation(fullModel, { + maxFlows: 3, + maxPartialFlows: 5, + maxEvidencePerFlow: 2 + }); + assert.match(fullMarkdown, /summary: sources 1, sinks 1, sanitizers 0, localFlows 1/); + assert.match(fullMarkdown, /interprocedural: status ok, flows 1, partial flows 2, summaries 1, call sites 1/); + assert.match(fullMarkdown, /pack caps: maxFlows 3, maxPartialFlows 5, maxBytes 512, maxTokens 128, maxPartialBytes 100, maxPartialTokens 50/); + assert.match(fullMarkdown, /provenance: generated 2026-03-12T00:00:00.000Z, rules 1.0.0 sha1:bundle, config sha1:config/); + assert.match(fullMarkdown, /step 1: src\/full.js:18:4 query\(req.body\)/); + assert.match(fullMarkdown, /watch: taint req.body -> input; params input; callee query; semantics sem.callback.register-handler-payload; confidence 0.6000 -> 0.5100/); + assert.match(fullMarkdown, /Partial Risk Flows/); + assert.match(fullMarkdown, /partial-a/); + + const cappedModel = buildRiskExplanationModelFromRiskSlice(createCappedRiskSliceInput()); + const cappedJson = renderRiskExplanationJson(cappedModel, { + title: 'Risk Explain', + maxFlows: 1, + maxEvidencePerFlow: 1 + }); + assert.deepEqual(cappedJson.flowSelection, { + totalFlows: 2, + shownFlows: 1, + omittedFlows: 1, + maxFlows: 1, + maxEvidencePerFlow: 1 + }); + assert.equal(cappedJson.flows.length, 1); + assert.equal(cappedJson.sarif.runs[0].properties.pairOfCleats.flowSelection.omittedFlows, 1); + const cappedMarkdown = renderRiskExplanation(cappedModel, { maxFlows: 1, maxEvidencePerFlow: 1 }); + assert.match(cappedMarkdown, /truncation: maxFlows/); + assert.match(cappedMarkdown, /omitted 1 additional flow\(s\) after maxFlows=1/); + } + }, + { + name: 'SARIF contract stays stable for minimal, full, and capped flows', + run() { + const minimalModel = buildRiskExplanationModelFromStandalone(createMinimalRiskStandaloneInput()); + const minimalSarif = renderRiskExplanationSarif(minimalModel, { + title: 'Risk Explain', + maxFlows: 1, + maxEvidencePerFlow: 2 + }); + assert.equal(minimalSarif.version, '2.1.0'); + assert.equal(minimalSarif.runs[0].results.length, 0); + assert.deepEqual(minimalSarif.runs[0].properties.pairOfCleats.flowSelection, { + totalFlows: 0, + shownFlows: 0, + omittedFlows: 0, + maxFlows: 1, + maxEvidencePerFlow: 2 + }); + assert.deepEqual(minimalSarif.runs[0].properties.pairOfCleats.partialFlowSelection, { + totalPartialFlows: 0, + shownPartialFlows: 0, + omittedPartialFlows: 0, + maxPartialFlows: 3, + maxEvidencePerFlow: 2 + }); + + const fullModel = buildRiskExplanationModelFromStandalone(createFullRiskStandaloneInput()); + const fullSarif = renderRiskExplanationSarif(fullModel, { + title: 'Risk Explain', + maxFlows: 3, + maxEvidencePerFlow: 2 + }); + assert.equal(fullSarif.runs[0].results.length, 1); + assert.equal(fullSarif.runs[0].tool.driver.rules.length, 1); + assert.equal(fullSarif.runs[0].results[0].ruleId, 'pairofcleats/risk-flow'); + assert.equal(fullSarif.runs[0].results[0].properties.pairOfCleats.flowId, 'flow-full'); + assert.equal(fullSarif.runs[0].results[0].properties.pairOfCleats.confidence, 0.91); + assert.equal( + fullSarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].location.physicalLocation.artifactLocation.uri, + 'src/full.js' + ); + assert.equal( + fullSarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].location.physicalLocation.region.startLine, + 18 + ); + assert.equal( + fullSarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.calleeNormalized, + 'query' + ); + assert.deepEqual( + fullSarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.semanticIds, + ['sem.callback.register-handler-payload'] + ); + assert.deepEqual( + fullSarif.runs[0].results[0].codeFlows[0].threadFlows[0].locations[0].properties.pairOfCleats.watchWindow.semanticKinds, + ['callback'] + ); + assert.match(fullSarif.runs[0].results[0].message.text, /injection \| SRC -> SNK/); + assert.equal(fullSarif.runs[0].properties.pairOfCleats.partialFlowSelection.totalPartialFlows, 1); + assert.equal(fullSarif.runs[0].properties.pairOfCleats.partialFlows[0].partialFlowId, 'partial-a'); + assert.equal(fullSarif.runs[0].properties.pairOfCleats.partialFlows[0].frontier.chunkUid, 'chunk-mid'); + assert.equal(fullSarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].calleeNormalized, 'query'); + assert.deepEqual( + fullSarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].semanticIds, + ['sem.callback.register-handler-payload'] + ); + assert.deepEqual( + fullSarif.runs[0].properties.pairOfCleats.partialFlows[0].path.watchByStep[0].semanticKinds, + ['callback'] + ); + + const cappedModel = buildRiskExplanationModelFromRiskSlice(createCappedRiskSliceInput()); + const cappedSarif = renderRiskExplanationSarif(cappedModel, { + title: 'Risk Explain', + maxFlows: 1, + maxEvidencePerFlow: 1 + }); + assert.equal(cappedSarif.runs[0].results.length, 1); + assert.deepEqual(cappedSarif.runs[0].properties.pairOfCleats.flowSelection, { + totalFlows: 2, + shownFlows: 1, + omittedFlows: 1, + maxFlows: 1, + maxEvidencePerFlow: 1 + }); + assert.deepEqual(cappedSarif.runs[0].properties.pairOfCleats.truncation, cappedModel.truncation); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('risk explanation contract matrix test passed'); diff --git a/tests/retrieval/output/stats-intent-miss-taxonomy.test.js b/tests/retrieval/output/stats-intent-miss-taxonomy.test.js index bf5696cc0..5a8d7eef5 100644 --- a/tests/retrieval/output/stats-intent-miss-taxonomy.test.js +++ b/tests/retrieval/output/stats-intent-miss-taxonomy.test.js @@ -1,74 +1,22 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; import { color } from '../../../src/retrieval/cli/ansi.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { renderSearchOutputForTest } from '../helpers/search-output-fixture.js'; applyTestEnv(); -const payload = renderSearchOutput({ - emitOutput: false, - jsonOutput: true, - jsonCompact: true, +const payload = renderSearchOutputForTest({ explain: false, color, - rootDir: process.cwd(), - backendLabel: 'memory', backendPolicyInfo: null, - runCode: true, - runProse: false, - runExtractedProse: false, - runRecords: false, topN: 1, - queryTokens: ['alpha'], highlightRegex: /alpha/g, - contextExpansionEnabled: false, - expandedHits: { - prose: { hits: [] }, - extractedProse: { hits: [] }, - code: { hits: [] }, - records: { hits: [] } - }, - baseHits: { - proseHits: [], - extractedProseHits: [], - codeHits: [], - recordHits: [] - }, - annEnabled: false, - annActive: false, annBackend: 'js', vectorExtension: { annMode: 'dense', provider: null, table: null }, - vectorAnnEnabled: false, - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswConfig: { enabled: false }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, modelIds: { code: null, prose: null, extractedProse: null, records: null }, embeddingProvider: null, embeddingOnnx: { modelPath: null, tokenizerId: null }, - cacheInfo: { enabled: false, hit: false, key: null }, intentInfo: { type: 'code', effectiveType: 'code', @@ -82,15 +30,12 @@ const payload = renderSearchOutput({ } }, resolvedDenseVectorMode: 'merged', - fieldWeights: null, contextExpansionStats: { enabled: false }, idxProse: null, idxExtractedProse: null, idxCode: null, idxRecords: null, showStats: true, - showMatched: false, - verboseCache: false, elapsedMs: 2 }); diff --git a/tests/retrieval/parity/equivalence.test.js b/tests/retrieval/parity/equivalence.test.js new file mode 100644 index 000000000..ae7fcc4b6 --- /dev/null +++ b/tests/retrieval/parity/equivalence.test.js @@ -0,0 +1,390 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { createCli } from '../../../src/shared/cli.js'; +import { summarizeRetrievalHitComparison } from '../../../src/retrieval/hit-comparison.js'; +import { mean, meanNullable } from '../../../src/shared/stats.js'; +import { readQueryFileSafe, resolveTopNAndLimit, selectQueriesByLimit } from '../../../tools/shared/query-file-utils.js'; +import { runSearchCliWithSpawnSync } from '../../../tools/shared/search-cli-harness.js'; +import { loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; +import { ensureParityArtifacts } from '../../../tools/shared/parity-indexes.js'; +import { formatParityDuration } from '../../helpers/duration-format.js'; +import { runNode } from '../../helpers/run-node.js'; +import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; +import { ensureTestingEnv, syncProcessEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const isTestRun = process.env.PAIROFCLEATS_TESTING === '1'; + +const argv = createCli({ + scriptName: 'parity', + options: { + ann: { type: 'boolean', default: !isTestRun }, + 'write-report': { type: 'boolean', default: false }, + enforce: { type: 'boolean', default: false }, + 'enforce-fts': { type: 'boolean', default: false }, + 'min-overlap': { type: 'number' }, + 'min-rank-corr': { type: 'number' }, + 'max-delta': { type: 'number' }, + 'min-overlap-single': { type: 'number' }, + queries: { type: 'string' }, + out: { type: 'string' }, + repo: { type: 'string' }, + search: { type: 'string' }, + 'sqlite-backend': { type: 'string', default: 'sqlite' }, + top: { type: 'number', default: isTestRun ? 2 : 5 }, + limit: { type: 'number', default: isTestRun ? 1 : 0 } + }, + aliases: { n: 'top', q: 'queries' } +}).parse(); + +const workspaceRoot = process.cwd(); +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const createTestParityRepo = async () => { + const repoRoot = path.join(resolveTestCachePath(workspaceRoot, 'retrieval-parity'), 'repo'); + await fs.rm(repoRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fs.mkdir(path.join(repoRoot, 'docs'), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, 'src', 'index.js'), + [ + 'export function searchIndex(items, needle) {', + ' return items.filter((item) => item.includes(needle));', + '}', + '', + 'export const sqliteDictionary = new Map([["index", "chunk"]]);', + '' + ].join('\n'), + 'utf8' + ); + await fs.writeFile( + path.join(repoRoot, 'docs', 'retrieval.md'), + [ + '# Retrieval fixture', + '', + 'The sqlite search index stores dictionary chunk metadata for parity checks.', + '' + ].join('\n'), + 'utf8' + ); + return repoRoot; +}; +const root = argv.repo + ? path.resolve(argv.repo) + : (isTestRun ? await createTestParityRepo() : workspaceRoot); +const testCacheRoot = path.join(resolveTestCachePath(workspaceRoot, 'retrieval-parity'), 'cache'); +if (isTestRun && !process.env.PAIROFCLEATS_CACHE_ROOT) { + syncProcessEnv({ PAIROFCLEATS_CACHE_ROOT: testCacheRoot }); +} +const userConfig = loadUserConfig(root); +const parityEnv = ensureTestingEnv({ ...process.env }); +if (!parityEnv.PAIROFCLEATS_EMBEDDINGS) { + parityEnv.PAIROFCLEATS_EMBEDDINGS = 'stub'; +} +if (isTestRun && !parityEnv.PAIROFCLEATS_TEST_CONFIG) { + parityEnv.PAIROFCLEATS_TEST_CONFIG = JSON.stringify({ + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }); +} +const resolveSqlitePathsForRoot = () => resolveSqlitePaths(root, userConfig); + +const searchPath = argv.search + ? path.resolve(argv.search) + : path.join(scriptRoot, 'search.js'); +if (!fsSync.existsSync(searchPath)) { + console.error(`search.js not found at ${searchPath}`); + process.exit(1); +} + +const parityArtifacts = await ensureParityArtifacts({ + root, + userConfig, + resolveSqlitePathsForRoot, + canBuild: isTestRun, + buildIndexOnSqliteMissing: true, + buildSqliteAfterIndexBuild: true, + buildIndex: () => { + const env = { ...parityEnv }; + const runBuildStage = (stage) => runNode( + [path.join(scriptRoot, 'build_index.js'), '--stage', stage, '--stub-embeddings', '--repo', root], + `parity build index stage ${stage}`, + root, + env, + { stdio: 'inherit', allowFailure: true } + ); + const annWanted = argv.ann !== false; + const stages = annWanted ? ['1', '3'] : ['1']; + for (const stage of stages) { + const buildResult = runBuildStage(stage); + if (buildResult.status !== 0) { + console.error(`Parity test failed: build index stage ${stage}`); + process.exit(buildResult.status ?? 1); + } + } + }, + buildSqlite: async () => { + await runSqliteBuild(root, { env: parityEnv }); + } +}); +const sqlitePaths = parityArtifacts.sqlitePaths || resolveSqlitePathsForRoot(); + +for (const mode of ['code', 'prose']) { + const modeState = parityArtifacts.indexByMode[mode]; + if (!modeState?.exists) { + const fallbackMetaPath = path.join(root, `index-${mode}`, 'chunk_meta.json'); + console.error(`Missing ${modeState?.metaPath || fallbackMetaPath}. Build the index first.`); + process.exit(1); + } +} + +const missing = parityArtifacts.missingSqliteEntries.map( + (entry) => `${entry.mode}=${entry.path || ''}` +); +if (missing.length) { + console.error(`SQLite index not found (${missing.join(', ')}). Run "pairofcleats index build --stage 4" first.`); + process.exit(1); +} + +const defaultQueriesPath = path.join(scriptRoot, 'tests', 'retrieval', 'parity', 'parity-queries.txt'); +const queriesPath = argv.queries ? path.resolve(argv.queries) : defaultQueriesPath; +if (argv.queries && !fsSync.existsSync(queriesPath)) { + console.error(`Query file not found at ${queriesPath}`); + process.exit(1); +} +const fallbackQueries = [ + 'index', + 'search', + 'sqlite', + 'dictionary', + 'bootstrap', + 'chunk', + 'minhash', + 'ann', + 'bm25', + 'cache' +]; + +const loadedQueries = await readQueryFileSafe(queriesPath, { allowJson: true, jsonMode: 'stringify' }); +const queries = loadedQueries.length ? loadedQueries : fallbackQueries; +if (!loadedQueries.length) { + console.warn(`Query file not found or empty; using fallback queries (${queries.length}).`); +} + +const { topN, limit } = resolveTopNAndLimit({ + top: argv.top, + limit: argv.limit, + defaultTop: 5, + defaultLimit: 0 +}); +const selectedQueries = selectQueriesByLimit(queries, limit); +const annEnabled = argv.ann !== false; +const annArg = annEnabled ? '--ann' : '--no-ann'; +const sqliteBackendRaw = String(argv['sqlite-backend'] || 'sqlite').toLowerCase(); +if (!['sqlite', 'sqlite-fts', 'fts'].includes(sqliteBackendRaw)) { + console.error('Invalid sqlite backend. Use --sqlite-backend sqlite|sqlite-fts.'); + process.exit(1); +} +const sqliteBackend = sqliteBackendRaw === 'fts' ? 'sqlite-fts' : sqliteBackendRaw; + +function runSearch(query, backend) { + try { + return runSearchCliWithSpawnSync({ + query, + searchPath, + stats: true, + compact: true, + backend, + topN, + annArg, + repo: root, + env: parityEnv, + maxBuffer: 50 * 1024 * 1024, + now: () => performance.now() + }); + } catch (err) { + if (err?.code !== 'ERR_SEARCH_CLI_EXIT') throw err; + console.error(`Search failed for backend=${backend} query="${query}"`); + if (err.spawnError?.message) console.error(String(err.spawnError.message).trim()); + if (err.stderr) console.error(String(err.stderr).trim()); + process.exit(err.exitCode || 1); + } +} + +function summarizeMatch(memoryHits, sqliteHits) { + const comparison = summarizeRetrievalHitComparison(memoryHits, sqliteHits, { + topN, + missingLimit: 5, + treatBothEmptyAsPerfect: true + }); + const summary = { + overlap: comparison.overlap, + avgDelta: comparison.avgDelta, + missingFromSqlite: comparison.missingFromOther, + missingFromMemory: comparison.missingFromBase, + rankCorr: comparison.rankCorr, + topMemory: comparison.baseKeys, + topSqlite: comparison.otherKeys + }; + if (comparison.zeroHits) summary.zeroHits = true; + return summary; +} + +function toMb(bytes) { + return bytes ? bytes / (1024 * 1024) : 0; +} + +const results = []; +const totalQueries = selectedQueries.length; +const overallStart = performance.now(); +let completed = 0; +for (const query of selectedQueries) { + const queryStart = performance.now(); + const memRun = runSearch(query, 'memory'); + const sqlRun = runSearch(query, sqliteBackend); + const memPayload = memRun.payload; + const sqlPayload = sqlRun.payload; + + results.push({ + query, + memory: { + stats: memPayload.stats || {}, + wallMs: memRun.wallMs + }, + sqlite: { + stats: sqlPayload.stats || {}, + wallMs: sqlRun.wallMs + }, + code: summarizeMatch(memPayload.code || [], sqlPayload.code || []), + prose: summarizeMatch(memPayload.prose || [], sqlPayload.prose || []) + }); + + completed += 1; + const elapsed = performance.now() - overallStart; + const avg = elapsed / completed; + const remaining = avg * Math.max(0, totalQueries - completed); + const queryElapsed = performance.now() - queryStart; + console.log( + `[parity] ${completed}/${totalQueries} (${Math.round((completed / totalQueries) * 100)}%) ` + + `query="${query}" last=${formatParityDuration(queryElapsed)} ` + + `mem=${formatParityDuration(memRun.wallMs)} sqlite=${formatParityDuration(sqlRun.wallMs)} ` + + `elapsed=${formatParityDuration(elapsed)} eta=${formatParityDuration(remaining)}` + ); +} + +const overlapValues = results.flatMap((entry) => [entry.code.overlap, entry.prose.overlap]); +const deltaValues = results.flatMap((entry) => [entry.code.avgDelta, entry.prose.avgDelta]); +const rankCorrValues = results.flatMap((entry) => [entry.code.rankCorr, entry.prose.rankCorr]); +const memLatency = results.map((entry) => entry.memory.stats.elapsedMs || 0); +const sqlLatency = results.map((entry) => entry.sqlite.stats.elapsedMs || 0); +const memWall = results.map((entry) => entry.memory.wallMs || 0); +const sqlWall = results.map((entry) => entry.sqlite.wallMs || 0); + +const memRss = results.map((entry) => toMb(entry.memory.stats.memory?.rss || 0)); +const sqlRss = results.map((entry) => toMb(entry.sqlite.stats.memory?.rss || 0)); + +const summary = { + queries: results.length, + topN, + annEnabled, + sqliteBackend, + overlapAvg: mean(overlapValues), + scoreDeltaAvg: mean(deltaValues), + rankCorrAvg: meanNullable(rankCorrValues), + latencyMsAvg: { + memory: mean(memLatency), + sqlite: mean(sqlLatency) + }, + wallMsAvg: { + memory: mean(memWall), + sqlite: mean(sqlWall) + }, + rssMbAvg: { + memory: mean(memRss), + sqlite: mean(sqlRss) + } +}; + +console.log('Parity summary'); +console.log(`- Queries: ${summary.queries}`); +console.log(`- TopN: ${summary.topN}`); +console.log(`- Ann: ${summary.annEnabled}`); +console.log(`- SQLite backend: ${summary.sqliteBackend}`); +console.log(`- Overlap avg: ${summary.overlapAvg.toFixed(3)}`); +console.log(`- Score delta avg: ${summary.scoreDeltaAvg.toFixed(4)}`); +if (summary.rankCorrAvg === null) { + console.log('- Rank corr avg: n/a'); +} else { + console.log(`- Rank corr avg: ${summary.rankCorrAvg.toFixed(3)}`); +} +console.log(`- Latency ms avg (memory/sqlite): ${summary.latencyMsAvg.memory.toFixed(1)} / ${summary.latencyMsAvg.sqlite.toFixed(1)}`); +console.log(`- Wall ms avg (memory/sqlite): ${summary.wallMsAvg.memory.toFixed(1)} / ${summary.wallMsAvg.sqlite.toFixed(1)}`); +console.log(`- RSS MB avg (memory/sqlite): ${summary.rssMbAvg.memory.toFixed(1)} / ${summary.rssMbAvg.sqlite.toFixed(1)}`); + +const report = { + generatedAt: new Date().toISOString(), + queryFile: queriesPath, + topN, + annEnabled, + summary, + results +}; + +if (argv['write-report']) { + const outPath = argv.out + ? path.resolve(argv.out) + : path.join(scriptRoot, 'docs', 'phase3-parity-report.json'); + await fs.writeFile(outPath, JSON.stringify(report, null, 2)); + console.log(`Report written to ${outPath}`); +} + +if (argv.enforce) { + const isFts = sqliteBackend === 'sqlite-fts'; + const defaults = isFts + ? { minOverlap: 0.7, minRankCorr: 0.55, maxDelta: 0.5, minSingleOverlap: 0.6 } + : { minOverlap: 0.95, minRankCorr: 0.9, maxDelta: 0.1, minSingleOverlap: 0.6 }; + const thresholds = { + minOverlap: Number.isFinite(argv['min-overlap']) ? argv['min-overlap'] : defaults.minOverlap, + minRankCorr: Number.isFinite(argv['min-rank-corr']) ? argv['min-rank-corr'] : defaults.minRankCorr, + maxDelta: Number.isFinite(argv['max-delta']) ? argv['max-delta'] : defaults.maxDelta, + minSingleOverlap: Number.isFinite(argv['min-overlap-single']) + ? argv['min-overlap-single'] + : defaults.minSingleOverlap + }; + const minOverlapSingle = overlapValues.length ? Math.min(...overlapValues) : 1; + const failures = []; + if (summary.overlapAvg < thresholds.minOverlap) { + failures.push(`overlapAvg ${summary.overlapAvg.toFixed(3)} < ${thresholds.minOverlap}`); + } + if (summary.rankCorrAvg !== null && summary.rankCorrAvg < thresholds.minRankCorr) { + failures.push(`rankCorrAvg ${summary.rankCorrAvg.toFixed(3)} < ${thresholds.minRankCorr}`); + } + if (summary.scoreDeltaAvg > thresholds.maxDelta) { + failures.push(`avgDelta ${summary.scoreDeltaAvg.toFixed(3)} > ${thresholds.maxDelta}`); + } + if (minOverlapSingle < thresholds.minSingleOverlap) { + failures.push(`minOverlap@K ${minOverlapSingle.toFixed(3)} < ${thresholds.minSingleOverlap}`); + } + if (failures.length) { + const label = failures.join('; '); + if (isFts && argv['enforce-fts'] !== true) { + console.warn(`SQLite FTS parity warning: ${label}`); + } else { + console.error(`Parity thresholds failed: ${label}`); + process.exit(1); + } + } +} diff --git a/tests/retrieval/parity/parity-indexes-compressed-chunk-meta-detection.test.js b/tests/retrieval/parity/indexes-compressed-chunk-meta-detection.test.js similarity index 100% rename from tests/retrieval/parity/parity-indexes-compressed-chunk-meta-detection.test.js rename to tests/retrieval/parity/indexes-compressed-chunk-meta-detection.test.js diff --git a/tests/retrieval/parity/parity.test.js b/tests/retrieval/parity/parity.test.js deleted file mode 100644 index bdbacb3b5..000000000 --- a/tests/retrieval/parity/parity.test.js +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { performance } from 'node:perf_hooks'; -import { fileURLToPath } from 'node:url'; -import { createCli } from '../../../src/shared/cli.js'; -import { readQueryFileSafe, resolveTopNAndLimit, selectQueriesByLimit } from '../../../tools/shared/query-file-utils.js'; -import { runSearchCliWithSpawnSync } from '../../../tools/shared/search-cli-harness.js'; -import { mean, meanNullable } from '../../../tools/shared/stats-utils.js'; -import { loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { ensureParityArtifacts } from '../../../tools/shared/parity-indexes.js'; -import { formatParityDuration } from '../../helpers/duration-format.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; -import { ensureTestingEnv, syncProcessEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const argv = createCli({ - scriptName: 'parity', - options: { - ann: { type: 'boolean', default: true }, - 'write-report': { type: 'boolean', default: false }, - enforce: { type: 'boolean', default: false }, - 'enforce-fts': { type: 'boolean', default: false }, - 'min-overlap': { type: 'number' }, - 'min-rank-corr': { type: 'number' }, - 'max-delta': { type: 'number' }, - 'min-overlap-single': { type: 'number' }, - queries: { type: 'string' }, - out: { type: 'string' }, - search: { type: 'string' }, - 'sqlite-backend': { type: 'string', default: 'sqlite' }, - top: { type: 'number', default: 5 }, - limit: { type: 'number', default: 0 } - }, - aliases: { n: 'top', q: 'queries' } -}).parse(); - -const root = process.cwd(); -const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); -const isTestRun = process.env.PAIROFCLEATS_TESTING === '1'; -if (isTestRun && !process.env.PAIROFCLEATS_CACHE_ROOT) { - syncProcessEnv({ PAIROFCLEATS_CACHE_ROOT: resolveTestCachePath(root, 'retrieval-parity') }); -} -const userConfig = loadUserConfig(root); -const parityEnv = ensureTestingEnv({ ...process.env }); -if (!parityEnv.PAIROFCLEATS_EMBEDDINGS) { - parityEnv.PAIROFCLEATS_EMBEDDINGS = 'stub'; -} -const resolveSqlitePathsForRoot = () => resolveSqlitePaths(root, userConfig); - -const searchPath = argv.search - ? path.resolve(argv.search) - : path.join(root, 'search.js'); -if (!fsSync.existsSync(searchPath)) { - console.error(`search.js not found at ${searchPath}`); - process.exit(1); -} - -const parityArtifacts = await ensureParityArtifacts({ - root, - userConfig, - resolveSqlitePathsForRoot, - canBuild: isTestRun, - buildIndexOnSqliteMissing: true, - buildSqliteAfterIndexBuild: true, - buildIndex: () => { - const env = { ...parityEnv }; - const runBuildStage = (stage) => spawnSync( - process.execPath, - [path.join(scriptRoot, 'build_index.js'), '--stage', stage, '--stub-embeddings', '--repo', root], - { env, cwd: root, stdio: 'inherit' } - ); - const annWanted = argv.ann !== false; - const stages = annWanted ? ['1', '3'] : ['1']; - for (const stage of stages) { - const buildResult = runBuildStage(stage); - if (buildResult.status !== 0) { - console.error(`Parity test failed: build index stage ${stage}`); - process.exit(buildResult.status ?? 1); - } - } - }, - buildSqlite: async () => { - await runSqliteBuild(root); - } -}); -const sqlitePaths = parityArtifacts.sqlitePaths || resolveSqlitePathsForRoot(); - -for (const mode of ['code', 'prose']) { - const modeState = parityArtifacts.indexByMode[mode]; - if (!modeState?.exists) { - const fallbackMetaPath = path.join(root, `index-${mode}`, 'chunk_meta.json'); - console.error(`Missing ${modeState?.metaPath || fallbackMetaPath}. Build the index first.`); - process.exit(1); - } -} - -const missing = parityArtifacts.missingSqliteEntries.map( - (entry) => `${entry.mode}=${entry.path || ''}` -); -if (missing.length) { - console.error(`SQLite index not found (${missing.join(', ')}). Run "pairofcleats index build --stage 4" first.`); - process.exit(1); -} - -const defaultQueriesPath = path.join(root, 'tests', 'retrieval', 'parity', 'parity-queries.txt'); -const queriesPath = argv.queries ? path.resolve(argv.queries) : defaultQueriesPath; -if (argv.queries && !fsSync.existsSync(queriesPath)) { - console.error(`Query file not found at ${queriesPath}`); - process.exit(1); -} -const fallbackQueries = [ - 'index', - 'search', - 'sqlite', - 'dictionary', - 'bootstrap', - 'chunk', - 'minhash', - 'ann', - 'bm25', - 'cache' -]; - -const loadedQueries = await readQueryFileSafe(queriesPath, { allowJson: true, jsonMode: 'stringify' }); -const queries = loadedQueries.length ? loadedQueries : fallbackQueries; -if (!loadedQueries.length) { - console.warn(`Query file not found or empty; using fallback queries (${queries.length}).`); -} - -const { topN, limit } = resolveTopNAndLimit({ - top: argv.top, - limit: argv.limit, - defaultTop: 5, - defaultLimit: 0 -}); -const selectedQueries = selectQueriesByLimit(queries, limit); -const annEnabled = argv.ann !== false; -const annArg = annEnabled ? '--ann' : '--no-ann'; -const sqliteBackendRaw = String(argv['sqlite-backend'] || 'sqlite').toLowerCase(); -if (!['sqlite', 'sqlite-fts', 'fts'].includes(sqliteBackendRaw)) { - console.error('Invalid sqlite backend. Use --sqlite-backend sqlite|sqlite-fts.'); - process.exit(1); -} -const sqliteBackend = sqliteBackendRaw === 'fts' ? 'sqlite-fts' : sqliteBackendRaw; - -function runSearch(query, backend) { - try { - return runSearchCliWithSpawnSync({ - query, - searchPath, - stats: true, - compact: true, - backend, - topN, - annArg, - repo: root, - env: parityEnv, - maxBuffer: 50 * 1024 * 1024, - now: () => performance.now() - }); - } catch (err) { - if (err?.code !== 'ERR_SEARCH_CLI_EXIT') throw err; - console.error(`Search failed for backend=${backend} query="${query}"`); - if (err.spawnError?.message) console.error(String(err.spawnError.message).trim()); - if (err.stderr) console.error(String(err.stderr).trim()); - process.exit(err.exitCode || 1); - } -} - -function hitKey(hit, index) { - if (hit && (hit.id || hit.id === 0)) return String(hit.id); - if (hit && hit.file) { - const start = hit.startLine ?? hit.start ?? 0; - const end = hit.endLine ?? hit.end ?? 0; - return `${hit.file}:${start}:${end}:${hit.kind || ''}:${hit.name || ''}`; - } - return String(index); -} - -function hitScore(hit) { - if (!hit || typeof hit !== 'object') return 0; - if (Number.isFinite(hit.score)) return hit.score; - const selected = hit.scoreBreakdown?.selected?.score; - if (Number.isFinite(selected)) return selected; - if (Number.isFinite(hit.sparseScore)) return hit.sparseScore; - if (Number.isFinite(hit.annScore)) return hit.annScore; - return 0; -} - -function summarizeMatch(memoryHits, sqliteHits) { - const mem = memoryHits.slice(0, topN); - const sql = sqliteHits.slice(0, topN); - if (!mem.length && !sql.length) { - return { - overlap: 1, - avgDelta: 0, - missingFromSqlite: [], - missingFromMemory: [], - rankCorr: null, - topMemory: [], - topSqlite: [], - zeroHits: true - }; - } - const memKeys = mem.map(hitKey); - const sqlKeys = sql.map(hitKey); - const memRanks = new Map(memKeys.map((key, idx) => [key, idx + 1])); - const sqlRanks = new Map(sqlKeys.map((key, idx) => [key, idx + 1])); - const memSet = new Set(memKeys); - const sqlSet = new Set(sqlKeys); - const intersection = memKeys.filter((key) => sqlSet.has(key)); - const overlap = intersection.length / Math.max(1, Math.min(memKeys.length, sqlKeys.length)); - - const memScores = new Map(mem.map((hit, idx) => [hitKey(hit, idx), hitScore(hit)])); - const sqlScores = new Map(sql.map((hit, idx) => [hitKey(hit, idx), hitScore(hit)])); - const deltas = intersection.map((key) => Math.abs((memScores.get(key) || 0) - (sqlScores.get(key) || 0))); - const avgDelta = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : 0; - - const missingFromSqlite = memKeys.filter((key) => !sqlSet.has(key)); - const missingFromMemory = sqlKeys.filter((key) => !memSet.has(key)); - - let rankCorr = null; - if (intersection.length >= 2) { - let sum = 0; - for (const key of intersection) { - const d = (memRanks.get(key) || 0) - (sqlRanks.get(key) || 0); - sum += d * d; - } - const n = intersection.length; - rankCorr = 1 - (6 * sum) / (n * (n * n - 1)); - } - - return { - overlap, - avgDelta, - missingFromSqlite: missingFromSqlite.slice(0, 5), - missingFromMemory: missingFromMemory.slice(0, 5), - rankCorr, - topMemory: memKeys, - topSqlite: sqlKeys - }; -} - -function toMb(bytes) { - return bytes ? bytes / (1024 * 1024) : 0; -} - -const results = []; -const totalQueries = selectedQueries.length; -const overallStart = performance.now(); -let completed = 0; -for (const query of selectedQueries) { - const queryStart = performance.now(); - const memRun = runSearch(query, 'memory'); - const sqlRun = runSearch(query, sqliteBackend); - const memPayload = memRun.payload; - const sqlPayload = sqlRun.payload; - - results.push({ - query, - memory: { - stats: memPayload.stats || {}, - wallMs: memRun.wallMs - }, - sqlite: { - stats: sqlPayload.stats || {}, - wallMs: sqlRun.wallMs - }, - code: summarizeMatch(memPayload.code || [], sqlPayload.code || []), - prose: summarizeMatch(memPayload.prose || [], sqlPayload.prose || []) - }); - - completed += 1; - const elapsed = performance.now() - overallStart; - const avg = elapsed / completed; - const remaining = avg * Math.max(0, totalQueries - completed); - const queryElapsed = performance.now() - queryStart; - console.log( - `[parity] ${completed}/${totalQueries} (${Math.round((completed / totalQueries) * 100)}%) ` + - `query="${query}" last=${formatParityDuration(queryElapsed)} ` + - `mem=${formatParityDuration(memRun.wallMs)} sqlite=${formatParityDuration(sqlRun.wallMs)} ` + - `elapsed=${formatParityDuration(elapsed)} eta=${formatParityDuration(remaining)}` - ); -} - -const overlapValues = results.flatMap((entry) => [entry.code.overlap, entry.prose.overlap]); -const deltaValues = results.flatMap((entry) => [entry.code.avgDelta, entry.prose.avgDelta]); -const rankCorrValues = results.flatMap((entry) => [entry.code.rankCorr, entry.prose.rankCorr]); -const memLatency = results.map((entry) => entry.memory.stats.elapsedMs || 0); -const sqlLatency = results.map((entry) => entry.sqlite.stats.elapsedMs || 0); -const memWall = results.map((entry) => entry.memory.wallMs || 0); -const sqlWall = results.map((entry) => entry.sqlite.wallMs || 0); - -const memRss = results.map((entry) => toMb(entry.memory.stats.memory?.rss || 0)); -const sqlRss = results.map((entry) => toMb(entry.sqlite.stats.memory?.rss || 0)); - -const summary = { - queries: results.length, - topN, - annEnabled, - sqliteBackend, - overlapAvg: mean(overlapValues), - scoreDeltaAvg: mean(deltaValues), - rankCorrAvg: meanNullable(rankCorrValues), - latencyMsAvg: { - memory: mean(memLatency), - sqlite: mean(sqlLatency) - }, - wallMsAvg: { - memory: mean(memWall), - sqlite: mean(sqlWall) - }, - rssMbAvg: { - memory: mean(memRss), - sqlite: mean(sqlRss) - } -}; - -console.log('Parity summary'); -console.log(`- Queries: ${summary.queries}`); -console.log(`- TopN: ${summary.topN}`); -console.log(`- Ann: ${summary.annEnabled}`); -console.log(`- SQLite backend: ${summary.sqliteBackend}`); -console.log(`- Overlap avg: ${summary.overlapAvg.toFixed(3)}`); -console.log(`- Score delta avg: ${summary.scoreDeltaAvg.toFixed(4)}`); -if (summary.rankCorrAvg === null) { - console.log('- Rank corr avg: n/a'); -} else { - console.log(`- Rank corr avg: ${summary.rankCorrAvg.toFixed(3)}`); -} -console.log(`- Latency ms avg (memory/sqlite): ${summary.latencyMsAvg.memory.toFixed(1)} / ${summary.latencyMsAvg.sqlite.toFixed(1)}`); -console.log(`- Wall ms avg (memory/sqlite): ${summary.wallMsAvg.memory.toFixed(1)} / ${summary.wallMsAvg.sqlite.toFixed(1)}`); -console.log(`- RSS MB avg (memory/sqlite): ${summary.rssMbAvg.memory.toFixed(1)} / ${summary.rssMbAvg.sqlite.toFixed(1)}`); - -const report = { - generatedAt: new Date().toISOString(), - queryFile: queriesPath, - topN, - annEnabled, - summary, - results -}; - -if (argv['write-report']) { - const outPath = argv.out - ? path.resolve(argv.out) - : path.join(root, 'docs', 'phase3-parity-report.json'); - await fs.writeFile(outPath, JSON.stringify(report, null, 2)); - console.log(`Report written to ${outPath}`); -} - -if (argv.enforce) { - const isFts = sqliteBackend === 'sqlite-fts'; - const defaults = isFts - ? { minOverlap: 0.7, minRankCorr: 0.55, maxDelta: 0.5, minSingleOverlap: 0.6 } - : { minOverlap: 0.95, minRankCorr: 0.9, maxDelta: 0.1, minSingleOverlap: 0.6 }; - const thresholds = { - minOverlap: Number.isFinite(argv['min-overlap']) ? argv['min-overlap'] : defaults.minOverlap, - minRankCorr: Number.isFinite(argv['min-rank-corr']) ? argv['min-rank-corr'] : defaults.minRankCorr, - maxDelta: Number.isFinite(argv['max-delta']) ? argv['max-delta'] : defaults.maxDelta, - minSingleOverlap: Number.isFinite(argv['min-overlap-single']) - ? argv['min-overlap-single'] - : defaults.minSingleOverlap - }; - const minOverlapSingle = overlapValues.length ? Math.min(...overlapValues) : 1; - const failures = []; - if (summary.overlapAvg < thresholds.minOverlap) { - failures.push(`overlapAvg ${summary.overlapAvg.toFixed(3)} < ${thresholds.minOverlap}`); - } - if (summary.rankCorrAvg !== null && summary.rankCorrAvg < thresholds.minRankCorr) { - failures.push(`rankCorrAvg ${summary.rankCorrAvg.toFixed(3)} < ${thresholds.minRankCorr}`); - } - if (summary.scoreDeltaAvg > thresholds.maxDelta) { - failures.push(`avgDelta ${summary.scoreDeltaAvg.toFixed(3)} > ${thresholds.maxDelta}`); - } - if (minOverlapSingle < thresholds.minSingleOverlap) { - failures.push(`minOverlap@K ${minOverlapSingle.toFixed(3)} < ${thresholds.minSingleOverlap}`); - } - if (failures.length) { - const label = failures.join('; '); - if (isFts && argv['enforce-fts'] !== true) { - console.warn(`SQLite FTS parity warning: ${label}`); - } else { - console.error(`Parity thresholds failed: ${label}`); - process.exit(1); - } - } -} diff --git a/tests/retrieval/pipeline/ann-availability-contract-matrix.test.js b/tests/retrieval/pipeline/ann-availability-contract-matrix.test.js new file mode 100644 index 000000000..12d87d55e --- /dev/null +++ b/tests/retrieval/pipeline/ann-availability-contract-matrix.test.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; +import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { buildAnnPipelineFixture, runAnnFallbackScenario } from './helpers/ann-scenarios.js'; + +applyTestEnv({ testing: '1' }); + +const cases = [ + { + name: 'missing ann providers fall back to sparse retrieval while marking stage warnings', + async run() { + const { outputs, stageTracker } = await runAnnFallbackScenario({ + createAnnProviders: () => new Map(), + runs: 1 + }); + assert.ok(Array.isArray(outputs[0]) && outputs[0].length > 0); + + const annStage = stageTracker.stages.find((entry) => entry.stage === 'ann'); + assert.ok(annStage); + assert.equal(annStage.warned, true); + assert.equal(annStage.providerAvailable, false); + } + }, + { + name: 'failed provider preflight is cached and suppresses ann queries during cooldown', + async run() { + let preflightCalls = 0; + let queryCalls = 0; + const provider = { + id: ANN_PROVIDER_IDS.DENSE, + isAvailable: () => true, + preflight: async () => { + preflightCalls += 1; + return false; + }, + query: async () => { + queryCalls += 1; + return [{ idx: 0, sim: 0.9 }]; + } + }; + + const { stageTracker, context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) + }); + const pipeline = createSearchPipeline(context); + + const realDateNow = Date.now; + let nowMs = realDateNow(); + Date.now = () => nowMs; + try { + const first = await pipeline(idx, 'code', [0.1, 0.2]); + nowMs += 500; + const second = await pipeline(idx, 'code', [0.1, 0.2]); + assert.ok(first.length > 0); + assert.ok(second.length > 0); + } finally { + Date.now = realDateNow; + } + + assert.equal(preflightCalls, 1); + assert.equal(queryCalls, 0); + const lastAnnStage = stageTracker.stages.filter((entry) => entry.stage === 'ann').at(-1); + assert.ok(lastAnnStage); + assert.equal(lastAnnStage.warned, true); + assert.equal(lastAnnStage.providerAvailable, false); + } + }, + { + name: 'non-strict lancedb loads fall back to legacy metadata and directory paths', + async run() { + const root = process.cwd(); + const fixtureRoot = resolveTestCachePath(root, 'ann-availability-contract-matrix-lancedb-fallback'); + const indexDir = path.join(fixtureRoot, 'index-code'); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fs.mkdir(path.join(indexDir, 'dense_vectors.lancedb'), { recursive: true }); + + const compatibilityKey = 'compat-lancedb-nonstrict-fallback'; + const dims = 4; + const manifest = { + version: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + compatibilityKey, + pieces: [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, + { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, + { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' }, + { type: 'embeddings', name: 'dense_vectors', format: 'json', path: 'dense_vectors_uint8.json', count: 1, dims } + ] + }; + + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([{ id: 0, file: 'src/a.js', start: 0, end: 1 }], null, 2)); + await fs.writeFile(path.join(indexDir, 'file_meta.json'), JSON.stringify([{ id: 0, file: 'src/a.js', ext: '.js' }], null, 2)); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify({ + vocab: ['alpha'], + postings: [[[0, 1]]], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + }, null, 2)); + await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ + generatedAt: new Date().toISOString(), + mode: 'code', + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + compatibilityKey, + embeddings: { + ready: true, + pending: false, + embeddingIdentity: { + dims, + model: 'stub-model', + scale: 1, + minVal: -1, + maxVal: 1, + levels: 255 + } + } + }, null, 2)); + await fs.writeFile(path.join(indexDir, '.filelists.json'), JSON.stringify({ + generatedAt: new Date().toISOString(), + scanned: { count: 1, sample: [] }, + skipped: { count: 0, sample: [] } + }, null, 2)); + await fs.writeFile(path.join(indexDir, 'dense_vectors_uint8.json'), JSON.stringify({ + dims, + model: 'stub-model', + scale: 1, + minVal: -1, + maxVal: 1, + levels: 255, + vectors: [new Array(dims).fill(0)] + }, null, 2)); + await fs.writeFile(path.join(indexDir, 'dense_vectors.lancedb.meta.json'), JSON.stringify({ + version: 1, + generatedAt: new Date().toISOString(), + model: 'stub-model', + dims, + count: 1, + metric: 'cosine', + table: 'vectors', + embeddingColumn: 'vector', + idColumn: 'id', + scale: 1, + minVal: -1, + maxVal: 1, + levels: 255 + }, null, 2)); + await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const loaded = await loadSearchIndexes({ + rootDir: fixtureRoot, + userConfig: {}, + searchMode: 'code', + runProse: false, + runExtractedProse: false, + loadExtractedProse: false, + runCode: true, + runRecords: false, + useSqlite: false, + useLmdb: false, + emitOutput: false, + exitOnError: false, + annActive: true, + filtersActive: false, + contextExpansionEnabled: false, + graphRankingEnabled: false, + sqliteFtsRequested: false, + backendLabel: 'memory', + backendForcedTantivy: false, + indexCache: null, + modelIdDefault: null, + fileChargramN: null, + hnswConfig: { enabled: false }, + lancedbConfig: { enabled: true }, + tantivyConfig: { enabled: false }, + strict: false, + loadIndexFromSqlite: () => ({}), + loadIndexFromLmdb: () => ({}), + resolvedDenseVectorMode: 'merged', + requiredArtifacts: new Set(['ann']) + }); + + assert.ok(loaded?.idxCode?.lancedb); + assert.equal(loaded.idxCode.lancedb.available, true); + assert.equal(loaded.idxCode.lancedb.meta?.dims, dims); + assert.equal(path.basename(loaded.idxCode.lancedb.dir || ''), 'dense_vectors.lancedb'); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('ann availability contract matrix test passed'); diff --git a/tests/retrieval/pipeline/ann-fallback-contract-matrix.test.js b/tests/retrieval/pipeline/ann-fallback-contract-matrix.test.js new file mode 100644 index 000000000..145b1bdd4 --- /dev/null +++ b/tests/retrieval/pipeline/ann-fallback-contract-matrix.test.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { getBitmapSize, isRoaringAvailable } from '../../../src/retrieval/bitmap.js'; +import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; +import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + createAvailableDenseAnnProvider, + createInMemorySearchPipeline +} from './helpers/in-memory-search-pipeline-fixture.js'; + +applyTestEnv(); + +const baseChunkMeta = () => ([ + { id: 0, tokens: ['alpha'], weight: 1, file: 'src/a.js', ext: '.js', kind: 'Definition' }, + { id: 1, tokens: ['alpha'], weight: 1, file: 'src/b.js', ext: '.js', kind: 'Definition' }, + { id: 2, tokens: ['gamma'], weight: 1, file: 'src/c.js', ext: '.js', kind: 'Definition' }, + { id: 3, tokens: ['alpha'], weight: 1, file: 'src/d.ts', ext: '.ts', kind: 'Definition' } +]); + +const baseIndex = () => ({ + chunkMeta: baseChunkMeta(), + tokenIndex: { + vocab: ['alpha'], + postings: [ + [[0, 1], [1, 1], [3, 1]] + ], + docLengths: [1, 1, 1, 1], + totalDocs: 4, + avgDocLen: 1 + }, + denseVec: { + vectors: [ + [0.1, 0.1], + [0.2, 0.2], + [0.9, 0.9], + [0.3, 0.3] + ] + }, + minhash: { signatures: [] } +}); + +const createBasePipeline = ({ + provider, + annCandidateCap = 1, + filters = { ext: ['js'] }, + filtersActive = true +}) => createInMemorySearchPipeline({ + provider, + filters, + filtersActive, + topN: 3, + annCandidateCap, +}); + +const sortedCandidateSet = (candidateSet) => ( + candidateSet + ? Array.from(candidateSet).sort((a, b) => a - b) + : null +); + +const runFallbackRetryScenario = async (annCandidateCap) => { + const annCandidateSets = []; + const provider = createAvailableDenseAnnProvider(async ({ candidateSet }) => { + annCandidateSets.push(sortedCandidateSet(candidateSet)); + if (candidateSet && candidateSet.has(2)) { + return [{ idx: 2, sim: 0.95 }]; + } + return []; + }); + const pipeline = createBasePipeline({ provider, annCandidateCap }); + const results = await pipeline(baseIndex(), 'code', [0.4, 0.4]); + return { annCandidateSets, results }; +}; + +const assertFallbackRetryResult = ({ annCandidateSets, results }) => { + assert.equal(annCandidateSets.length, 2); + assert.deepEqual(annCandidateSets[0], [0, 1]); + assert.deepEqual(annCandidateSets[1], [0, 1, 2]); + assert.ok(results.some((entry) => entry.id === 2 && entry.annSource === ANN_PROVIDER_IDS.DENSE)); +}; + +const cases = [ + { + name: 'nonvector mode never initializes ANN providers', + async run() { + let initCount = 0; + const searchPipeline = createInMemorySearchPipeline({ + createAnnProviders: () => { + initCount += 1; + return new Map([[ + 'js', + { id: 'dense', isAvailable: () => true, query: async () => [] } + ]]); + } + }); + const warnings = []; + const originalWarn = console.warn; + console.warn = (msg) => warnings.push(String(msg)); + try { + const results = await searchPipeline({ + chunkMeta: [ + { id: 0, tokens: ['alpha'], weight: 1, file: 'a.js', kind: 'Definition' }, + { id: 1, tokens: ['alpha'], weight: 1, file: 'b.js', kind: 'Definition' } + ], + minhash: { signatures: [] } + }, 'code', null); + assert.equal(initCount, 0); + assert.equal(warnings.length, 0); + assert.ok(Array.isArray(results) && results.length > 0); + } finally { + console.warn = originalWarn; + } + } + }, + { + name: 'fallback retries from filtered BM candidates to full allowed set', + async run() { + assertFallbackRetryResult(await runFallbackRetryScenario(1)); + } + }, + { + name: 'fractional candidate cap still retries after clamp', + async run() { + assertFallbackRetryResult(await runFallbackRetryScenario(0.5)); + } + }, + { + name: 'bitmap allowlist fallback preserves bitmap candidate representation', + async run() { + if (!isRoaringAvailable()) return; + const providerCandidateKinds = []; + const providerCandidateSizes = []; + const hasCandidateId = (candidateSet, id) => { + if (!candidateSet) return false; + if (candidateSet instanceof Set) return candidateSet.has(id); + if (typeof candidateSet.has === 'function') return candidateSet.has(id); + if (typeof candidateSet.contains === 'function') return candidateSet.contains(id); + if (typeof candidateSet.includes === 'function') return candidateSet.includes(id); + return false; + }; + const provider = createAvailableDenseAnnProvider(async ({ candidateSet }) => { + providerCandidateKinds.push(candidateSet instanceof Set ? 'set' : 'bitmap'); + providerCandidateSizes.push(getBitmapSize(candidateSet)); + if (hasCandidateId(candidateSet, 2)) { + return [{ idx: 2, sim: 0.95 }]; + } + return []; + }); + const idx = baseIndex(); + idx.filterIndex = buildFilterIndex(idx.chunkMeta); + idx.filterIndex.bitmap = null; + const pipeline = createBasePipeline({ provider, annCandidateCap: 100 }); + const results = await pipeline(idx, 'code', [0.4, 0.4]); + assert.equal(providerCandidateKinds.length, 2); + assert.equal(providerCandidateKinds[0], 'set'); + assert.equal(providerCandidateKinds[1], 'bitmap'); + assert.deepEqual(providerCandidateSizes, [2, 3]); + assert.ok(results.some((entry) => entry.id === 2 && entry.annSource === ANN_PROVIDER_IDS.DENSE)); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('ann fallback contract matrix test passed'); diff --git a/tests/retrieval/pipeline/ann-fallback-filtered-too-large-fractional-cap.test.js b/tests/retrieval/pipeline/ann-fallback-filtered-too-large-fractional-cap.test.js deleted file mode 100644 index 7cc97eeae..000000000 --- a/tests/retrieval/pipeline/ann-fallback-filtered-too-large-fractional-cap.test.js +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -const annCandidateSets = []; -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - query: async ({ candidateSet }) => { - const resolved = candidateSet - ? Array.from(candidateSet).sort((a, b) => a - b) - : null; - annCandidateSets.push(resolved); - if (candidateSet && candidateSet.has(2)) { - return [{ idx: 2, sim: 0.95 }]; - } - return []; - } -}; - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - relationBoost: { enabled: false }, - filters: { ext: ['js'] }, - filtersActive: true, - filterPredicates: null, - topN: 3, - maxCandidates: 50, - annEnabled: true, - annBackend: ANN_PROVIDER_IDS.DENSE, - annCandidateCap: 0.5, - annCandidateMinDocCount: 1, - annCandidateMaxDocCount: 100, - scoreBlend: { enabled: false }, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); - -const idx = { - chunkMeta: [ - { id: 0, tokens: ['alpha'], weight: 1, file: 'src/a.js', kind: 'Definition' }, - { id: 1, tokens: ['alpha'], weight: 1, file: 'src/b.js', kind: 'Definition' }, - { id: 2, tokens: ['gamma'], weight: 1, file: 'src/c.js', kind: 'Definition' }, - { id: 3, tokens: ['alpha'], weight: 1, file: 'src/d.ts', kind: 'Definition' } - ], - tokenIndex: { - vocab: ['alpha'], - postings: [ - [[0, 1], [1, 1], [3, 1]] - ], - docLengths: [1, 1, 1, 1], - totalDocs: 4, - avgDocLen: 1 - }, - denseVec: { - vectors: [ - [0.1, 0.1], - [0.2, 0.2], - [0.9, 0.9], - [0.3, 0.3] - ] - }, - minhash: { signatures: [] } -}; - -const results = await pipeline(idx, 'code', [0.4, 0.4]); - -assert.equal( - annCandidateSets.length, - 2, - 'expected ANN fallback retry when fractional cap is clamped to one candidate' -); -assert.deepEqual( - annCandidateSets[0], - [0, 1], - 'expected first ANN attempt to use BM-constrained filtered candidates' -); -assert.deepEqual( - annCandidateSets[1], - [0, 1, 2], - 'expected ANN fallback attempt to expand to full allowed filter set' -); -assert.ok( - results.some((entry) => entry.id === 2 && entry.annSource === ANN_PROVIDER_IDS.DENSE), - 'expected fallback ANN hit from allowed set outside BM-derived subset' -); - -console.log('ann fallback filtered-too-large fractional cap test passed'); diff --git a/tests/retrieval/pipeline/ann-fallback-filtered-too-large.test.js b/tests/retrieval/pipeline/ann-fallback-filtered-too-large.test.js deleted file mode 100644 index a24f8f939..000000000 --- a/tests/retrieval/pipeline/ann-fallback-filtered-too-large.test.js +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -const annCandidateSets = []; -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - query: async ({ candidateSet }) => { - const resolved = candidateSet - ? Array.from(candidateSet).sort((a, b) => a - b) - : null; - annCandidateSets.push(resolved); - if (candidateSet && candidateSet.has(2)) { - return [{ idx: 2, sim: 0.95 }]; - } - return []; - } -}; - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - relationBoost: { enabled: false }, - filters: { ext: ['js'] }, - filtersActive: true, - filterPredicates: null, - topN: 3, - maxCandidates: 50, - annEnabled: true, - annBackend: ANN_PROVIDER_IDS.DENSE, - annCandidateCap: 1, - annCandidateMinDocCount: 1, - annCandidateMaxDocCount: 100, - scoreBlend: { enabled: false }, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); - -const idx = { - chunkMeta: [ - { id: 0, tokens: ['alpha'], weight: 1, file: 'src/a.js', kind: 'Definition' }, - { id: 1, tokens: ['alpha'], weight: 1, file: 'src/b.js', kind: 'Definition' }, - { id: 2, tokens: ['gamma'], weight: 1, file: 'src/c.js', kind: 'Definition' }, - { id: 3, tokens: ['alpha'], weight: 1, file: 'src/d.ts', kind: 'Definition' } - ], - tokenIndex: { - vocab: ['alpha'], - postings: [ - [[0, 1], [1, 1], [3, 1]] - ], - docLengths: [1, 1, 1, 1], - totalDocs: 4, - avgDocLen: 1 - }, - denseVec: { - vectors: [ - [0.1, 0.1], - [0.2, 0.2], - [0.9, 0.9], - [0.3, 0.3] - ] - }, - minhash: { signatures: [] } -}; - -const results = await pipeline(idx, 'code', [0.4, 0.4]); - -assert.equal(annCandidateSets.length, 2, 'expected ANN fallback retry for filtered too-large policy'); -assert.deepEqual( - annCandidateSets[0], - [0, 1], - 'expected first ANN attempt to use BM-constrained filtered candidates' -); -assert.deepEqual( - annCandidateSets[1], - [0, 1, 2], - 'expected ANN fallback attempt to expand to full allowed filter set' -); -assert.ok( - results.some((entry) => entry.id === 2 && entry.annSource === ANN_PROVIDER_IDS.DENSE), - 'expected fallback ANN hit from allowed set outside BM-derived subset' -); - -console.log('ann fallback filtered-too-large test passed'); diff --git a/tests/retrieval/pipeline/ann-fallback-nonvector.test.js b/tests/retrieval/pipeline/ann-fallback-nonvector.test.js deleted file mode 100644 index 37b42f7c9..000000000 --- a/tests/retrieval/pipeline/ann-fallback-nonvector.test.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; - -let initCount = 0; -const createAnnProviders = () => { - initCount += 1; - return new Map([ - ['js', { - id: 'dense', - isAvailable: () => true, - query: async () => [] - }] - ]); -}; - -const searchPipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, - topN: 2, - maxCandidates: 50, - annEnabled: true, - annBackend: 'dense', - scoreBlend: { enabled: false }, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - createAnnProviders -}); - -const idx = { - chunkMeta: [ - { id: 0, tokens: ['alpha'], weight: 1, file: 'a.js', kind: 'Definition' }, - { id: 1, tokens: ['alpha'], weight: 1, file: 'b.js', kind: 'Definition' } - ], - minhash: { signatures: [] } -}; - -const warnings = []; -const originalWarn = console.warn; -console.warn = (msg) => warnings.push(String(msg)); - -const results = await searchPipeline(idx, 'code', null); - -console.warn = originalWarn; - -if (initCount !== 0) { - console.error(`ann nonvector fallback failed: expected no provider init, got ${initCount}`); - process.exit(1); -} -if (warnings.length !== 0) { - console.error(`ann nonvector fallback failed: expected no warnings, got ${warnings.length}`); - process.exit(1); -} -if (!Array.isArray(results) || results.length === 0) { - console.error('ann nonvector fallback failed: expected sparse results.'); - process.exit(1); -} - -console.log('ann nonvector fallback test passed'); diff --git a/tests/retrieval/pipeline/ann-fallback-preserves-bitmap-candidates.test.js b/tests/retrieval/pipeline/ann-fallback-preserves-bitmap-candidates.test.js deleted file mode 100644 index fed81d165..000000000 --- a/tests/retrieval/pipeline/ann-fallback-preserves-bitmap-candidates.test.js +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { getBitmapSize, isRoaringAvailable } from '../../../src/retrieval/bitmap.js'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -if (!isRoaringAvailable()) { - console.log('roaring-wasm not available; skipping ann fallback bitmap candidate test'); - process.exit(0); -} - -const providerCandidateKinds = []; -const providerCandidateSizes = []; -const hasCandidateId = (candidateSet, id) => { - if (!candidateSet) return false; - if (candidateSet instanceof Set) return candidateSet.has(id); - if (typeof candidateSet.has === 'function') return candidateSet.has(id); - if (typeof candidateSet.contains === 'function') return candidateSet.contains(id); - if (typeof candidateSet.includes === 'function') return candidateSet.includes(id); - return false; -}; - -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - query: async ({ candidateSet }) => { - providerCandidateKinds.push(candidateSet instanceof Set ? 'set' : 'bitmap'); - providerCandidateSizes.push(getBitmapSize(candidateSet)); - if (hasCandidateId(candidateSet, 2)) { - return [{ idx: 2, sim: 0.95 }]; - } - return []; - } -}; - -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - relationBoost: { enabled: false }, - filters: { ext: ['js'] }, - filtersActive: true, - filterPredicates: null, - topN: 3, - maxCandidates: 50, - annEnabled: true, - annBackend: ANN_PROVIDER_IDS.DENSE, - annCandidateCap: 100, - annCandidateMinDocCount: 1, - annCandidateMaxDocCount: 100, - scoreBlend: { enabled: false }, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); - -const chunkMeta = [ - { id: 0, tokens: ['alpha'], weight: 1, file: 'src/a.js', ext: '.js', kind: 'Definition' }, - { id: 1, tokens: ['alpha'], weight: 1, file: 'src/b.js', ext: '.js', kind: 'Definition' }, - { id: 2, tokens: ['gamma'], weight: 1, file: 'src/c.js', ext: '.js', kind: 'Definition' }, - { id: 3, tokens: ['alpha'], weight: 1, file: 'src/d.ts', ext: '.ts', kind: 'Definition' } -]; -const filterIndex = buildFilterIndex(chunkMeta); -// Force dynamic bitmap output in filterChunkIds by removing bitmap min-size metadata. -filterIndex.bitmap = null; - -const idx = { - chunkMeta, - tokenIndex: { - vocab: ['alpha'], - postings: [ - [[0, 1], [1, 1], [3, 1]] - ], - docLengths: [1, 1, 1, 1], - totalDocs: 4, - avgDocLen: 1 - }, - filterIndex, - denseVec: { - vectors: [ - [0.1, 0.1], - [0.2, 0.2], - [0.9, 0.9], - [0.3, 0.3] - ] - }, - minhash: { signatures: [] } -}; - -const results = await pipeline(idx, 'code', [0.4, 0.4]); - -assert.equal(providerCandidateKinds.length, 2, 'expected fallback ANN retry under active filters'); -assert.equal(providerCandidateKinds[0], 'set', 'expected primary ANN query to use BM-constrained Set candidates'); -assert.equal(providerCandidateKinds[1], 'bitmap', 'expected ANN fallback query to keep bitmap allowlist candidates'); -assert.deepEqual(providerCandidateSizes, [2, 3], 'expected fallback candidate sizes to reflect BM and filtered allowlist cohorts'); -assert.ok( - results.some((entry) => entry.id === 2 && entry.annSource === ANN_PROVIDER_IDS.DENSE), - 'expected fallback ANN hit from bitmap allowlist expansion' -); - -console.log('ann fallback preserves bitmap candidate set test passed'); diff --git a/tests/retrieval/pipeline/ann-optional-skip.test.js b/tests/retrieval/pipeline/ann-optional-skip.test.js deleted file mode 100644 index 187456c6f..000000000 --- a/tests/retrieval/pipeline/ann-optional-skip.test.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { runAnnFallbackScenario } from './helpers/ann-scenarios.js'; - -const scenario = 'ann-missing-provider-fallback'; -const { outputs, stageTracker } = await runAnnFallbackScenario({ - createAnnProviders: () => new Map(), - runs: 1 -}); -const results = outputs[0]; - -assert.ok(Array.isArray(results) && results.length > 0, 'expected sparse results to return'); - -const annStage = stageTracker.stages.find((entry) => entry.stage === 'ann'); -assert.ok(annStage, 'expected ann stage'); -assert.equal(annStage.warned, true, 'expected ann fallback warning'); -assert.equal(annStage.providerAvailable, false, 'expected ann provider to be unavailable'); - -console.log(`${scenario} test passed`); diff --git a/tests/retrieval/pipeline/ann-preflight.test.js b/tests/retrieval/pipeline/ann-preflight.test.js deleted file mode 100644 index 0b6aa8e61..000000000 --- a/tests/retrieval/pipeline/ann-preflight.test.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { buildAnnPipelineFixture } from './helpers/ann-scenarios.js'; - -let preflightCalls = 0; -let queryCalls = 0; - -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - preflight: async () => { - preflightCalls += 1; - return false; - }, - query: async () => { - queryCalls += 1; - return [{ idx: 0, sim: 0.9 }]; - } -}; -const scenario = 'ann-provider-preflight-failure-fallback'; -const { stageTracker, context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); -const pipeline = createSearchPipeline(context); - -const realDateNow = Date.now; -let nowMs = realDateNow(); -Date.now = () => nowMs; -let results = []; -let resultsAgain = []; -try { - results = await pipeline(idx, 'code', [0.1, 0.2]); - // Advance within provider cooldown window so failed preflight stays cached. - nowMs += 500; - resultsAgain = await pipeline(idx, 'code', [0.1, 0.2]); -} finally { - Date.now = realDateNow; -} - -assert.ok(results.length > 0, 'expected sparse fallback results'); -assert.ok(resultsAgain.length > 0, 'expected sparse fallback results on second run'); -assert.equal(preflightCalls, 1, 'expected preflight to be reused from cache after failure'); -assert.equal(queryCalls, 0, 'expected ANN query to be skipped after preflight failure'); - -const annStages = stageTracker.stages.filter((entry) => entry.stage === 'ann'); -const lastAnnStage = annStages[annStages.length - 1]; -assert.ok(lastAnnStage, 'expected ann stage to be recorded'); -assert.equal(lastAnnStage.warned, true, 'expected ann fallback warning'); -assert.equal(lastAnnStage.providerAvailable, false, 'expected provider to be unavailable after preflight failure'); - -console.log(`${scenario} test passed`); diff --git a/tests/retrieval/pipeline/artifact-gating-deps.test.js b/tests/retrieval/pipeline/artifact-gating-deps.test.js deleted file mode 100644 index 8f13aa2f8..000000000 --- a/tests/retrieval/pipeline/artifact-gating-deps.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { - REQUIRED_ARTIFACTS, - resolveRequiredArtifacts -} from '../../../src/retrieval/cli/required-artifacts.js'; -import { buildTestPlan, createPlanInputs } from './query-plan-helpers.js'; - -applyTestEnv(); - -const inputs = createPlanInputs(); -const plan = buildTestPlan(inputs); -const required = resolveRequiredArtifacts({ - queryPlan: plan, - contextExpansionEnabled: true, - contextExpansionOptions: { - includeCalls: false, - includeImports: false, - includeUsages: false, - includeExports: true - }, - contextExpansionRespectFilters: true, - graphRankingEnabled: false, - annActive: false -}); - -assert.ok(required.has(REQUIRED_ARTIFACTS.REPO_MAP), 'expected repoMap requirement'); -assert.ok(required.has(REQUIRED_ARTIFACTS.GRAPH_RELATIONS), 'expected graphRelations dependency'); - -console.log('artifact gating dependencies test passed'); diff --git a/tests/retrieval/pipeline/artifact-gating.test.js b/tests/retrieval/pipeline/artifact-gating.test.js index 4b358dff8..affb548ea 100644 --- a/tests/retrieval/pipeline/artifact-gating.test.js +++ b/tests/retrieval/pipeline/artifact-gating.test.js @@ -9,43 +9,76 @@ import { buildTestPlan, createPlanInputs } from './query-plan-helpers.js'; applyTestEnv(); -const importInputs = createPlanInputs({ searchImport: 'react' }); -const importPlan = buildTestPlan(importInputs); -const importRequired = resolveRequiredArtifacts({ - queryPlan: importPlan, - contextExpansionEnabled: false, - contextExpansionRespectFilters: true, - graphRankingEnabled: false, - annActive: false -}); - -assert.ok(importRequired.has(REQUIRED_ARTIFACTS.FILTER_INDEX), 'expected filterIndex requirement'); -assert.ok(importRequired.has(REQUIRED_ARTIFACTS.FILE_RELATIONS), 'expected fileRelations requirement'); -assert.ok(!importRequired.has(REQUIRED_ARTIFACTS.REPO_MAP), 'did not expect repoMap requirement'); - -const contextInputs = createPlanInputs(); -const contextPlan = buildTestPlan(contextInputs); -const contextRequired = resolveRequiredArtifacts({ - queryPlan: contextPlan, - contextExpansionEnabled: true, - contextExpansionOptions: {}, - contextExpansionRespectFilters: true, - graphRankingEnabled: false, - annActive: false -}); - -assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.REPO_MAP), 'expected repoMap requirement'); -assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.GRAPH_RELATIONS), 'expected graphRelations requirement'); -assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.FILE_RELATIONS), 'expected fileRelations requirement'); -assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.CONTEXT_INDEX), 'expected contextIndex requirement'); - -const annRequired = resolveRequiredArtifacts({ - queryPlan: contextPlan, - contextExpansionEnabled: false, - graphRankingEnabled: false, - annActive: true -}); - -assert.ok(annRequired.has(REQUIRED_ARTIFACTS.ANN), 'expected ann requirement'); +const cases = [ + { + name: 'imports, context expansion, and ann each request the right artifacts', + run() { + const importInputs = createPlanInputs({ searchImport: 'react' }); + const importPlan = buildTestPlan(importInputs); + const importRequired = resolveRequiredArtifacts({ + queryPlan: importPlan, + contextExpansionEnabled: false, + contextExpansionRespectFilters: true, + graphRankingEnabled: false, + annActive: false + }); + + assert.ok(importRequired.has(REQUIRED_ARTIFACTS.FILTER_INDEX)); + assert.ok(importRequired.has(REQUIRED_ARTIFACTS.FILE_RELATIONS)); + assert.ok(!importRequired.has(REQUIRED_ARTIFACTS.REPO_MAP)); + + const contextInputs = createPlanInputs(); + const contextPlan = buildTestPlan(contextInputs); + const contextRequired = resolveRequiredArtifacts({ + queryPlan: contextPlan, + contextExpansionEnabled: true, + contextExpansionOptions: {}, + contextExpansionRespectFilters: true, + graphRankingEnabled: false, + annActive: false + }); + + assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.REPO_MAP)); + assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.GRAPH_RELATIONS)); + assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.FILE_RELATIONS)); + assert.ok(contextRequired.has(REQUIRED_ARTIFACTS.CONTEXT_INDEX)); + + const annRequired = resolveRequiredArtifacts({ + queryPlan: contextPlan, + contextExpansionEnabled: false, + graphRankingEnabled: false, + annActive: true + }); + assert.ok(annRequired.has(REQUIRED_ARTIFACTS.ANN)); + } + }, + { + name: 'artifact dependency closure keeps graph relations for export-only expansion', + run() { + const inputs = createPlanInputs(); + const plan = buildTestPlan(inputs); + const required = resolveRequiredArtifacts({ + queryPlan: plan, + contextExpansionEnabled: true, + contextExpansionOptions: { + includeCalls: false, + includeImports: false, + includeUsages: false, + includeExports: true + }, + contextExpansionRespectFilters: true, + graphRankingEnabled: false, + annActive: false + }); + + assert.ok(required.has(REQUIRED_ARTIFACTS.REPO_MAP)); + assert.ok(required.has(REQUIRED_ARTIFACTS.GRAPH_RELATIONS)); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} console.log('artifact gating test passed'); diff --git a/tests/retrieval/pipeline/candidates-buffer-reuse.test.js b/tests/retrieval/pipeline/candidates-buffer-reuse.test.js index 2541d2e5c..51f4aa838 100644 --- a/tests/retrieval/pipeline/candidates-buffer-reuse.test.js +++ b/tests/retrieval/pipeline/candidates-buffer-reuse.test.js @@ -1,24 +1,20 @@ #!/usr/bin/env node import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { createCandidatePool } from '../../../src/retrieval/pipeline/candidate-pool.js'; import { createScoreBufferPool } from '../../../src/retrieval/pipeline/score-buffer.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; applyTestEnv(); const candidatePool = createCandidatePool({ maxSets: 2, maxEntries: 100 }); const scoreBufferPool = createScoreBufferPool({ maxBuffers: 2, maxEntries: 100 }); -const context = { - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', +const pipeline = createSearchPipelineFixture({ sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, postingsConfig: { enablePhraseNgrams: false, enableChargrams: false, @@ -27,72 +23,18 @@ const context = { chargramMinN: 3, chargramMaxN: 3 }, - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, explain: false, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, topN: 2, maxCandidates: null, - annEnabled: false, annBackend: 'js', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - hnswAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - lanceAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, graphRankingConfig: { enabled: false }, candidatePool, scoreBufferPool -}; +}); -const idx = { - chunkMeta: [ +const idx = createAlphaSearchIndex({ + chunks: [ { id: 0, file: 'src/a.js', tokens: ['alpha'], weight: 1 }, { id: 1, file: 'src/b.js', tokens: ['alpha', 'beta'], weight: 1 } ], @@ -105,26 +47,57 @@ const idx = { docLengths: [1, 2], totalDocs: 2, avgDocLen: 1.5 - }, - filterIndex: null, - fileRelations: null, - repoMap: null, - minhash: null -}; + } +}); + +const cases = [ + { + name: 'pipeline reuse avoids new candidate and score buffer allocations', + async run() { + await pipeline(idx, 'code', null); + const allocationsAfterFirst = { + candidate: candidatePool.stats.allocations, + score: scoreBufferPool.stats.allocations + }; -const pipeline = createSearchPipeline(context); + await pipeline(idx, 'code', null); + + assert.ok(candidatePool.stats.reuses > 0, 'expected candidate pool reuse'); + assert.ok(scoreBufferPool.stats.reuses > 0, 'expected score buffer reuse'); + assert.equal(candidatePool.stats.allocations, allocationsAfterFirst.candidate, 'no extra candidate allocations'); + assert.equal(scoreBufferPool.stats.allocations, allocationsAfterFirst.score, 'no extra score allocations'); + } + }, + { + name: 'pool release drops oversized objects and clears reused state', + async run() { + const tinyCandidatePool = createCandidatePool({ maxSets: 1, maxEntries: 2 }); + const oversized = tinyCandidatePool.acquire(); + oversized.add(1); + oversized.add(2); + oversized.add(3); + tinyCandidatePool.release(oversized); + assert.ok(tinyCandidatePool.stats.drops > 0); -await pipeline(idx, 'code', null); -const allocationsAfterFirst = { - candidate: candidatePool.stats.allocations, - score: scoreBufferPool.stats.allocations -}; + const reused = tinyCandidatePool.acquire(); + assert.equal(reused.size, 0); + tinyCandidatePool.release(reused); -await pipeline(idx, 'code', null); + const tinyScorePool = createScoreBufferPool({ maxBuffers: 1, maxEntries: 2 }); + const buffer = tinyScorePool.acquire({ + fields: ['idx', 'score'], + numericFields: ['idx', 'score'], + capacity: 5 + }); + buffer.push({ idx: 1, score: 0.1 }); + tinyScorePool.release(buffer); + assert.ok(tinyScorePool.stats.drops > 0); + } + } +]; -assert.ok(candidatePool.stats.reuses > 0, 'expected candidate pool reuse'); -assert.ok(scoreBufferPool.stats.reuses > 0, 'expected score buffer reuse'); -assert.equal(candidatePool.stats.allocations, allocationsAfterFirst.candidate, 'no extra candidate allocations'); -assert.equal(scoreBufferPool.stats.allocations, allocationsAfterFirst.score, 'no extra score allocations'); +for (const testCase of cases) { + await testCase.run(); +} console.log('candidates buffer reuse test passed'); diff --git a/tests/retrieval/pipeline/candidates-early-cutoff.test.js b/tests/retrieval/pipeline/candidates-early-cutoff.test.js deleted file mode 100644 index 3a28154bd..000000000 --- a/tests/retrieval/pipeline/candidates-early-cutoff.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import { createTopKReducer, compareTopKEntries } from '../../../src/retrieval/pipeline/topk.js'; - -const total = 2000; -const k = 8; - -const items = []; -for (let i = 0; i < total; i += 1) { - items.push({ idx: i, score: total - i, sourceRank: i }); -} - -const reducer = createTopKReducer({ - k, - sorted: true -}); - -for (const item of items) { - const stop = reducer.pushRaw(item.score, item.idx, item.sourceRank); - if (stop) break; -} - -const result = reducer.finish({ limit: k }); -const baseline = items - .slice() - .sort((a, b) => compareTopKEntries( - { score: a.score, id: a.idx, sourceRank: a.sourceRank }, - { score: b.score, id: b.idx, sourceRank: b.sourceRank } - )) - .slice(0, k); - -if (reducer.stats.cutoffs <= 0) { - console.error('candidates early cutoff failed: cutoff not triggered.'); - process.exit(1); -} -if (result.length !== baseline.length) { - console.error('candidates early cutoff failed: length mismatch.'); - process.exit(1); -} -for (let i = 0; i < result.length; i += 1) { - if (result[i].idx !== baseline[i].idx || result[i].score !== baseline[i].score) { - console.error('candidates early cutoff failed: result mismatch.'); - process.exit(1); - } -} - -console.log('candidates early cutoff test passed'); diff --git a/tests/retrieval/pipeline/candidates-memory-plateau.test.js b/tests/retrieval/pipeline/candidates-memory-plateau.test.js deleted file mode 100644 index caedae594..000000000 --- a/tests/retrieval/pipeline/candidates-memory-plateau.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import { createTopKReducer } from '../../../src/retrieval/pipeline/topk.js'; - -const total = 50000; -const k = 10; -const slack = 5; - -const reducer = createTopKReducer({ - k, - slack, - sorted: true -}); - -for (let i = 0; i < total; i += 1) { - const score = total - i; - const stop = reducer.pushRaw(score, i, i); - if (stop) break; -} - -const stats = reducer.stats; -if (!stats.usedHeap) { - console.error('candidates memory plateau failed: heap path not used.'); - process.exit(1); -} -if (stats.maxSize > k + slack) { - console.error(`candidates memory plateau failed: heap grew to ${stats.maxSize}.`); - process.exit(1); -} - -console.log('candidates memory plateau test passed'); diff --git a/tests/retrieval/pipeline/extracted-prose-optional-sqlite-fallback.test.js b/tests/retrieval/pipeline/extracted-prose-optional-sqlite-fallback.test.js index 7831bed22..530a2efd4 100644 --- a/tests/retrieval/pipeline/extracted-prose-optional-sqlite-fallback.test.js +++ b/tests/retrieval/pipeline/extracted-prose-optional-sqlite-fallback.test.js @@ -1,61 +1,22 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { + createOptionalExtractedProseRoot, + loadOptionalExtractedProseIndexes, + writeOptionalExtractedProseIndexPair +} from './helpers/optional-extracted-prose-index-fixture.js'; applyTestEnv(); -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-extracted-sqlite-fallback-')); +const rootDir = await createOptionalExtractedProseRoot('poc-extracted-sqlite-fallback-'); const compatibilityKey = 'compat-extracted-sqlite-fallback'; - -/** - * Write a minimal index artifact bundle for one mode. - * - * This keeps fallback loading deterministic when sqlite read paths fail. - * - * @param {string} mode - * @param {object[]} chunkMeta - * @returns {Promise} - */ -const writeModeIndex = async (mode, chunkMeta) => { - const indexDir = path.join(rootDir, `index-${mode}`); - await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - const indexState = { - generatedAt: new Date().toISOString(), - mode, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey - }; - const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 - }; - const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } - ] - }; - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8'); -}; - -await writeModeIndex('code', [{ id: 11, file: 'src/code.js', start: 0, end: 4 }]); -await writeModeIndex('extracted-prose', [{ id: 22, file: 'docs/notes.md', start: 0, end: 4 }]); +await writeOptionalExtractedProseIndexPair(rootDir, { + codeCompatibilityKey: compatibilityKey, + codeChunkMeta: [{ id: 11, file: 'src/code.js', start: 0, end: 4 }], + extractedProseChunkMeta: [{ id: 22, file: 'docs/notes.md', start: 0, end: 4 }] +}); const sqliteCalls = []; /** @@ -87,33 +48,10 @@ const loadIndexFromSqlite = (mode, options) => { throw new Error(`unexpected sqlite mode ${mode}`); }; -const loaded = await loadSearchIndexes({ - rootDir, - userConfig: {}, - searchMode: 'code', - runProse: false, - runExtractedProse: false, - loadExtractedProse: true, - runCode: true, - runRecords: false, +const loaded = await loadOptionalExtractedProseIndexes(rootDir, { useSqlite: true, - useLmdb: false, - emitOutput: false, - exitOnError: false, - annActive: false, - filtersActive: false, - contextExpansionEnabled: false, - graphRankingEnabled: false, sqliteFtsRequested: true, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, lancedbConfig: { enabled: false }, - tantivyConfig: { enabled: false }, - strict: true, requiredArtifacts: new Set(), loadIndexFromSqlite, loadIndexFromLmdb: () => { diff --git a/tests/retrieval/pipeline/extracted-prose-optional-strict-compatibility-mismatch.test.js b/tests/retrieval/pipeline/extracted-prose-optional-strict-compatibility-mismatch.test.js index e84b0b7f6..f0fa3b255 100644 --- a/tests/retrieval/pipeline/extracted-prose-optional-strict-compatibility-mismatch.test.js +++ b/tests/retrieval/pipeline/extracted-prose-optional-strict-compatibility-mismatch.test.js @@ -1,107 +1,31 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { + assertOptionalExtractedProseDisabled, + createOptionalExtractedProseRoot, + loadOptionalExtractedProseIndexesWithWarnings, + writeOptionalExtractedProseIndexPair +} from './helpers/optional-extracted-prose-index-fixture.js'; applyTestEnv(); -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-optional-extracted-strict-compat-mismatch-')); +const rootDir = await createOptionalExtractedProseRoot('poc-optional-extracted-strict-compat-mismatch-'); -const writeModeIndex = async (mode, compatibilityKey, { chunkMeta = [] } = {}) => { - const indexDir = path.join(rootDir, `index-${mode}`); - await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - const indexState = { - generatedAt: new Date().toISOString(), - mode, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey - }; - const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 - }; - const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } - ] - }; - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2), 'utf8'); - await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8'); -}; - -await writeModeIndex('code', 'compat-cohort-a', { - chunkMeta: [{ id: 0, file: 'src/a.js', start: 0, end: 4 }] -}); -await writeModeIndex('extracted-prose', 'compat-cohort-b', { - chunkMeta: [{ id: 1, file: 'docs/a.md', start: 0, end: 4 }] +await writeOptionalExtractedProseIndexPair(rootDir, { + codeCompatibilityKey: 'compat-cohort-a', + extractedProseCompatibilityKey: 'compat-cohort-b', + codeChunkMeta: [{ id: 0, file: 'src/a.js', start: 0, end: 4 }], + extractedProseChunkMeta: [{ id: 1, file: 'docs/a.md', start: 0, end: 4 }] }); -const warnings = []; -const originalWarn = console.warn; -let loaded; -try { - console.warn = (message) => warnings.push(String(message || '')); - loaded = await loadSearchIndexes({ - rootDir, - userConfig: {}, - searchMode: 'code', - runProse: false, - runExtractedProse: false, - loadExtractedProse: true, - runCode: true, - runRecords: false, - useSqlite: false, - useLmdb: false, - emitOutput: false, - exitOnError: false, - annActive: false, - filtersActive: false, - contextExpansionEnabled: false, - graphRankingEnabled: false, - sqliteFtsRequested: false, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, - lancedbConfig: { enabled: true }, - tantivyConfig: { enabled: false }, - strict: true, - loadIndexFromSqlite: () => ({}), - loadIndexFromLmdb: () => ({}), - resolvedDenseVectorMode: 'merged' - }); -} finally { - console.warn = originalWarn; -} +const { loaded, warnings } = await loadOptionalExtractedProseIndexesWithWarnings(rootDir); -assert.equal(loaded.runExtractedProse, false, 'optional extracted-prose run flag should remain disabled'); -assert.equal( - loaded.extractedProseLoaded, - false, - 'strict optional extracted-prose with cohort mismatch should be disabled' -); assert.equal(loaded.idxCode.chunkMeta.length, 1, 'expected primary code index to remain available'); -assert.deepEqual( - loaded.idxExtractedProse?.chunkMeta || [], - [], - 'expected optional extracted-prose index to remain empty when disabled' +assertOptionalExtractedProseDisabled( + loaded, + 'strict optional extracted-prose with cohort mismatch should be disabled' ); assert.equal(warnings.length, 0, 'did not expect optional extracted-prose warnings when emitOutput=false'); diff --git a/tests/retrieval/pipeline/extracted-prose-optional-strict-manifest-fallback.test.js b/tests/retrieval/pipeline/extracted-prose-optional-strict-manifest-fallback.test.js index 2bd294d2d..de92d4779 100644 --- a/tests/retrieval/pipeline/extracted-prose-optional-strict-manifest-fallback.test.js +++ b/tests/retrieval/pipeline/extracted-prose-optional-strict-manifest-fallback.test.js @@ -1,104 +1,31 @@ #!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { + assertOptionalExtractedProseDisabled, + createOptionalExtractedProseRoot, + loadOptionalExtractedProseIndexes, + writeLegacyChunkMetaIndex, + writeModeIndex +} from './helpers/optional-extracted-prose-index-fixture.js'; applyTestEnv(); -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-optional-extracted-strict-')); - -const codeIndexDir = path.join(rootDir, 'index-code'); -await fs.mkdir(path.join(codeIndexDir, 'pieces'), { recursive: true }); +const rootDir = await createOptionalExtractedProseRoot('poc-optional-extracted-strict-'); const compatibilityKey = 'compat-optional-extracted-strict'; const chunkMeta = [{ id: 0, file: 'src/a.js', start: 0, end: 8 }]; -const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 -}; -const indexState = { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey -}; -const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } - ] -}; - -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); -await fs.writeFile(path.join(codeIndexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2)); -await fs.writeFile(path.join(codeIndexDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); -await fs.writeFile(path.join(codeIndexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2)); +await writeModeIndex(rootDir, 'code', compatibilityKey, { chunkMeta }); // Create a legacy extracted-prose directory without a pieces manifest. // It is discoverable by hasIndexMeta (chunk_meta exists), but optional // comment-join loading must not fail strict runs for this legacy layout. -const extractedProseDir = path.join(rootDir, 'index-extracted-prose'); -await fs.mkdir(extractedProseDir, { recursive: true }); -await fs.writeFile(path.join(extractedProseDir, 'chunk_meta.json'), JSON.stringify([], null, 2)); +await writeLegacyChunkMetaIndex(rootDir, 'extracted-prose'); -const loaded = await loadSearchIndexes({ - rootDir, - userConfig: {}, - searchMode: 'code', - runProse: false, - runExtractedProse: false, - loadExtractedProse: true, - runCode: true, - runRecords: false, - useSqlite: false, - useLmdb: false, - emitOutput: false, - exitOnError: false, - annActive: false, - filtersActive: false, - contextExpansionEnabled: false, - graphRankingEnabled: false, - sqliteFtsRequested: false, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, - lancedbConfig: { enabled: true }, - tantivyConfig: { enabled: false }, - strict: true, - loadIndexFromSqlite: () => ({}), - loadIndexFromLmdb: () => ({}), - resolvedDenseVectorMode: 'merged' -}); +const loaded = await loadOptionalExtractedProseIndexes(rootDir); -assert.equal( - loaded.runExtractedProse, - false, - 'expected optional extracted-prose mode to remain disabled' -); -assert.equal( - loaded.extractedProseLoaded, - false, +assertOptionalExtractedProseDisabled( + loaded, 'expected strict optional extracted-prose load to skip legacy indexes without manifest' ); -assert.deepEqual( - loaded.idxExtractedProse?.chunkMeta || [], - [], - 'expected skipped optional extracted-prose load to keep empty index payload' -); console.log('optional extracted-prose strict manifest fallback test passed'); diff --git a/tests/retrieval/pipeline/filter-bitmap-contract-matrix.test.js b/tests/retrieval/pipeline/filter-bitmap-contract-matrix.test.js new file mode 100644 index 000000000..d5320167e --- /dev/null +++ b/tests/retrieval/pipeline/filter-bitmap-contract-matrix.test.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { bitmapToArray, getBitmapSize, isRoaringAvailable } from '../../../src/retrieval/bitmap.js'; +import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; +import { compileFilterPredicates } from '../../../src/retrieval/output/filters.js'; +import { filterChunks, filterChunkIds } from '../../../src/retrieval/output.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const createFilterContractMeta = ({ + thirdFile, + thirdExt, + thirdKind = 'FunctionDeclaration', + thirdAuthor = 'Alice', + thirdVisibility = 'public', + thirdLang +}) => [ + { + id: 0, + file: 'src/a.js', + ext: '.js', + kind: 'FunctionDeclaration', + last_author: 'Alice', + chunk_authors: ['Alice'], + docmeta: { visibility: 'public' }, + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 1, + file: 'src/b.js', + ext: '.js', + kind: 'ClassDeclaration', + last_author: 'Bob', + chunk_authors: ['Bob'], + docmeta: { visibility: 'private' }, + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 2, + file: thirdFile, + ext: thirdExt, + kind: thirdKind, + last_author: thirdAuthor, + chunk_authors: [thirdAuthor], + docmeta: { visibility: thirdVisibility }, + metaV2: { lang: thirdLang, effective: { languageId: thirdLang } } + } +]; + +const toSortedAllowedIds = (allowed, meta) => { + const allowedIds = allowed == null + ? meta.map((entry) => entry.id) + : (allowed instanceof Set ? Array.from(allowed) : bitmapToArray(allowed)); + allowedIds.sort((a, b) => a - b); + return allowedIds; +}; + +const cases = [ + { + name: 'bitmap allowlist matches filterChunks results', + run() { + const meta = createFilterContractMeta({ + thirdFile: 'src/c.py', + thirdExt: '.py', + thirdLang: 'python' + }); + const index = buildFilterIndex(meta); + const filters = { ext: '.js', author: 'alice' }; + const expected = filterChunks(meta, filters, index) + .map((entry) => entry.id) + .sort((a, b) => a - b); + const allowed = filterChunkIds(meta, filters, index, null, { preferBitmap: true }); + assert.deepEqual(toSortedAllowedIds(allowed, meta), expected); + } + }, + { + name: 'short-circuit returns null for no narrowing and empty allowlist for no matches', + run() { + const meta = [ + { + id: 0, + file: 'src/a.js', + ext: '.js', + kind: 'FunctionDeclaration', + last_author: 'Alice', + chunk_authors: ['Alice'], + docmeta: { visibility: 'public' }, + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + }, + { + id: 1, + file: 'src/b.py', + ext: '.py', + kind: 'ClassDeclaration', + last_author: 'Bob', + chunk_authors: ['Bob'], + docmeta: { visibility: 'private' }, + metaV2: { lang: 'python', effective: { languageId: 'python' } } + } + ]; + const index = buildFilterIndex(meta); + const allResult = filterChunkIds(meta, {}, index); + assert.equal(allResult, null); + const noneResult = filterChunkIds(meta, { ext: '.rs' }, index, null, { preferBitmap: true }); + const noneCount = noneResult ? getBitmapSize(noneResult) : 0; + assert.equal(noneCount, 0); + } + }, + { + name: 'bitmap threshold switches between bitmap and Set outputs', + run() { + if (!isRoaringAvailable()) return; + const meta = Array.from({ length: 12 }, (_, idx) => ({ + id: idx, + file: `src/${idx}.js`, + ext: '.js', + kind: 'FunctionDeclaration', + last_author: 'Alice', + chunk_authors: ['Alice'], + docmeta: { visibility: 'public' }, + metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } + })); + const index = buildFilterIndex(meta); + const filters = { ext: '.js' }; + const bitmapResult = filterChunkIds(meta, filters, index, null, { + preferBitmap: true, + bitmapMinSize: 4 + }); + assert.ok(bitmapResult && !(bitmapResult instanceof Set)); + assert.equal(getBitmapSize(bitmapResult), meta.length); + const setResult = filterChunkIds(meta, filters, index, null, { + preferBitmap: true, + bitmapMinSize: 20 + }); + assert.ok(setResult instanceof Set); + assert.equal(setResult.size, meta.length); + } + }, + { + name: 'compiled filter predicates stay reusable while matching chunk filtering results', + run() { + const meta = createFilterContractMeta({ + thirdFile: 'tests/c.ts', + thirdExt: '.ts', + thirdLang: 'typescript' + }); + const index = buildFilterIndex(meta); + const filters = { + file: '/src/.*\\.js$/', + ext: '.js', + caseFile: false + }; + + const compiled = compileFilterPredicates(filters, { fileChargramN: 3 }); + const matcherRef = compiled.fileMatchers; + + const expected = filterChunks(meta, filters, index) + .map((entry) => entry.id) + .sort((a, b) => a - b); + + const allowed = filterChunkIds(meta, filters, index, null, { compiled, preferBitmap: true }); + assert.deepEqual(toSortedAllowedIds(allowed, meta), expected); + assert.equal(compiled.fileMatchers, matcherRef); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('filter bitmap contract matrix test passed'); diff --git a/tests/retrieval/pipeline/filter-bitmap-equivalence.test.js b/tests/retrieval/pipeline/filter-bitmap-equivalence.test.js deleted file mode 100644 index c56aba78a..000000000 --- a/tests/retrieval/pipeline/filter-bitmap-equivalence.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunks, filterChunkIds } from '../../../src/retrieval/output.js'; -import { bitmapToArray } from '../../../src/retrieval/bitmap.js'; - -applyTestEnv(); - -const meta = [ - { - id: 0, - file: 'src/a.js', - ext: '.js', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 1, - file: 'src/b.js', - ext: '.js', - kind: 'ClassDeclaration', - last_author: 'Bob', - chunk_authors: ['Bob'], - docmeta: { visibility: 'private' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 2, - file: 'src/c.py', - ext: '.py', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'python', effective: { languageId: 'python' } } - } -]; - -const index = buildFilterIndex(meta); -const filters = { ext: '.js', author: 'alice' }; - -const expected = filterChunks(meta, filters, index) - .map((entry) => entry.id) - .sort((a, b) => a - b); - -const allowed = filterChunkIds(meta, filters, index, null, { preferBitmap: true }); -const allowedIds = allowed == null - ? meta.map((entry) => entry.id) - : (allowed instanceof Set ? Array.from(allowed) : bitmapToArray(allowed)); - -allowedIds.sort((a, b) => a - b); -assert.deepEqual(allowedIds, expected, 'bitmap allowlist should match filterChunks results'); - -console.log('filter bitmap equivalence test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/filter-bitmap-shortcircuit.test.js b/tests/retrieval/pipeline/filter-bitmap-shortcircuit.test.js deleted file mode 100644 index f6cb3ff94..000000000 --- a/tests/retrieval/pipeline/filter-bitmap-shortcircuit.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunkIds } from '../../../src/retrieval/output.js'; -import { getBitmapSize } from '../../../src/retrieval/bitmap.js'; - -applyTestEnv(); - -const meta = [ - { - id: 0, - file: 'src/a.js', - ext: '.js', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 1, - file: 'src/b.py', - ext: '.py', - kind: 'ClassDeclaration', - last_author: 'Bob', - chunk_authors: ['Bob'], - docmeta: { visibility: 'private' }, - metaV2: { lang: 'python', effective: { languageId: 'python' } } - } -]; - -const index = buildFilterIndex(meta); - -const allResult = filterChunkIds(meta, {}, index); -assert.equal(allResult, null, 'expected null allowlist when filters do not narrow'); - -const noneResult = filterChunkIds(meta, { ext: '.rs' }, index, null, { preferBitmap: true }); -const noneCount = noneResult ? getBitmapSize(noneResult) : 0; -assert.equal(noneCount, 0, 'expected empty allowlist for non-matching filters'); - -console.log('filter bitmap shortcircuit test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/filter-bitmap-threshold.test.js b/tests/retrieval/pipeline/filter-bitmap-threshold.test.js deleted file mode 100644 index d70308f31..000000000 --- a/tests/retrieval/pipeline/filter-bitmap-threshold.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { filterChunkIds } from '../../../src/retrieval/output.js'; -import { getBitmapSize, isRoaringAvailable } from '../../../src/retrieval/bitmap.js'; - -applyTestEnv(); - -if (!isRoaringAvailable()) { - console.log('roaring-wasm not available; skipping bitmap threshold test'); - process.exit(0); -} - -const meta = Array.from({ length: 12 }, (_, idx) => ({ - id: idx, - file: `src/${idx}.js`, - ext: '.js', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } -})); - -const index = buildFilterIndex(meta); -const filters = { ext: '.js' }; - -const bitmapResult = filterChunkIds(meta, filters, index, null, { preferBitmap: true, bitmapMinSize: 4 }); -assert.ok(bitmapResult && !(bitmapResult instanceof Set), 'expected bitmap output above threshold'); -assert.equal(getBitmapSize(bitmapResult), meta.length, 'bitmap result size mismatch'); - -const setResult = filterChunkIds(meta, filters, index, null, { preferBitmap: true, bitmapMinSize: 20 }); -assert.ok(setResult instanceof Set, 'expected Set output below threshold'); -assert.equal(setResult.size, meta.length, 'Set result size mismatch'); - -console.log('filter bitmap threshold test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/filter-compiled-reuse.test.js b/tests/retrieval/pipeline/filter-compiled-reuse.test.js deleted file mode 100644 index 18cacb2db..000000000 --- a/tests/retrieval/pipeline/filter-compiled-reuse.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildFilterIndex } from '../../../src/retrieval/filter-index.js'; -import { compileFilterPredicates } from '../../../src/retrieval/output/filters.js'; -import { filterChunks, filterChunkIds } from '../../../src/retrieval/output.js'; -import { bitmapToArray } from '../../../src/retrieval/bitmap.js'; - -applyTestEnv(); - -const meta = [ - { - id: 0, - file: 'src/a.js', - ext: '.js', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 1, - file: 'src/b.js', - ext: '.js', - kind: 'ClassDeclaration', - last_author: 'Bob', - chunk_authors: ['Bob'], - docmeta: { visibility: 'private' }, - metaV2: { lang: 'javascript', effective: { languageId: 'javascript' } } - }, - { - id: 2, - file: 'tests/c.ts', - ext: '.ts', - kind: 'FunctionDeclaration', - last_author: 'Alice', - chunk_authors: ['Alice'], - docmeta: { visibility: 'public' }, - metaV2: { lang: 'typescript', effective: { languageId: 'typescript' } } - } -]; - -const index = buildFilterIndex(meta); -const filters = { - file: '/src/.*\\.js$/', - ext: '.js', - caseFile: false -}; - -const compiled = compileFilterPredicates(filters, { fileChargramN: 3 }); -const matcherRef = compiled.fileMatchers; - -const expected = filterChunks(meta, filters, index) - .map((entry) => entry.id) - .sort((a, b) => a - b); - -const allowed = filterChunkIds(meta, filters, index, null, { compiled, preferBitmap: true }); -const allowedIds = allowed == null - ? meta.map((entry) => entry.id) - : (allowed instanceof Set ? Array.from(allowed) : bitmapToArray(allowed)); - -allowedIds.sort((a, b) => a - b); -assert.deepEqual(allowedIds, expected, 'compiled predicates should match filterChunks results'); -assert.equal(compiled.fileMatchers, matcherRef, 'compiled matchers should be reused'); - -console.log('filter compiled reuse test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/helpers/in-memory-search-pipeline-fixture.js b/tests/retrieval/pipeline/helpers/in-memory-search-pipeline-fixture.js new file mode 100644 index 000000000..efec968be --- /dev/null +++ b/tests/retrieval/pipeline/helpers/in-memory-search-pipeline-fixture.js @@ -0,0 +1,86 @@ +import { ANN_PROVIDER_IDS } from '../../../../src/retrieval/ann/types.js'; +import { createSearchPipeline } from '../../../../src/retrieval/pipeline.js'; + +export const createAvailableDenseAnnProvider = (query) => ({ + id: ANN_PROVIDER_IDS.DENSE, + isAvailable: () => true, + query +}); + +export const createAnnProviderMap = (provider) => ( + provider + ? new Map([[provider.id || ANN_PROVIDER_IDS.DENSE, provider]]) + : new Map() +); + +export const createInMemorySearchPipelineContext = ({ + provider, + createAnnProviders, + query = 'alpha', + queryTokens = ['alpha'], + filters = {}, + filtersActive = false, + topN = 2, + maxCandidates = 50, + annCandidateCap = 100, + annCandidateMinDocCount = 1, + annCandidateMaxDocCount = 100, + minhashMaxDocs = null, + stageTracker, + overrides = {} +} = {}) => ({ + useSqlite: false, + sqliteFtsRequested: false, + sqliteFtsNormalize: false, + sqliteFtsProfile: null, + sqliteFtsWeights: null, + bm25K1: 1.2, + bm25B: 0.75, + fieldWeights: null, + postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, + query, + queryTokens, + queryAst: null, + phraseNgramSet: null, + phraseRange: null, + explain: false, + symbolBoost: { enabled: false }, + relationBoost: { enabled: false }, + filters, + filtersActive, + filterPredicates: null, + topN, + maxCandidates, + annEnabled: true, + annBackend: ANN_PROVIDER_IDS.DENSE, + annCandidateCap, + annCandidateMinDocCount, + annCandidateMaxDocCount, + scoreBlend: { enabled: false }, + minhashMaxDocs, + sparseBackend: 'auto', + vectorAnnState: { code: { available: false } }, + vectorAnnUsed: null, + hnswAnnState: { code: { available: false } }, + hnswAnnUsed: null, + lanceAnnState: { code: { available: false } }, + lanceAnnUsed: null, + lancedbConfig: {}, + buildCandidateSetSqlite: () => null, + getTokenIndexForQuery: () => null, + rankSqliteFts: () => ({ hits: [], type: 'fts' }), + rankVectorAnnSqlite: () => [], + sqliteHasFts: () => false, + signal: null, + rrf: { enabled: false }, + graphRankingConfig: { enabled: false }, + stageTracker, + createAnnProviders: typeof createAnnProviders === 'function' + ? createAnnProviders + : () => createAnnProviderMap(provider), + ...overrides +}); + +export const createInMemorySearchPipeline = (options = {}) => ( + createSearchPipeline(createInMemorySearchPipelineContext(options)) +); diff --git a/tests/retrieval/pipeline/helpers/minhash-filtered-fixture.js b/tests/retrieval/pipeline/helpers/minhash-filtered-fixture.js new file mode 100644 index 000000000..cfbd38ac3 --- /dev/null +++ b/tests/retrieval/pipeline/helpers/minhash-filtered-fixture.js @@ -0,0 +1,70 @@ +import { SimpleMinHash } from '../../../../src/index/minhash.js'; +import { createRetrievalStageTracker } from '../../../../src/retrieval/pipeline/stage-checkpoints.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { createInMemorySearchPipeline } from './in-memory-search-pipeline-fixture.js'; + +const signatureForTokens = (tokens) => { + const minhash = new SimpleMinHash(); + for (const token of tokens) minhash.update(token); + return minhash.hashValues.slice(); +}; + +export const createFilteredMinhashIndex = () => ({ + chunkMeta: [ + { id: 0, file: 'src/a.js', tokens: ['alpha', 'core'], weight: 1 }, + { id: 1, file: 'src/b.js', tokens: ['alpha', 'extra'], weight: 1 }, + { id: 2, file: 'src/c.js', tokens: ['gamma'], weight: 1 }, + { id: 3, file: 'src/d.ts', tokens: ['alpha'], weight: 1 } + ], + tokenIndex: { + vocab: ['alpha', 'gamma'], + postings: [ + [[0, 1], [1, 1], [3, 1]], + [[2, 1]] + ], + docLengths: [2, 2, 1, 1], + totalDocs: 4, + avgDocLen: 1.5 + }, + denseVec: { + vectors: [ + [0.1, 0.1], + [0.2, 0.2], + [0.3, 0.3], + [0.4, 0.4] + ] + }, + minhash: { + signatures: [ + signatureForTokens(['alpha', 'core']), + signatureForTokens(['alpha', 'extra']), + signatureForTokens(['gamma']), + signatureForTokens(['alpha']) + ] + } +}); + +export const createFilteredMinhashPipelineFixture = ({ + provider, + topN +}) => { + applyTestEnv(); + + const stageTracker = createRetrievalStageTracker({ enabled: true }); + const pipeline = createInMemorySearchPipeline({ + provider, + topN, + filters: { ext: ['js'] }, + filtersActive: true, + annCandidateCap: 100, + annCandidateMinDocCount: 3, + minhashMaxDocs: 2, + stageTracker + }); + + return { + idx: createFilteredMinhashIndex(), + pipeline, + stageTracker + }; +}; diff --git a/tests/retrieval/pipeline/helpers/optional-extracted-prose-index-fixture.js b/tests/retrieval/pipeline/helpers/optional-extracted-prose-index-fixture.js new file mode 100644 index 000000000..22126d786 --- /dev/null +++ b/tests/retrieval/pipeline/helpers/optional-extracted-prose-index-fixture.js @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ARTIFACT_SURFACE_VERSION } from '../../../../src/contracts/versioning.js'; +import { loadSearchIndexes } from '../../../../src/retrieval/cli/load-indexes.js'; + +export const createOptionalExtractedProseRoot = (prefix) => ( + fs.mkdtemp(path.join(os.tmpdir(), prefix)) +); + +export const writeModeIndex = async (rootDir, mode, compatibilityKey, { chunkMeta = [] } = {}) => { + const indexDir = path.join(rootDir, `index-${mode}`); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + const indexState = { + generatedAt: new Date().toISOString(), + mode, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + compatibilityKey + }; + const tokenPostings = { + vocab: ['alpha'], + postings: [[[0, 1]]], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + }; + const manifest = { + version: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + compatibilityKey, + pieces: [ + { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, + { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, + { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' } + ] + }; + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2), 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2), 'utf8'); + await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2), 'utf8'); + await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8'); + return indexDir; +}; + +export const writeLegacyChunkMetaIndex = async (rootDir, mode, chunkMeta = []) => { + const indexDir = path.join(rootDir, `index-${mode}`); + await fs.mkdir(indexDir, { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2), 'utf8'); + return indexDir; +}; + +export const writeOptionalExtractedProseIndexPair = async ( + rootDir, + { + codeCompatibilityKey, + extractedProseCompatibilityKey = codeCompatibilityKey, + codeChunkMeta = [], + extractedProseChunkMeta = [] + } +) => { + await writeModeIndex(rootDir, 'code', codeCompatibilityKey, { chunkMeta: codeChunkMeta }); + await writeModeIndex(rootDir, 'extracted-prose', extractedProseCompatibilityKey, { + chunkMeta: extractedProseChunkMeta + }); +}; + +export const loadOptionalExtractedProseIndexes = (rootDir, overrides = {}) => loadSearchIndexes({ + rootDir, + userConfig: {}, + searchMode: 'code', + runProse: false, + runExtractedProse: false, + loadExtractedProse: true, + runCode: true, + runRecords: false, + useSqlite: false, + useLmdb: false, + emitOutput: false, + exitOnError: false, + annActive: false, + filtersActive: false, + contextExpansionEnabled: false, + graphRankingEnabled: false, + sqliteFtsRequested: false, + backendLabel: 'memory', + backendForcedTantivy: false, + indexCache: null, + modelIdDefault: null, + fileChargramN: null, + hnswConfig: { enabled: false }, + lancedbConfig: { enabled: true }, + tantivyConfig: { enabled: false }, + strict: true, + loadIndexFromSqlite: () => ({}), + loadIndexFromLmdb: () => ({}), + resolvedDenseVectorMode: 'merged', + ...overrides +}); + +export const loadOptionalExtractedProseIndexesWithWarnings = async (rootDir, overrides = {}) => { + const warnings = []; + const originalWarn = console.warn; + let loaded; + try { + console.warn = (message) => warnings.push(String(message || '')); + loaded = await loadOptionalExtractedProseIndexes(rootDir, overrides); + } finally { + console.warn = originalWarn; + } + return { loaded, warnings }; +}; + +export const assertOptionalExtractedProseDisabled = (loaded, message) => { + assert.equal(loaded.runExtractedProse, false, `${message}: expected run flag to remain disabled`); + assert.equal(loaded.extractedProseLoaded, false, message); + assert.deepEqual( + loaded.idxExtractedProse?.chunkMeta || [], + [], + `${message}: expected optional extracted-prose index to remain empty` + ); +}; diff --git a/tests/retrieval/pipeline/index-loader-binary-chunk-meta-budget-fallback.test.js b/tests/retrieval/pipeline/index-loader-binary-chunk-meta-budget-fallback.test.js deleted file mode 100644 index d70e39760..000000000 --- a/tests/retrieval/pipeline/index-loader-binary-chunk-meta-budget-fallback.test.js +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { encodeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -applyTestEnv({ - extraEnv: { - PAIROFCLEATS_TEST_MAX_JSON_BYTES: '1024' - } -}); - -const { loadIndex } = await import('../../../src/retrieval/cli-index.js'); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-binary-budget-')); -const indexDir = path.join(rootDir, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -const chunkRows = [ - { - id: 0, - fileRef: 0, - file: null, - start: 0, - end: 100, - lang: 'javascript', - kind: 'FunctionDeclaration', - name: 'alpha', - docmeta: { doc: `alpha-${'x'.repeat(500)}` } - }, - { - id: 1, - fileRef: 1, - file: null, - start: 100, - end: 240, - lang: 'javascript', - kind: 'FunctionDeclaration', - name: 'beta', - docmeta: { doc: `beta-${'y'.repeat(500)}` } - } -]; - -const encoded = encodeBinaryRowFrames( - chunkRows.map((row) => Buffer.from(JSON.stringify(row), 'utf8')) -); -await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.bin'), encoded.dataBuffer); -await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.offsets.bin'), encoded.offsetsBuffer); -await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.lengths.varint'), encoded.lengthsBuffer); -await fs.writeFile( - path.join(indexDir, 'chunk_meta.binary-columnar.meta.json'), - JSON.stringify({ - fields: { - format: 'binary-columnar-v1', - count: chunkRows.length, - data: 'chunk_meta.binary-columnar.bin', - offsets: 'chunk_meta.binary-columnar.offsets.bin', - lengths: 'chunk_meta.binary-columnar.lengths.varint' - }, - arrays: { - fileTable: ['src/alpha.js', 'src/beta.js'] - } - }, null, 2) -); - -await writePiecesManifest(indexDir, [ - { name: 'chunk_meta', path: 'chunk_meta.binary-columnar.bin', format: 'binary-columnar' }, - { name: 'chunk_meta_binary_columnar_offsets', path: 'chunk_meta.binary-columnar.offsets.bin', format: 'binary' }, - { name: 'chunk_meta_binary_columnar_lengths', path: 'chunk_meta.binary-columnar.lengths.varint', format: 'varint' }, - { name: 'chunk_meta_binary_columnar_meta', path: 'chunk_meta.binary-columnar.meta.json', format: 'json' } -]); - -const loaded = await loadIndex(indexDir, { - modelIdDefault: 'stub-model', - strict: true, - includeTokenIndex: false, - includeFilterIndex: false, - includeDense: false, - includeMinhash: false, - includeFileRelations: false, - includeRepoMap: false, - includeChunkMetaCold: false -}); - -assert.ok(Array.isArray(loaded?.chunkMeta), 'expected chunk metadata array'); -assert.equal(loaded.chunkMeta.length, chunkRows.length, 'expected all chunk rows to load'); -assert.equal(loaded.chunkMeta[0]?.file, 'src/alpha.js', 'expected binary fileRef lookup for row 0'); -assert.equal(loaded.chunkMeta[1]?.file, 'src/beta.js', 'expected binary fileRef lookup for row 1'); - -console.log('index loader binary chunk_meta budget fallback test passed'); diff --git a/tests/retrieval/pipeline/index-loader-binary-file-meta-budget-fallback.test.js b/tests/retrieval/pipeline/index-loader-binary-file-meta-budget-fallback.test.js deleted file mode 100644 index 8db00ecba..000000000 --- a/tests/retrieval/pipeline/index-loader-binary-file-meta-budget-fallback.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { encodeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -applyTestEnv({ - extraEnv: { - PAIROFCLEATS_TEST_MAX_JSON_BYTES: '1024' - } -}); - -const { loadIndex } = await import('../../../src/retrieval/cli-index.js'); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-file-meta-binary-budget-')); -const indexDir = path.join(rootDir, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -const chunkRows = [ - { - id: 0, - fileId: 0, - file: null, - start: 0, - end: 42, - lang: 'go', - kind: 'FunctionDeclaration', - name: 'alpha' - } -]; -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkRows)); - -const fileMetaRows = [ - { - id: 0, - file: 'src/alpha.go', - ext: '.go', - docmeta: { - note: `large-${'x'.repeat(3000)}` - } - } -]; - -const encoded = encodeBinaryRowFrames( - fileMetaRows.map((row) => Buffer.from(JSON.stringify(row), 'utf8')) -); -await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.bin'), encoded.dataBuffer); -await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.offsets.bin'), encoded.offsetsBuffer); -await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.lengths.varint'), encoded.lengthsBuffer); -await fs.writeFile( - path.join(indexDir, 'file_meta.binary-columnar.meta.json'), - JSON.stringify({ - fields: { - format: 'binary-columnar-v1', - count: fileMetaRows.length, - data: 'file_meta.binary-columnar.bin', - offsets: 'file_meta.binary-columnar.offsets.bin', - lengths: 'file_meta.binary-columnar.lengths.varint' - } - }, null, 2) -); - -await writePiecesManifest(indexDir, [ - { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' }, - { name: 'file_meta', path: 'file_meta.binary-columnar.bin', format: 'binary-columnar' }, - { name: 'file_meta_binary_columnar_offsets', path: 'file_meta.binary-columnar.offsets.bin', format: 'binary' }, - { name: 'file_meta_binary_columnar_lengths', path: 'file_meta.binary-columnar.lengths.varint', format: 'varint' }, - { name: 'file_meta_binary_columnar_meta', path: 'file_meta.binary-columnar.meta.json', format: 'json' } -]); - -const loaded = await loadIndex(indexDir, { - modelIdDefault: 'stub-model', - strict: true, - includeTokenIndex: false, - includeFilterIndex: false, - includeDense: false, - includeMinhash: false, - includeFileRelations: false, - includeRepoMap: false, - includeChunkMetaCold: false -}); - -assert.ok(Array.isArray(loaded?.chunkMeta), 'expected chunk metadata array'); -assert.equal(loaded.chunkMeta.length, 1, 'expected chunk metadata row to load'); -assert.equal(loaded.chunkMeta[0]?.file, 'src/alpha.go', 'expected file to hydrate from binary file_meta'); - -console.log('index loader binary file_meta budget fallback test passed'); diff --git a/tests/retrieval/pipeline/index-loader-contract-matrix.test.js b/tests/retrieval/pipeline/index-loader-contract-matrix.test.js new file mode 100644 index 000000000..eca18b325 --- /dev/null +++ b/tests/retrieval/pipeline/index-loader-contract-matrix.test.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { encodeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; +import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; +import { loadIndex, requireIndexDir } from '../../../src/retrieval/cli-index.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const withTempDir = async (prefix, run) => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await run(rootDir); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } +}; + +const cases = [ + { + name: 'detects manifest, binary-columnar, and compressed chunk meta layouts', + async run() { + applyTestEnv(); + await withTempDir('poc-index-layouts-', async (rootDir) => { + const columnarDir = path.join(rootDir, 'index-columnar'); + await fs.mkdir(columnarDir, { recursive: true }); + await fs.writeFile(path.join(columnarDir, 'chunk_meta.columnar.json.zst'), '{}', 'utf8'); + assert.equal(hasIndexMeta(columnarDir), true); + + const binaryDir = path.join(rootDir, 'index-binary'); + await fs.mkdir(binaryDir, { recursive: true }); + await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.meta.json'), JSON.stringify({ + format: 'binary-columnar-v1', + count: 1, + data: 'chunk_meta.binary-columnar.bin', + offsets: 'chunk_meta.binary-columnar.offsets.bin', + lengths: 'chunk_meta.binary-columnar.lengths.varint' + }, null, 2)); + await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.bin'), Buffer.from([1, 2, 3])); + await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.offsets.bin'), Buffer.from([0, 0, 0, 0])); + await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.lengths.varint'), Buffer.from([3])); + assert.equal(hasIndexMeta(binaryDir), true); + assert.equal(requireIndexDir(rootDir, 'code', {}, { + resolveOptions: { + indexDirByMode: { code: binaryDir }, + explicitRef: true + }, + emitOutput: false, + exitOnError: false + }), binaryDir); + + const manifestDir = path.join(rootDir, 'index-manifest-only'); + await fs.mkdir(path.join(manifestDir, 'pieces'), { recursive: true }); + await fs.mkdir(path.join(manifestDir, 'custom'), { recursive: true }); + await fs.writeFile( + path.join(manifestDir, 'custom', 'chunk_meta.jsonl'), + '{"id":1,"file":"src/a.js","start":0,"end":1}\n' + ); + await fs.writeFile(path.join(manifestDir, 'pieces', 'manifest.json'), JSON.stringify({ + version: 2, + pieces: [ + { name: 'chunk_meta', path: 'custom/chunk_meta.jsonl', format: 'jsonl' } + ] + }, null, 2)); + assert.equal(hasIndexMeta(manifestDir), true); + + const emptyDir = path.join(rootDir, 'index-empty'); + await fs.mkdir(emptyDir, { recursive: true }); + assert.equal(hasIndexMeta(emptyDir), false); + assert.throws( + () => requireIndexDir(rootDir, 'code', {}, { + resolveOptions: { + indexDirByMode: { code: emptyDir }, + explicitRef: true + }, + emitOutput: false, + exitOnError: false + }), + (err) => err?.code === 'NO_INDEX' + ); + }); + } + }, + { + name: 'strict token_postings loading fails while non-strict skips', + async run() { + applyTestEnv(); + await withTempDir('poc-index-token-strict-', async (rootDir) => { + const indexDir = path.join(rootDir, 'index-code'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([ + { id: 0, file: 'src/a.js', start: 0, end: 1 } + ], null, 2)); + await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ + version: 2, + pieces: [{ name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' }] + }, null, 2)); + await assert.rejects( + () => loadIndex(indexDir, { modelIdDefault: 'stub-model', strict: true }), + /token_postings/i + ); + const nonStrict = await loadIndex(indexDir, { modelIdDefault: 'stub-model', strict: false }); + assert.equal(nonStrict?.tokenIndex, undefined); + }); + } + }, + { + name: 'lazy loading skips optional heavy artifacts', + async run() { + applyTestEnv(); + const { codeDir } = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheScope: 'shared', + requiredModes: ['code'] + }); + const idx = await loadIndex(codeDir, { + includeFileRelations: false, + includeRepoMap: false, + includeFilterIndex: false, + includeDense: false, + includeMinhash: false, + includeTokenIndex: false, + fileChargramN: 3, + strict: true + }); + assert.equal(idx.fileRelations, null); + assert.equal(idx.repoMap, null); + assert.equal(idx.filterIndex, null); + assert.equal(idx.denseVec, null); + assert.equal(idx.minhash, null); + assert.equal(idx.tokenIndex, undefined); + } + }, + { + name: 'binary chunk_meta fallback hydrates file refs under budget pressure', + async run() { + applyTestEnv({ + extraEnv: { + PAIROFCLEATS_TEST_MAX_JSON_BYTES: '1024' + } + }); + await withTempDir('poc-index-binary-budget-', async (rootDir) => { + const indexDir = path.join(rootDir, 'index-code'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + const chunkRows = [ + { + id: 0, + fileRef: 0, + file: null, + start: 0, + end: 100, + lang: 'javascript', + kind: 'FunctionDeclaration', + name: 'alpha', + docmeta: { doc: `alpha-${'x'.repeat(500)}` } + }, + { + id: 1, + fileRef: 1, + file: null, + start: 100, + end: 240, + lang: 'javascript', + kind: 'FunctionDeclaration', + name: 'beta', + docmeta: { doc: `beta-${'y'.repeat(500)}` } + } + ]; + const encoded = encodeBinaryRowFrames( + chunkRows.map((row) => Buffer.from(JSON.stringify(row), 'utf8')) + ); + await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.bin'), encoded.dataBuffer); + await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.offsets.bin'), encoded.offsetsBuffer); + await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.lengths.varint'), encoded.lengthsBuffer); + await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.meta.json'), JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: chunkRows.length, + data: 'chunk_meta.binary-columnar.bin', + offsets: 'chunk_meta.binary-columnar.offsets.bin', + lengths: 'chunk_meta.binary-columnar.lengths.varint' + }, + arrays: { + fileTable: ['src/alpha.js', 'src/beta.js'] + } + }, null, 2)); + await writePiecesManifest(indexDir, [ + { name: 'chunk_meta', path: 'chunk_meta.binary-columnar.bin', format: 'binary-columnar' }, + { name: 'chunk_meta_binary_columnar_offsets', path: 'chunk_meta.binary-columnar.offsets.bin', format: 'binary' }, + { name: 'chunk_meta_binary_columnar_lengths', path: 'chunk_meta.binary-columnar.lengths.varint', format: 'varint' }, + { name: 'chunk_meta_binary_columnar_meta', path: 'chunk_meta.binary-columnar.meta.json', format: 'json' } + ]); + const loaded = await loadIndex(indexDir, { + modelIdDefault: 'stub-model', + strict: true, + includeTokenIndex: false, + includeFilterIndex: false, + includeDense: false, + includeMinhash: false, + includeFileRelations: false, + includeRepoMap: false, + includeChunkMetaCold: false + }); + assert.equal(loaded.chunkMeta.length, chunkRows.length); + assert.equal(loaded.chunkMeta[0]?.file, 'src/alpha.js'); + assert.equal(loaded.chunkMeta[1]?.file, 'src/beta.js'); + }); + } + }, + { + name: 'binary file_meta fallback hydrates file paths under budget pressure', + async run() { + applyTestEnv({ + extraEnv: { + PAIROFCLEATS_TEST_MAX_JSON_BYTES: '1024' + } + }); + await withTempDir('poc-index-file-meta-binary-budget-', async (rootDir) => { + const indexDir = path.join(rootDir, 'index-code'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([ + { + id: 0, + fileId: 0, + file: null, + start: 0, + end: 42, + lang: 'go', + kind: 'FunctionDeclaration', + name: 'alpha' + } + ])); + const fileMetaRows = [ + { + id: 0, + file: 'src/alpha.go', + ext: '.go', + docmeta: { note: `large-${'x'.repeat(3000)}` } + } + ]; + const encoded = encodeBinaryRowFrames( + fileMetaRows.map((row) => Buffer.from(JSON.stringify(row), 'utf8')) + ); + await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.bin'), encoded.dataBuffer); + await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.offsets.bin'), encoded.offsetsBuffer); + await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.lengths.varint'), encoded.lengthsBuffer); + await fs.writeFile(path.join(indexDir, 'file_meta.binary-columnar.meta.json'), JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: fileMetaRows.length, + data: 'file_meta.binary-columnar.bin', + offsets: 'file_meta.binary-columnar.offsets.bin', + lengths: 'file_meta.binary-columnar.lengths.varint' + } + }, null, 2)); + await writePiecesManifest(indexDir, [ + { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' }, + { name: 'file_meta', path: 'file_meta.binary-columnar.bin', format: 'binary-columnar' }, + { name: 'file_meta_binary_columnar_offsets', path: 'file_meta.binary-columnar.offsets.bin', format: 'binary' }, + { name: 'file_meta_binary_columnar_lengths', path: 'file_meta.binary-columnar.lengths.varint', format: 'varint' }, + { name: 'file_meta_binary_columnar_meta', path: 'file_meta.binary-columnar.meta.json', format: 'json' } + ]); + const loaded = await loadIndex(indexDir, { + modelIdDefault: 'stub-model', + strict: true, + includeTokenIndex: false, + includeFilterIndex: false, + includeDense: false, + includeMinhash: false, + includeFileRelations: false, + includeRepoMap: false, + includeChunkMetaCold: false + }); + assert.equal(loaded.chunkMeta.length, 1); + assert.equal(loaded.chunkMeta[0]?.file, 'src/alpha.go'); + }); + } + }, + { + name: 'dense vector binary meta refuses path traversal outside index root', + async run() { + applyTestEnv(); + await withTempDir('poc-index-dense-path-', async (rootDir) => { + const indexDir = path.join(rootDir, 'index-code'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([ + { id: 0, file: 'src/a.js', start: 0, end: 1, ext: '.js' } + ], null, 2)); + await fs.writeFile(path.join(rootDir, 'outside.bin'), Buffer.from([1, 2, 3, 4])); + await fs.writeFile(path.join(indexDir, 'dense_vectors_binary_meta.json'), JSON.stringify({ + path: '../outside.bin', + dims: 2, + count: 2 + }, null, 2)); + await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ + version: 2, + pieces: [ + { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' }, + { name: 'dense_vectors_binary_meta', path: 'dense_vectors_binary_meta.json', format: 'json' } + ] + }, null, 2)); + const idx = await loadIndex(indexDir, { + modelIdDefault: 'stub-model', + strict: false, + includeFilterIndex: false, + includeTokenIndex: false, + includeHnsw: false, + includeMinhash: false, + fileChargramN: 3 + }); + assert.equal(idx?.denseVec, null); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('index loader contract matrix test passed'); diff --git a/tests/retrieval/pipeline/index-loader-dense-vectors-meta-path-traversal.test.js b/tests/retrieval/pipeline/index-loader-dense-vectors-meta-path-traversal.test.js deleted file mode 100644 index 2a53a6f59..000000000 --- a/tests/retrieval/pipeline/index-loader-dense-vectors-meta-path-traversal.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { loadIndex } from '../../../src/retrieval/cli-index.js'; - -applyTestEnv(); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-dense-path-')); -const indexDir = path.join(rootDir, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); - -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([ - { id: 0, file: 'src/a.js', start: 0, end: 1, ext: '.js' } -], null, 2)); - -await fs.writeFile(path.join(rootDir, 'outside.bin'), Buffer.from([1, 2, 3, 4])); -await fs.writeFile(path.join(indexDir, 'dense_vectors_binary_meta.json'), JSON.stringify({ - path: '../outside.bin', - dims: 2, - count: 2 -}, null, 2)); - -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - pieces: [ - { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' }, - { name: 'dense_vectors_binary_meta', path: 'dense_vectors_binary_meta.json', format: 'json' } - ] -}, null, 2)); - -const idx = await loadIndex(indexDir, { - modelIdDefault: 'stub-model', - strict: false, - includeFilterIndex: false, - includeTokenIndex: false, - includeHnsw: false, - includeMinhash: false, - fileChargramN: 3 -}); - -assert.equal( - idx?.denseVec, - null, - 'dense vector binary meta path should not allow traversal outside index directory' -); - -console.log('index loader dense_vectors binary meta path traversal test passed'); diff --git a/tests/retrieval/pipeline/index-loader-lazy.test.js b/tests/retrieval/pipeline/index-loader-lazy.test.js deleted file mode 100644 index febc2ffce..000000000 --- a/tests/retrieval/pipeline/index-loader-lazy.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { loadIndex } from '../../../src/retrieval/cli-index.js'; - -applyTestEnv(); - -const { codeDir } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheScope: 'shared', - requiredModes: ['code'] -}); - -const idx = await loadIndex(codeDir, { - includeFileRelations: false, - includeRepoMap: false, - includeFilterIndex: false, - includeDense: false, - includeMinhash: false, - includeTokenIndex: false, - fileChargramN: 3, - strict: true -}); - -assert.equal(idx.fileRelations, null, 'expected fileRelations to be skipped'); -assert.equal(idx.repoMap, null, 'expected repoMap to be skipped'); -assert.equal(idx.filterIndex, null, 'expected filterIndex to be skipped'); -assert.equal(idx.denseVec, null, 'expected dense vectors to be skipped'); -assert.equal(idx.minhash, null, 'expected minhash to be skipped'); -assert.equal(idx.tokenIndex, undefined, 'expected token index to be skipped'); - -console.log('index loader lazy test passed'); diff --git a/tests/retrieval/pipeline/index-loader-meta-layouts.test.js b/tests/retrieval/pipeline/index-loader-meta-layouts.test.js deleted file mode 100644 index 1afae9eca..000000000 --- a/tests/retrieval/pipeline/index-loader-meta-layouts.test.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; -import { requireIndexDir } from '../../../src/retrieval/cli-index.js'; - -applyTestEnv(); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-layouts-')); - -const columnarDir = path.join(rootDir, 'index-columnar'); -await fs.mkdir(columnarDir, { recursive: true }); -await fs.writeFile(path.join(columnarDir, 'chunk_meta.columnar.json.zst'), '{}', 'utf8'); -assert.equal(hasIndexMeta(columnarDir), true, 'expected compressed columnar chunk_meta layout to be detected'); - -const binaryDir = path.join(rootDir, 'index-binary'); -await fs.mkdir(binaryDir, { recursive: true }); -await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.meta.json'), JSON.stringify({ - format: 'binary-columnar-v1', - count: 1, - data: 'chunk_meta.binary-columnar.bin', - offsets: 'chunk_meta.binary-columnar.offsets.bin', - lengths: 'chunk_meta.binary-columnar.lengths.varint' -}, null, 2)); -await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.bin'), Buffer.from([1, 2, 3])); -await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.offsets.bin'), Buffer.from([0, 0, 0, 0])); -await fs.writeFile(path.join(binaryDir, 'chunk_meta.binary-columnar.lengths.varint'), Buffer.from([3])); -assert.equal(hasIndexMeta(binaryDir), true, 'expected binary-columnar chunk_meta layout to be detected'); - -const requiredBinaryDir = requireIndexDir(rootDir, 'code', {}, { - resolveOptions: { - indexDirByMode: { code: binaryDir }, - explicitRef: true - }, - emitOutput: false, - exitOnError: false -}); -assert.equal(requiredBinaryDir, binaryDir, 'expected requireIndexDir to accept binary-columnar chunk_meta layouts'); - -const manifestDir = path.join(rootDir, 'index-manifest-only'); -await fs.mkdir(path.join(manifestDir, 'pieces'), { recursive: true }); -await fs.mkdir(path.join(manifestDir, 'custom'), { recursive: true }); -await fs.writeFile(path.join(manifestDir, 'custom', 'chunk_meta.jsonl'), '{"id":1,"file":"src/a.js","start":0,"end":1}\n'); -await fs.writeFile(path.join(manifestDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - pieces: [ - { name: 'chunk_meta', path: 'custom/chunk_meta.jsonl', format: 'jsonl' } - ] -}, null, 2)); -assert.equal(hasIndexMeta(manifestDir), true, 'expected manifest-declared chunk_meta layout to be detected'); - -const emptyDir = path.join(rootDir, 'index-empty'); -await fs.mkdir(emptyDir, { recursive: true }); -assert.equal(hasIndexMeta(emptyDir), false, 'expected empty index dir to report missing chunk_meta artifacts'); -assert.throws( - () => requireIndexDir(rootDir, 'code', {}, { - resolveOptions: { - indexDirByMode: { code: emptyDir }, - explicitRef: true - }, - emitOutput: false, - exitOnError: false - }), - (err) => err?.code === 'NO_INDEX', - 'expected requireIndexDir to reject empty explicit index dirs' -); - -console.log('index loader meta layouts test passed'); diff --git a/tests/retrieval/pipeline/index-loader-token-postings-strict.test.js b/tests/retrieval/pipeline/index-loader-token-postings-strict.test.js deleted file mode 100644 index 80279beda..000000000 --- a/tests/retrieval/pipeline/index-loader-token-postings-strict.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { loadIndex } from '../../../src/retrieval/cli-index.js'; - -applyTestEnv(); - -const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-index-token-strict-')); -const indexDir = path.join(rootDir, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify([ - { id: 0, file: 'src/a.js', start: 0, end: 1 } -], null, 2)); -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - pieces: [ - { name: 'chunk_meta', path: 'chunk_meta.json', format: 'json' } - ] -}, null, 2)); - -await assert.rejects( - () => loadIndex(indexDir, { - modelIdDefault: 'stub-model', - strict: true - }), - /token_postings/i, - 'strict index loading should fail when token_postings is missing from the manifest' -); - -const nonStrict = await loadIndex(indexDir, { - modelIdDefault: 'stub-model', - strict: false -}); -assert.equal( - nonStrict?.tokenIndex, - undefined, - 'non-strict index loading should skip token_postings when manifest entries are missing' -); - -console.log('index loader token_postings strict wiring test passed'); diff --git a/tests/retrieval/pipeline/json-streaming.test.js b/tests/retrieval/pipeline/json-streaming.test.js index 6ef639aaa..3b3badd5b 100644 --- a/tests/retrieval/pipeline/json-streaming.test.js +++ b/tests/retrieval/pipeline/json-streaming.test.js @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import { renderSearchOutput } from '../../../src/retrieval/cli/render.js'; import { color } from '../../../src/retrieval/cli/ansi.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + captureSearchOutputStdout, + createSearchOutputHitState, + createSearchOutputOptions +} from '../helpers/search-output-fixture.js'; applyTestEnv(); @@ -9,100 +15,38 @@ const hits = [ { id: 1, file: 'src/a.js', start: 0, end: 1, score: 1, scoreType: 'bm25' } ]; -const outputChunks = []; -const originalWrite = process.stdout.write.bind(process.stdout); -process.stdout.write = (chunk) => { - outputChunks.push(String(chunk)); - return true; -}; - -try { - renderSearchOutput({ +const output = await captureSearchOutputStdout(async () => { + renderSearchOutput(createSearchOutputOptions({ emitOutput: true, - jsonOutput: true, - jsonCompact: true, explain: false, color, - rootDir: process.cwd(), - backendLabel: 'memory', backendPolicyInfo: null, - runCode: true, - runProse: false, - runExtractedProse: false, - runRecords: false, + routingPolicy: null, topN: 1, - queryTokens: ['alpha'], highlightRegex: /alpha/g, - contextExpansionEnabled: false, - expandedHits: { - prose: { hits: [] }, - extractedProse: { hits: [] }, - code: { hits }, - records: { hits: [] } - }, - baseHits: { - proseHits: [], - extractedProseHits: [], - codeHits: hits, - recordHits: [] - }, - annEnabled: false, - annActive: false, + ...createSearchOutputHitState({ codeHits: hits }), annBackend: 'js', vectorExtension: { annMode: 'dense', provider: null, table: null }, - vectorAnnEnabled: false, - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswConfig: { enabled: false }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, modelIds: { code: null, prose: null, extractedProse: null, records: null }, embeddingProvider: null, embeddingOnnx: { modelPath: null, tokenizerId: null }, - cacheInfo: { enabled: false, hit: false, key: null }, intentInfo: null, resolvedDenseVectorMode: 'merged', - fieldWeights: null, contextExpansionStats: { enabled: false }, idxProse: null, idxExtractedProse: null, idxCode: null, idxRecords: null, - showStats: false, - showMatched: false, - verboseCache: false, elapsedMs: 1, streamJson: true - }); -} finally { - process.stdout.write = originalWrite; -} + })); +}); -const output = outputChunks.join(''); const parsed = JSON.parse(output); assert.equal(parsed.backend, 'memory'); +assert.equal(parsed.retrieval?.backend?.selected, 'memory'); +assert.equal(parsed.retrieval?.cache?.hit, false); assert.equal(parsed.code.length, 1); assert.equal(parsed.code[0].file, 'src/a.js'); console.log('json streaming output test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/lancedb-nonstrict-fallback-without-manifest-entry.test.js b/tests/retrieval/pipeline/lancedb-nonstrict-fallback-without-manifest-entry.test.js deleted file mode 100644 index b74196977..000000000 --- a/tests/retrieval/pipeline/lancedb-nonstrict-fallback-without-manifest-entry.test.js +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; -import { loadSearchIndexes } from '../../../src/retrieval/cli/load-indexes.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -const root = process.cwd(); -const fixtureRoot = resolveTestCachePath(root, 'lancedb-nonstrict-fallback-without-manifest-entry'); -const indexDir = path.join(fixtureRoot, 'index-code'); -await fs.rm(fixtureRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fs.mkdir(path.join(indexDir, 'dense_vectors.lancedb'), { recursive: true }); - -const compatibilityKey = 'compat-lancedb-nonstrict-fallback'; -const dims = 4; -const chunkMeta = [{ id: 0, file: 'src/a.js', start: 0, end: 1 }]; -const fileMeta = [{ id: 0, file: 'src/a.js', ext: '.js' }]; -const tokenPostings = { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - avgDocLen: 1, - totalDocs: 1 -}; -const denseVectors = { - dims, - model: 'stub-model', - scale: 1, - minVal: -1, - maxVal: 1, - levels: 255, - vectors: [new Array(dims).fill(0)] -}; -const lanceMeta = { - version: 1, - generatedAt: new Date().toISOString(), - model: 'stub-model', - dims, - count: 1, - metric: 'cosine', - table: 'vectors', - embeddingColumn: 'vector', - idColumn: 'id', - scale: 1, - minVal: -1, - maxVal: 1, - levels: 255 -}; -const indexState = { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - embeddings: { - ready: true, - pending: false, - embeddingIdentity: { - dims, - model: 'stub-model', - scale: 1, - minVal: -1, - maxVal: 1, - levels: 255 - } - } -}; -const fileLists = { - generatedAt: new Date().toISOString(), - scanned: { count: 1, sample: [] }, - skipped: { count: 0, sample: [] } -}; -const manifest = { - version: 2, - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - compatibilityKey, - pieces: [ - { type: 'chunks', name: 'chunk_meta', format: 'json', path: 'chunk_meta.json' }, - { type: 'chunks', name: 'file_meta', format: 'json', path: 'file_meta.json' }, - { type: 'postings', name: 'token_postings', format: 'json', path: 'token_postings.json' }, - { type: 'stats', name: 'index_state', format: 'json', path: 'index_state.json' }, - { type: 'stats', name: 'filelists', format: 'json', path: '.filelists.json' }, - { type: 'embeddings', name: 'dense_vectors', format: 'json', path: 'dense_vectors_uint8.json', count: 1, dims } - ] -}; - -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), JSON.stringify(chunkMeta, null, 2)); -await fs.writeFile(path.join(indexDir, 'file_meta.json'), JSON.stringify(fileMeta, null, 2)); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), JSON.stringify(tokenPostings, null, 2)); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify(indexState, null, 2)); -await fs.writeFile(path.join(indexDir, '.filelists.json'), JSON.stringify(fileLists, null, 2)); -await fs.writeFile(path.join(indexDir, 'dense_vectors_uint8.json'), JSON.stringify(denseVectors, null, 2)); -await fs.writeFile(path.join(indexDir, 'dense_vectors.lancedb.meta.json'), JSON.stringify(lanceMeta, null, 2)); -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify(manifest, null, 2)); - -const loaded = await loadSearchIndexes({ - rootDir: fixtureRoot, - userConfig: {}, - searchMode: 'code', - runProse: false, - runExtractedProse: false, - loadExtractedProse: false, - runCode: true, - runRecords: false, - useSqlite: false, - useLmdb: false, - emitOutput: false, - exitOnError: false, - annActive: true, - filtersActive: false, - contextExpansionEnabled: false, - graphRankingEnabled: false, - sqliteFtsRequested: false, - backendLabel: 'memory', - backendForcedTantivy: false, - indexCache: null, - modelIdDefault: null, - fileChargramN: null, - hnswConfig: { enabled: false }, - lancedbConfig: { enabled: true }, - tantivyConfig: { enabled: false }, - strict: false, - loadIndexFromSqlite: () => ({}), - loadIndexFromLmdb: () => ({}), - resolvedDenseVectorMode: 'merged', - requiredArtifacts: new Set(['ann']) -}); - -assert.ok(loaded?.idxCode?.lancedb, 'expected lancedb metadata object to be present'); -assert.equal( - loaded.idxCode.lancedb.available, - true, - 'expected non-strict lancedb load to fall back to legacy metadata + directory paths' -); -assert.equal(loaded.idxCode.lancedb.meta?.dims, dims, 'expected lancedb fallback to load legacy metadata file'); -assert.equal( - path.basename(loaded.idxCode.lancedb.dir || ''), - 'dense_vectors.lancedb', - 'expected lancedb fallback to use legacy directory path' -); - -console.log('lancedb non-strict fallback without manifest entry test passed'); diff --git a/tests/retrieval/pipeline/load-indexes-chunk-author-cache.test.js b/tests/retrieval/pipeline/load-indexes-chunk-author-cache.test.js new file mode 100644 index 000000000..9d150ca93 --- /dev/null +++ b/tests/retrieval/pipeline/load-indexes-chunk-author-cache.test.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + __testScmChunkAuthorHydration, + hydrateChunkAuthorIndexes +} from '../../../src/retrieval/cli/load-indexes/chunk-author-loader.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const runGit = (cwd, args, env = null) => { + const result = spawnSync('git', args, { + cwd, + env: env || process.env, + encoding: 'utf8', + timeout: 15000 + }); + assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr || result.stdout}`); +}; + +const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-load-indexes-chunk-authors-')); +const repoRoot = path.join(rootDir, 'repo'); +const indexDir = path.join(repoRoot, 'index-prose'); +await fs.mkdir(repoRoot, { recursive: true }); +await fs.mkdir(indexDir, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'alpha.txt'), 'alpha beta\nalpha gamma\n', 'utf8'); + +runGit(repoRoot, ['init']); +runGit(repoRoot, ['config', 'user.email', 'test@example.com']); +runGit(repoRoot, ['config', 'user.name', 'Test User']); +runGit(repoRoot, ['add', 'alpha.txt']); +runGit(repoRoot, ['commit', '-m', 'add alpha', '--author', 'Alice ']); + +const createIndexPayload = () => ({ + indexDir, + chunkMeta: [ + { + id: 1, + file: 'alpha.txt', + startLine: 1, + endLine: 2 + } + ] +}); + +__testScmChunkAuthorHydration.reset(); + +const idxProseCold = createIndexPayload(); +await hydrateChunkAuthorIndexes({ + idxCode: null, + idxProse: idxProseCold, + idxExtractedProse: null, + idxRecords: null, + runCode: false, + runProse: true, + runRecords: false, + resolvedLoadExtractedProse: false, + rootDir: repoRoot, + userConfig: {}, + fileChargramN: 3, + filtersActive: true, + chunkAuthorFilterActive: true, + emitOutput: false +}); + +assert.deepEqual( + idxProseCold.chunkMeta[0].chunk_authors, + ['Alice'], + 'expected cold chunk-author hydration to annotate prose chunk authors' +); + +const coldStats = __testScmChunkAuthorHydration.getStats(); +assert.equal(coldStats.cacheMisses, 1, 'expected first hydration to miss cache'); +assert.equal(coldStats.cacheHits, 0, 'expected no cache hits before second hydration'); + +const idxProseWarm = createIndexPayload(); +await hydrateChunkAuthorIndexes({ + idxCode: null, + idxProse: idxProseWarm, + idxExtractedProse: null, + idxRecords: null, + runCode: false, + runProse: true, + runRecords: false, + resolvedLoadExtractedProse: false, + rootDir: repoRoot, + userConfig: {}, + fileChargramN: 3, + filtersActive: true, + chunkAuthorFilterActive: true, + emitOutput: false +}); + +assert.deepEqual( + idxProseWarm.chunkMeta[0].chunk_authors, + ['Alice'], + 'expected cached chunk-author hydration to preserve annotated authors' +); + +const warmStats = __testScmChunkAuthorHydration.getStats(); +assert.equal(warmStats.cacheMisses, 1, 'expected second hydration to reuse prior cache entry'); +assert.equal(warmStats.cacheHits, 1, 'expected second hydration to hit cache'); + +console.log('load-indexes chunk-author cache test passed'); diff --git a/tests/retrieval/pipeline/minhash-filtered-candidates-constrained.test.js b/tests/retrieval/pipeline/minhash-filtered-candidates-constrained.test.js index 51251279a..b77a8d4ff 100644 --- a/tests/retrieval/pipeline/minhash-filtered-candidates-constrained.test.js +++ b/tests/retrieval/pipeline/minhash-filtered-candidates-constrained.test.js @@ -1,18 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { createRetrievalStageTracker } from '../../../src/retrieval/pipeline/stage-checkpoints.js'; import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { SimpleMinHash } from '../../../src/index/minhash.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -const signatureForTokens = (tokens) => { - const minhash = new SimpleMinHash(); - for (const token of tokens) minhash.update(token); - return minhash.hashValues.slice(); -}; +import { createFilteredMinhashPipelineFixture } from './helpers/minhash-filtered-fixture.js'; const provider = { id: ANN_PROVIDER_IDS.DENSE, @@ -20,92 +9,11 @@ const provider = { query: async () => [] }; -const stageTracker = createRetrievalStageTracker({ enabled: true }); -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - relationBoost: { enabled: false }, - filters: { ext: ['js'] }, - filtersActive: true, - filterPredicates: null, - topN: 1, - maxCandidates: 50, - annEnabled: true, - annBackend: ANN_PROVIDER_IDS.DENSE, - annCandidateCap: 100, - annCandidateMinDocCount: 3, - annCandidateMaxDocCount: 100, - minhashMaxDocs: 2, - scoreBlend: { enabled: false }, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - stageTracker, - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) +const { idx, pipeline, stageTracker } = createFilteredMinhashPipelineFixture({ + provider, + topN: 1 }); -const idx = { - chunkMeta: [ - { id: 0, file: 'src/a.js', tokens: ['alpha', 'core'], weight: 1 }, - { id: 1, file: 'src/b.js', tokens: ['alpha', 'extra'], weight: 1 }, - { id: 2, file: 'src/c.js', tokens: ['gamma'], weight: 1 }, - { id: 3, file: 'src/d.ts', tokens: ['alpha'], weight: 1 } - ], - tokenIndex: { - vocab: ['alpha', 'gamma'], - postings: [ - [[0, 1], [1, 1], [3, 1]], - [[2, 1]] - ], - docLengths: [2, 2, 1, 1], - totalDocs: 4, - avgDocLen: 1.5 - }, - denseVec: { - vectors: [ - [0.1, 0.1], - [0.2, 0.2], - [0.3, 0.3], - [0.4, 0.4] - ] - }, - minhash: { - signatures: [ - signatureForTokens(['alpha', 'core']), - signatureForTokens(['alpha', 'extra']), - signatureForTokens(['gamma']), - signatureForTokens(['alpha']) - ] - } -}; - const results = await pipeline(idx, 'code', [0.2, 0.3]); assert.ok(results.length > 0, 'expected filtered minhash fallback to return in-filter hits'); diff --git a/tests/retrieval/pipeline/minhash-filtered-oversized-fallback.test.js b/tests/retrieval/pipeline/minhash-filtered-oversized-fallback.test.js index 4265f9298..a5d1a89e9 100644 --- a/tests/retrieval/pipeline/minhash-filtered-oversized-fallback.test.js +++ b/tests/retrieval/pipeline/minhash-filtered-oversized-fallback.test.js @@ -1,18 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { createRetrievalStageTracker } from '../../../src/retrieval/pipeline/stage-checkpoints.js'; import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { SimpleMinHash } from '../../../src/index/minhash.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -const signatureForTokens = (tokens) => { - const minhash = new SimpleMinHash(); - for (const token of tokens) minhash.update(token); - return minhash.hashValues.slice(); -}; +import { createFilteredMinhashPipelineFixture } from './helpers/minhash-filtered-fixture.js'; const providerCandidateSizes = []; const provider = { @@ -24,92 +13,11 @@ const provider = { } }; -const stageTracker = createRetrievalStageTracker({ enabled: true }); -const pipeline = createSearchPipeline({ - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: null, - sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, - postingsConfig: { enablePhraseNgrams: false, enableChargrams: false }, - query: 'alpha', - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, - explain: false, - symbolBoost: { enabled: false }, - relationBoost: { enabled: false }, - filters: { ext: ['js'] }, - filtersActive: true, - filterPredicates: null, - topN: 2, - maxCandidates: 50, - annEnabled: true, - annBackend: ANN_PROVIDER_IDS.DENSE, - annCandidateCap: 100, - annCandidateMinDocCount: 3, - annCandidateMaxDocCount: 100, - minhashMaxDocs: 2, - scoreBlend: { enabled: false }, - sparseBackend: 'auto', - vectorAnnState: { code: { available: false } }, - vectorAnnUsed: null, - hnswAnnState: { code: { available: false } }, - hnswAnnUsed: null, - lanceAnnState: { code: { available: false } }, - lanceAnnUsed: null, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, - rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, - graphRankingConfig: { enabled: false }, - stageTracker, - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) +const { idx, pipeline, stageTracker } = createFilteredMinhashPipelineFixture({ + provider, + topN: 2 }); -const idx = { - chunkMeta: [ - { id: 0, file: 'src/a.js', tokens: ['alpha', 'core'], weight: 1 }, - { id: 1, file: 'src/b.js', tokens: ['alpha', 'extra'], weight: 1 }, - { id: 2, file: 'src/c.js', tokens: ['gamma'], weight: 1 }, - { id: 3, file: 'src/d.ts', tokens: ['alpha'], weight: 1 } - ], - tokenIndex: { - vocab: ['alpha', 'gamma'], - postings: [ - [[0, 1], [1, 1], [3, 1]], - [[2, 1]] - ], - docLengths: [2, 2, 1, 1], - totalDocs: 4, - avgDocLen: 1.5 - }, - denseVec: { - vectors: [ - [0.1, 0.1], - [0.2, 0.2], - [0.3, 0.3], - [0.4, 0.4] - ] - }, - minhash: { - signatures: [ - signatureForTokens(['alpha', 'core']), - signatureForTokens(['alpha', 'extra']), - signatureForTokens(['gamma']), - signatureForTokens(['alpha']) - ] - } -}; - await pipeline(idx, 'code', [0.1, 0.2]); assert.deepEqual( diff --git a/tests/retrieval/pipeline/object-pool-leak.test.js b/tests/retrieval/pipeline/object-pool-leak.test.js deleted file mode 100644 index 8799af6a9..000000000 --- a/tests/retrieval/pipeline/object-pool-leak.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createCandidatePool } from '../../../src/retrieval/pipeline/candidate-pool.js'; -import { createScoreBufferPool } from '../../../src/retrieval/pipeline/score-buffer.js'; - -applyTestEnv(); - -const candidatePool = createCandidatePool({ maxSets: 1, maxEntries: 2 }); -const oversized = candidatePool.acquire(); -oversized.add(1); -oversized.add(2); -oversized.add(3); -candidatePool.release(oversized); -assert.ok(candidatePool.stats.drops > 0, 'expected candidate pool to drop oversized sets'); - -const reused = candidatePool.acquire(); -assert.equal(reused.size, 0, 'expected candidate pool to clear reused sets'); -candidatePool.release(reused); - -const scoreBufferPool = createScoreBufferPool({ maxBuffers: 1, maxEntries: 2 }); -const buffer = scoreBufferPool.acquire({ - fields: ['idx', 'score'], - numericFields: ['idx', 'score'], - capacity: 5 -}); -buffer.push({ idx: 1, score: 0.1 }); -scoreBufferPool.release(buffer); -assert.ok(scoreBufferPool.stats.drops > 0, 'expected score buffer pool to drop oversized buffers'); - -console.log('object pool leak test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-cache-hit.test.js b/tests/retrieval/pipeline/query-cache-hit.test.js deleted file mode 100644 index 6fcc17369..000000000 --- a/tests/retrieval/pipeline/query-cache-hit.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60000 }); -const inputs = createPlanInputs(); -const plan = buildTestPlan(inputs); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature(); -const keyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); - -cache.set( - keyInfo.key, - createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }) -); - -const cached = cache.get(keyInfo.key, { configSignature, indexSignature }); -assert.ok(cached, 'expected cache hit'); -assert.equal(cached.plan, plan, 'expected cached plan to match reference'); - -console.log('query plan cache hit test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-cache-contract-matrix.test.js b/tests/retrieval/pipeline/query-plan-cache-contract-matrix.test.js new file mode 100644 index 000000000..54dd794b4 --- /dev/null +++ b/tests/retrieval/pipeline/query-plan-cache-contract-matrix.test.js @@ -0,0 +1,371 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + createQueryPlanCache, + createQueryPlanDiskCache, + createQueryPlanEntry +} from '../../../src/retrieval/query-plan-cache.js'; +import { validateQueryPlan } from '../../../src/retrieval/query-plan-schema.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + buildPlanCacheKey, + buildPlanConfigSignature, + buildPlanIndexSignature, + buildTestPlan, + createPlanInputs +} from './query-plan-helpers.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const buildEntryContext = (overrides = {}) => { + const inputs = createPlanInputs(overrides); + const plan = buildTestPlan(inputs); + const configSignature = buildPlanConfigSignature(inputs); + const indexSignature = buildPlanIndexSignature(overrides.indexSignatureValue ?? null); + const keyInfo = buildPlanCacheKey({ + query: inputs.query, + configSignature, + indexSignature + }); + return { inputs, plan, configSignature, indexSignature, keyInfo }; +}; + +const cases = [ + { + name: 'cache keys stay stable and change with query/config/index inputs', + run() { + const { inputs, configSignature, indexSignature, keyInfo } = buildEntryContext(); + + const sameKeyInfo = buildPlanCacheKey({ + query: inputs.query, + configSignature, + indexSignature + }); + assert.equal(keyInfo.key, sameKeyInfo.key); + + const differentQueryKey = buildPlanCacheKey({ + query: `${inputs.query} extra`, + configSignature, + indexSignature + }); + assert.notEqual(keyInfo.key, differentQueryKey.key); + + const differentConfigKey = buildPlanCacheKey({ + query: inputs.query, + configSignature: `${configSignature}-alt`, + indexSignature + }); + assert.notEqual(keyInfo.key, differentConfigKey.key); + + const differentIndexKey = buildPlanCacheKey({ + query: inputs.query, + configSignature, + indexSignature: buildPlanIndexSignature({ backend: 'memory', code: 'sig-alt' }) + }); + assert.notEqual(keyInfo.key, differentIndexKey.key); + } + }, + { + name: 'query plans satisfy runtime schema requirements', + run() { + const { plan } = buildEntryContext({ query: 'alpha "beta gamma"' }); + assert.ok(validateQueryPlan(plan)); + assert.ok(Array.isArray(plan.queryTokens)); + assert.ok(plan.highlightRegex instanceof RegExp); + assert.ok(plan.phraseNgramSet instanceof Set || plan.phraseNgramSet === null); + assert.ok(plan.requiredArtifacts instanceof Set); + } + }, + { + name: 'in-memory cache returns hits for matching config and index signatures', + run() { + const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60_000 }); + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext(); + + cache.set(keyInfo.key, createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + })); + + const cached = cache.get(keyInfo.key, { configSignature, indexSignature }); + assert.ok(cached); + assert.equal(cached.plan, plan); + } + }, + { + name: 'in-memory cache invalidates on schema version mismatch', + run() { + const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60_000 }); + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext(); + const entry = createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + }); + entry.schemaVersion = 0; + cache.set(keyInfo.key, entry); + assert.equal(cache.get(keyInfo.key, { configSignature, indexSignature }), null); + } + }, + { + name: 'in-memory cache invalidates on index signature changes', + run() { + const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60_000 }); + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext({ + indexSignatureValue: { backend: 'memory', code: 'sig-a' } + }); + cache.set(keyInfo.key, createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + })); + const differentIndexSignature = buildPlanIndexSignature({ backend: 'memory', code: 'sig-b' }); + assert.equal(cache.get(keyInfo.key, { + configSignature, + indexSignature: differentIndexSignature + }), null); + } + }, + { + name: 'in-memory cache invalidates on config changes', + run() { + const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60_000 }); + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext(); + cache.resetIfConfigChanged(configSignature); + cache.set(keyInfo.key, createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + })); + cache.resetIfConfigChanged(`${configSignature}-changed`); + assert.equal(cache.get(keyInfo.key, { configSignature, indexSignature }), null); + } + }, + { + name: 'in-memory cache evicts oldest entries when max size is exceeded', + run() { + const cache = createQueryPlanCache({ maxEntries: 1, ttlMs: 60_000 }); + + const entryA = buildEntryContext({ + query: 'alpha', + indexSignatureValue: { backend: 'memory', code: 'sig-a' } + }); + cache.set(entryA.keyInfo.key, createQueryPlanEntry({ + plan: entryA.plan, + configSignature: entryA.configSignature, + indexSignature: entryA.indexSignature, + keyPayload: entryA.keyInfo.payload + })); + + const entryB = buildEntryContext({ + query: 'beta', + indexSignatureValue: { backend: 'memory', code: 'sig-b' } + }); + cache.set(entryB.keyInfo.key, createQueryPlanEntry({ + plan: entryB.plan, + configSignature: entryB.configSignature, + indexSignature: entryB.indexSignature, + keyPayload: entryB.keyInfo.payload + })); + + assert.equal( + cache.get(entryA.keyInfo.key, { + configSignature: entryA.configSignature, + indexSignature: entryA.indexSignature + }), + null + ); + assert.ok( + cache.get(entryB.keyInfo.key, { + configSignature: entryB.configSignature, + indexSignature: entryB.indexSignature + }) + ); + } + }, + { + name: 'in-memory cache size never exceeds configured max entries', + run() { + const cache = createQueryPlanCache({ maxEntries: 2, ttlMs: 1_000 }); + + const makeEntry = (query) => { + const ctx = buildEntryContext({ + query, + indexSignatureValue: { code: `sig:${query}` } + }); + return { + key: ctx.keyInfo.key, + configSignature: ctx.configSignature, + indexSignature: ctx.indexSignature, + entry: createQueryPlanEntry({ + plan: ctx.plan, + configSignature: ctx.configSignature, + indexSignature: ctx.indexSignature, + keyPayload: ctx.keyInfo.payload + }) + }; + }; + + const a = makeEntry('alpha'); + const b = makeEntry('beta'); + const c = makeEntry('gamma'); + + cache.set(a.key, a.entry); + cache.set(b.key, b.entry); + cache.set(c.key, c.entry); + + assert.ok(cache.size() <= 2); + assert.equal(cache.get(a.key, { configSignature: a.configSignature, indexSignature: a.indexSignature }), null); + assert.ok(cache.get(b.key, { configSignature: b.configSignature, indexSignature: b.indexSignature })); + assert.ok(cache.get(c.key, { configSignature: c.configSignature, indexSignature: c.indexSignature })); + } + }, + { + name: 'disk cache persists and rehydrates query plans', + async run() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-')); + const cachePath = path.join(tempDir, 'queryPlanCache.json'); + const cache = createQueryPlanDiskCache({ + path: cachePath, + maxEntries: 5, + ttlMs: 60_000, + maxBytes: 1024 * 1024 + }); + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext({ query: 'alpha beta' }); + + cache.load(); + cache.set(keyInfo.key, createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + })); + await cache.persist(); + + assert.ok(fs.existsSync(cachePath)); + + const freshCache = createQueryPlanDiskCache({ + path: cachePath, + maxEntries: 5, + ttlMs: 60_000, + maxBytes: 1024 * 1024 + }); + freshCache.load(); + const cached = freshCache.get(keyInfo.key, { configSignature, indexSignature }); + assert.ok(cached); + assert.ok(Array.isArray(cached.plan.queryTokens)); + assert.ok(cached.plan.highlightRegex instanceof RegExp); + assert.ok(cached.plan.phraseNgramSet == null || cached.plan.phraseNgramSet instanceof Set); + } + }, + { + name: 'corrupt disk cache files are evicted on load', + run() { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'query-plan-cache-contract-matrix-corrupt'); + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.mkdirSync(tempRoot, { recursive: true }); + const cachePath = path.join(tempRoot, 'queryPlanCache.json'); + fs.writeFileSync(cachePath, '{not-json', 'utf8'); + + const cache = createQueryPlanDiskCache({ + path: cachePath, + maxEntries: 8, + ttlMs: 60_000, + maxBytes: 1024 * 1024 + }); + + assert.equal(cache.load(), 0); + assert.equal(fs.existsSync(cachePath), false); + } + }, + { + name: 'disk cache respects max byte caps', + async run() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-size-')); + const cachePath = path.join(tempDir, 'queryPlanCache.json'); + const maxBytes = 1500; + const cache = createQueryPlanDiskCache({ + path: cachePath, + maxEntries: 10, + ttlMs: 60_000, + maxBytes + }); + + cache.load(); + for (let index = 0; index < 8; index += 1) { + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext({ + query: `alpha beta ${'x'.repeat(index * 40)}` + }); + cache.set(keyInfo.key, createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + })); + } + + await cache.persist(); + const stats = fs.statSync(cachePath); + assert.ok(stats.size <= maxBytes, `expected cache size <= ${maxBytes}, got ${stats.size}`); + + const payload = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + assert.ok(Array.isArray(payload.entries)); + assert.ok(payload.entries.length <= 8); + } + }, + { + name: 'oversize newest entries are skipped so smaller older entries survive', + async run() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-size-oversize-head-')); + const cachePath = path.join(tempDir, 'queryPlanCache.json'); + const cache = createQueryPlanDiskCache({ + path: cachePath, + maxEntries: 10, + ttlMs: 60_000, + maxBytes: 10_000 + }); + + const makeEntry = (query, ts) => { + const { plan, configSignature, indexSignature, keyInfo } = buildEntryContext({ query }); + const entry = createQueryPlanEntry({ + plan, + configSignature, + indexSignature, + keyPayload: keyInfo.payload + }); + entry.ts = ts; + return { key: keyInfo.key, entry }; + }; + + const now = Date.now(); + const small = makeEntry('small query', now - 1000); + const huge = makeEntry(`huge query ${'x'.repeat(16_000)}`, now); + + cache.set(small.key, small.entry); + cache.set(huge.key, huge.entry); + await cache.persist(); + + const payload = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + const persistedKeys = new Set((payload.entries || []).map((entry) => entry?.key)); + + assert.equal(persistedKeys.has(small.key), true); + assert.equal(persistedKeys.has(huge.key), false); + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('query plan cache contract matrix test passed'); diff --git a/tests/retrieval/pipeline/query-plan-cache-key.test.js b/tests/retrieval/pipeline/query-plan-cache-key.test.js deleted file mode 100644 index 0cc8e4b4a..000000000 --- a/tests/retrieval/pipeline/query-plan-cache-key.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const inputs = createPlanInputs(); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature(); -const keyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); - -const sameKeyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); -assert.equal(keyInfo.key, sameKeyInfo.key, 'expected stable key for same inputs'); - -const differentQueryKey = buildPlanCacheKey({ - query: `${inputs.query} extra`, - configSignature, - indexSignature -}); -assert.notEqual(keyInfo.key, differentQueryKey.key, 'expected different key for different query'); - -const otherConfigSignature = `${configSignature}-alt`; -const differentConfigKey = buildPlanCacheKey({ - query: inputs.query, - configSignature: otherConfigSignature, - indexSignature -}); -assert.notEqual(keyInfo.key, differentConfigKey.key, 'expected different key for different config'); - -const otherIndexSignature = buildPlanIndexSignature({ backend: 'memory', code: 'sig-alt' }); -const differentIndexKey = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature: otherIndexSignature -}); -assert.notEqual(keyInfo.key, differentIndexKey.key, 'expected different key for different index signature'); - -console.log('query plan cache key test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-disk-cache-size-cap-skips-oversize-head.test.js b/tests/retrieval/pipeline/query-plan-disk-cache-size-cap-skips-oversize-head.test.js deleted file mode 100644 index 3e65adf3d..000000000 --- a/tests/retrieval/pipeline/query-plan-disk-cache-size-cap-skips-oversize-head.test.js +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - createQueryPlanDiskCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -applyTestEnv(); - -const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-size-oversize-head-')); -const cachePath = path.join(tempDir, 'queryPlanCache.json'); - -const cache = createQueryPlanDiskCache({ - path: cachePath, - maxEntries: 10, - ttlMs: 60000, - maxBytes: 10000 -}); - -const makeEntry = (query, ts) => { - const inputs = createPlanInputs({ query }); - const plan = buildTestPlan(inputs); - const configSignature = buildPlanConfigSignature(inputs); - const indexSignature = buildPlanIndexSignature(); - const keyInfo = buildPlanCacheKey({ query: inputs.query, configSignature, indexSignature }); - const entry = createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }); - entry.ts = ts; - return { key: keyInfo.key, entry }; -}; - -const now = Date.now(); -const small = makeEntry('small query', now - 1000); -const huge = makeEntry(`huge query ${'x'.repeat(16000)}`, now); - -cache.set(small.key, small.entry); -cache.set(huge.key, huge.entry); -await cache.persist(); - -const payload = JSON.parse(fs.readFileSync(cachePath, 'utf8')); -const persistedKeys = new Set((payload.entries || []).map((entry) => entry?.key)); - -assert.equal( - persistedKeys.has(small.key), - true, - 'expected cache trimming to keep smaller entries even when newest entry exceeds maxBytes' -); -assert.equal( - persistedKeys.has(huge.key), - false, - 'expected oversize newest entry to be skipped under size cap' -); - -console.log('query plan disk cache oversize-head trimming test passed'); diff --git a/tests/retrieval/pipeline/query-plan-disk-cache-size-cap.test.js b/tests/retrieval/pipeline/query-plan-disk-cache-size-cap.test.js deleted file mode 100644 index 3d61ee830..000000000 --- a/tests/retrieval/pipeline/query-plan-disk-cache-size-cap.test.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - createQueryPlanDiskCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-size-')); -const cachePath = path.join(tempDir, 'queryPlanCache.json'); -const maxBytes = 1500; - -const cache = createQueryPlanDiskCache({ - path: cachePath, - maxEntries: 10, - ttlMs: 60000, - maxBytes -}); - -cache.load(); - -for (let i = 0; i < 8; i += 1) { - const inputs = createPlanInputs({ query: `alpha beta ${'x'.repeat(i * 40)}` }); - const plan = buildTestPlan(inputs); - const configSignature = buildPlanConfigSignature(inputs); - const indexSignature = buildPlanIndexSignature(); - const keyInfo = buildPlanCacheKey({ query: inputs.query, configSignature, indexSignature }); - cache.set( - keyInfo.key, - createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }) - ); -} - -await cache.persist(); - -const stats = fs.statSync(cachePath); -assert.ok(stats.size <= maxBytes, `expected cache size <= ${maxBytes}, got ${stats.size}`); - -const payload = JSON.parse(fs.readFileSync(cachePath, 'utf8')); -assert.ok(Array.isArray(payload.entries), 'expected disk cache entries list'); -assert.ok(payload.entries.length <= 8, 'expected cache entries to be trimmed'); - -console.log('query plan disk cache size cap test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-disk-cache.test.js b/tests/retrieval/pipeline/query-plan-disk-cache.test.js deleted file mode 100644 index 51376fd2e..000000000 --- a/tests/retrieval/pipeline/query-plan-disk-cache.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - createQueryPlanDiskCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-query-plan-')); -const cachePath = path.join(tempDir, 'queryPlanCache.json'); - -const inputs = createPlanInputs({ query: 'alpha beta' }); -const plan = buildTestPlan(inputs); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature(); -const keyInfo = buildPlanCacheKey({ query: inputs.query, configSignature, indexSignature }); - -const cache = createQueryPlanDiskCache({ - path: cachePath, - maxEntries: 5, - ttlMs: 60000, - maxBytes: 1024 * 1024 -}); - -cache.load(); -cache.set( - keyInfo.key, - createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }) -); -await cache.persist(); - -assert.ok(fs.existsSync(cachePath), 'expected disk cache file to be written'); - -const freshCache = createQueryPlanDiskCache({ - path: cachePath, - maxEntries: 5, - ttlMs: 60000, - maxBytes: 1024 * 1024 -}); - -freshCache.load(); -const cached = freshCache.get(keyInfo.key, { configSignature, indexSignature }); -assert.ok(cached, 'expected disk cache hit'); -assert.ok(Array.isArray(cached.plan.queryTokens), 'expected cached plan tokens'); -assert.ok(cached.plan.highlightRegex instanceof RegExp, 'expected highlight regex to be hydrated'); -assert.ok( - cached.plan.phraseNgramSet == null || cached.plan.phraseNgramSet instanceof Set, - 'expected phraseNgramSet to be hydrated' -); - -console.log('query plan disk cache test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-eviction.test.js b/tests/retrieval/pipeline/query-plan-eviction.test.js deleted file mode 100644 index 6bf684f7f..000000000 --- a/tests/retrieval/pipeline/query-plan-eviction.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const cache = createQueryPlanCache({ maxEntries: 1, ttlMs: 60000 }); - -const inputsA = createPlanInputs({ query: 'alpha' }); -const planA = buildTestPlan(inputsA); -const configA = buildPlanConfigSignature(inputsA); -const indexA = buildPlanIndexSignature({ backend: 'memory', code: 'sig-a' }); -const keyA = buildPlanCacheKey({ query: inputsA.query, configSignature: configA, indexSignature: indexA }); - -cache.set( - keyA.key, - createQueryPlanEntry({ - plan: planA, - configSignature: configA, - indexSignature: indexA, - keyPayload: keyA.payload - }) -); - -const inputsB = createPlanInputs({ query: 'beta' }); -const planB = buildTestPlan(inputsB); -const configB = buildPlanConfigSignature(inputsB); -const indexB = buildPlanIndexSignature({ backend: 'memory', code: 'sig-b' }); -const keyB = buildPlanCacheKey({ query: inputsB.query, configSignature: configB, indexSignature: indexB }); - -cache.set( - keyB.key, - createQueryPlanEntry({ - plan: planB, - configSignature: configB, - indexSignature: indexB, - keyPayload: keyB.payload - }) -); - -const evicted = cache.get(keyA.key, { configSignature: configA, indexSignature: indexA }); -assert.equal(evicted, null, 'expected first entry to be evicted'); - -const retained = cache.get(keyB.key, { configSignature: configB, indexSignature: indexB }); -assert.ok(retained, 'expected second entry to remain'); - -console.log('query plan cache eviction test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-helpers.js b/tests/retrieval/pipeline/query-plan-helpers.js index 2266cfba6..65599d029 100644 --- a/tests/retrieval/pipeline/query-plan-helpers.js +++ b/tests/retrieval/pipeline/query-plan-helpers.js @@ -5,6 +5,50 @@ import { buildQueryPlanIndexSignature } from '../../../src/retrieval/query-plan-cache.js'; +const createDefaultPlanConfigInputs = ({ dictConfig, postingsConfig }) => ({ + dictConfig, + postingsConfig, + caseTokens: false, + fileFilter: null, + caseFile: false, + searchRegexConfig: null, + filePrefilterEnabled: true, + fileChargramN: postingsConfig.chargramMinN, + searchType: null, + searchAuthor: null, + searchImport: null, + chunkAuthorFilter: null, + branchesMin: null, + loopsMin: null, + breaksMin: null, + continuesMin: null, + churnMin: null, + extFilter: null, + langFilter: null, + extImpossible: null, + langImpossible: null, + metaFilters: null, + modifiedAfter: null, + modifiedSinceDays: null, + fieldWeightsConfig: null, + denseVectorMode: 'merged', + branchFilter: null +}); + +const PLAN_CONFIG_KEYS = Object.keys(createDefaultPlanConfigInputs({ + dictConfig: null, + postingsConfig: { chargramMinN: null } +})); + +const projectPlanConfigInputs = (inputs) => { + const projected = {}; + for (const key of PLAN_CONFIG_KEYS) { + projected[key] = inputs[key]; + } + projected.dictSize = inputs.dict?.size ?? null; + return projected; +}; + export function createPlanInputs(overrides = {}) { const query = overrides.query ?? 'alpha beta'; const argv = { @@ -49,68 +93,13 @@ export function createPlanInputs(overrides = {}) { query, argv, dict, - dictConfig, - postingsConfig, - caseTokens: false, - fileFilter: null, - caseFile: false, - searchRegexConfig: null, - filePrefilterEnabled: true, - fileChargramN: postingsConfig.chargramMinN, - searchType: null, - searchAuthor: null, - searchImport: null, - chunkAuthorFilter: null, - branchesMin: null, - loopsMin: null, - breaksMin: null, - continuesMin: null, - churnMin: null, - extFilter: null, - langFilter: null, - extImpossible: null, - langImpossible: null, - metaFilters: null, - modifiedAfter: null, - modifiedSinceDays: null, - fieldWeightsConfig: null, - denseVectorMode: 'merged', - branchFilter: null + ...createDefaultPlanConfigInputs({ dictConfig, postingsConfig }) }; return { ...inputs, ...overrides }; } export function buildPlanConfigSignature(inputs) { - return buildQueryPlanConfigSignature({ - dictConfig: inputs.dictConfig, - dictSize: inputs.dict?.size ?? null, - postingsConfig: inputs.postingsConfig, - caseTokens: inputs.caseTokens, - fileFilter: inputs.fileFilter, - caseFile: inputs.caseFile, - searchRegexConfig: inputs.searchRegexConfig, - filePrefilterEnabled: inputs.filePrefilterEnabled, - fileChargramN: inputs.fileChargramN, - searchType: inputs.searchType, - searchAuthor: inputs.searchAuthor, - searchImport: inputs.searchImport, - chunkAuthorFilter: inputs.chunkAuthorFilter, - branchesMin: inputs.branchesMin, - loopsMin: inputs.loopsMin, - breaksMin: inputs.breaksMin, - continuesMin: inputs.continuesMin, - churnMin: inputs.churnMin, - extFilter: inputs.extFilter, - langFilter: inputs.langFilter, - extImpossible: inputs.extImpossible, - langImpossible: inputs.langImpossible, - metaFilters: inputs.metaFilters, - modifiedAfter: inputs.modifiedAfter, - modifiedSinceDays: inputs.modifiedSinceDays, - fieldWeightsConfig: inputs.fieldWeightsConfig, - denseVectorMode: inputs.denseVectorMode, - branchFilter: inputs.branchFilter - }); + return buildQueryPlanConfigSignature(projectPlanConfigInputs(inputs)); } export function buildPlanIndexSignature(value = null) { @@ -131,32 +120,6 @@ export function buildTestPlan(inputs) { query: inputs.query, argv: inputs.argv, dict: inputs.dict, - dictConfig: inputs.dictConfig, - postingsConfig: inputs.postingsConfig, - caseTokens: inputs.caseTokens, - fileFilter: inputs.fileFilter, - caseFile: inputs.caseFile, - searchRegexConfig: inputs.searchRegexConfig, - filePrefilterEnabled: inputs.filePrefilterEnabled, - fileChargramN: inputs.fileChargramN, - searchType: inputs.searchType, - searchAuthor: inputs.searchAuthor, - searchImport: inputs.searchImport, - chunkAuthorFilter: inputs.chunkAuthorFilter, - branchesMin: inputs.branchesMin, - loopsMin: inputs.loopsMin, - breaksMin: inputs.breaksMin, - continuesMin: inputs.continuesMin, - churnMin: inputs.churnMin, - extFilter: inputs.extFilter, - langFilter: inputs.langFilter, - extImpossible: inputs.extImpossible, - langImpossible: inputs.langImpossible, - metaFilters: inputs.metaFilters, - modifiedAfter: inputs.modifiedAfter, - modifiedSinceDays: inputs.modifiedSinceDays, - fieldWeightsConfig: inputs.fieldWeightsConfig, - denseVectorMode: inputs.denseVectorMode, - branchFilter: inputs.branchFilter + ...projectPlanConfigInputs(inputs) }); } diff --git a/tests/retrieval/pipeline/query-plan-invalidates-config.test.js b/tests/retrieval/pipeline/query-plan-invalidates-config.test.js deleted file mode 100644 index ff566ffd4..000000000 --- a/tests/retrieval/pipeline/query-plan-invalidates-config.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60000 }); -const inputs = createPlanInputs(); -const plan = buildTestPlan(inputs); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature(); -const keyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); - -cache.resetIfConfigChanged(configSignature); -cache.set( - keyInfo.key, - createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }) -); - -const nextConfigSignature = `${configSignature}-changed`; -cache.resetIfConfigChanged(nextConfigSignature); - -const cached = cache.get(keyInfo.key, { configSignature, indexSignature }); -assert.equal(cached, null, 'expected cache miss after config change'); - -console.log('query plan cache invalidates on config change test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-invalidates-index.test.js b/tests/retrieval/pipeline/query-plan-invalidates-index.test.js deleted file mode 100644 index f59c016ac..000000000 --- a/tests/retrieval/pipeline/query-plan-invalidates-index.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60000 }); -const inputs = createPlanInputs(); -const plan = buildTestPlan(inputs); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature({ backend: 'memory', code: 'sig-a' }); -const keyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); - -cache.set( - keyInfo.key, - createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload - }) -); - -const differentIndexSignature = buildPlanIndexSignature({ backend: 'memory', code: 'sig-b' }); -const cached = cache.get(keyInfo.key, { - configSignature, - indexSignature: differentIndexSignature -}); -assert.equal(cached, null, 'expected cache miss when index signature changes'); - -console.log('query plan cache invalidates on index signature test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-requirements.test.js b/tests/retrieval/pipeline/query-plan-requirements.test.js deleted file mode 100644 index 4fa4449c8..000000000 --- a/tests/retrieval/pipeline/query-plan-requirements.test.js +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { validateQueryPlan } from '../../../src/retrieval/query-plan-schema.js'; -import { buildTestPlan, createPlanInputs } from './query-plan-helpers.js'; - -applyTestEnv(); - -const inputs = createPlanInputs({ query: 'alpha "beta gamma"' }); -const plan = buildTestPlan(inputs); - -assert.ok(validateQueryPlan(plan), 'expected query plan to pass schema validation'); -assert.ok(Array.isArray(plan.queryTokens), 'expected query tokens array'); -assert.ok(plan.highlightRegex instanceof RegExp, 'expected highlight regex'); -assert.ok(plan.phraseNgramSet instanceof Set || plan.phraseNgramSet === null, 'expected phrase ngram set'); -assert.ok(plan.requiredArtifacts instanceof Set, 'expected requiredArtifacts set'); - -console.log('query plan requirements test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/query-plan-schema-version.test.js b/tests/retrieval/pipeline/query-plan-schema-version.test.js deleted file mode 100644 index 0cfc10a92..000000000 --- a/tests/retrieval/pipeline/query-plan-schema-version.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createQueryPlanCache, - createQueryPlanEntry -} from '../../../src/retrieval/query-plan-cache.js'; -import { - buildPlanCacheKey, - buildPlanConfigSignature, - buildPlanIndexSignature, - buildTestPlan, - createPlanInputs -} from './query-plan-helpers.js'; - -applyTestEnv(); - -const cache = createQueryPlanCache({ maxEntries: 5, ttlMs: 60000 }); -const inputs = createPlanInputs(); -const plan = buildTestPlan(inputs); -const configSignature = buildPlanConfigSignature(inputs); -const indexSignature = buildPlanIndexSignature(); -const keyInfo = buildPlanCacheKey({ - query: inputs.query, - configSignature, - indexSignature -}); - -const entry = createQueryPlanEntry({ - plan, - configSignature, - indexSignature, - keyPayload: keyInfo.payload -}); -entry.schemaVersion = 0; - -cache.set(keyInfo.key, entry); - -const cached = cache.get(keyInfo.key, { configSignature, indexSignature }); -assert.equal(cached, null, 'expected schema version mismatch to invalidate cache'); - -console.log('query plan schema version test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/ranking-fastpath.test.js b/tests/retrieval/pipeline/ranking-fastpath.test.js deleted file mode 100644 index a54f7be80..000000000 --- a/tests/retrieval/pipeline/ranking-fastpath.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { compareTopKEntries, selectTopK } from '../../../src/retrieval/pipeline/topk.js'; - -applyTestEnv(); - -const items = Array.from({ length: 500 }, (_, i) => ({ - idx: i, - score: ((i * 73) % 97) + (i % 5) * 0.01 -})); -const k = 5; -const stats = {}; -const result = selectTopK(items, { - k, - score: (item) => item.score, - id: (item) => item.idx, - sourceRank: (_, index) => index, - stats -}); - -const expected = items - .map((item, index) => ({ - item, - score: item.score, - id: item.idx, - sourceRank: index - })) - .sort(compareTopKEntries) - .slice(0, k) - .map((entry) => entry.item.idx); - -assert.deepEqual( - result.map((item) => item.idx), - expected, - 'expected topk to match full sort' -); -assert.equal(stats.usedHeap, true, 'expected heap fastpath'); -assert.equal(stats.usedSort, false, 'expected heap path to avoid full sort'); - -console.log('ranking fastpath test passed'); diff --git a/tests/retrieval/pipeline/retrieval-stage-checkpoints.test.js b/tests/retrieval/pipeline/retrieval-stage-checkpoints.test.js index 36b89d531..553c27dde 100644 --- a/tests/retrieval/pipeline/retrieval-stage-checkpoints.test.js +++ b/tests/retrieval/pipeline/retrieval-stage-checkpoints.test.js @@ -1,91 +1,35 @@ #!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; import { createRetrievalStageTracker } from '../../../src/retrieval/pipeline/stage-checkpoints.js'; +import { + createAlphaSearchIndex, + createSearchPipelineFixture +} from '../helpers/search-pipeline-fixture.js'; applyTestEnv(); const stageTracker = createRetrievalStageTracker({ enabled: true }); -const context = { - useSqlite: false, - sqliteFtsRequested: false, - sqliteFtsNormalize: false, - sqliteFtsProfile: 'balanced', +const pipeline = createSearchPipelineFixture({ sqliteFtsWeights: null, - bm25K1: 1.2, - bm25B: 0.75, - fieldWeights: null, postingsConfig: { enablePhraseNgrams: false, enableChargrams: false, chargramMinN: 3, chargramMaxN: 3 }, - queryTokens: ['alpha'], - queryAst: null, - phraseNgramSet: null, - phraseRange: null, explain: false, - symbolBoost: { enabled: false }, - filters: {}, - filtersActive: false, topN: 3, maxCandidates: null, annEnabled: true, annBackend: 'js', - scoreBlend: null, - minhashMaxDocs: null, - sparseBackend: 'auto', - vectorAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - vectorAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - hnswAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - hnswAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - lanceAnnState: { - code: { available: false }, - prose: { available: false }, - records: { available: false }, - 'extracted-prose': { available: false } - }, - lanceAnnUsed: { - code: false, - prose: false, - records: false, - 'extracted-prose': false - }, - lancedbConfig: {}, - buildCandidateSetSqlite: () => null, - getTokenIndexForQuery: () => null, rankSqliteFts: () => ({ hits: [], type: 'fts' }), - rankVectorAnnSqlite: () => [], - sqliteHasFts: () => false, - signal: null, - rrf: { enabled: false }, graphRankingConfig: { enabled: false }, stageTracker -}; +}); -const idx = { - chunkMeta: [ +const idx = createAlphaSearchIndex({ + chunks: [ { id: 0, file: 'src/a.js', @@ -95,20 +39,9 @@ const idx = { weight: 1 } ], - tokenIndex: { - vocab: ['alpha'], - postings: [[[0, 1]]], - docLengths: [1], - totalDocs: 1, - avgDocLen: 1 - }, - filterIndex: null, - fileRelations: null, repoMap: null, - minhash: null -}; +}); -const pipeline = createSearchPipeline(context); await pipeline(idx, 'code', [0, 0]); const stages = stageTracker.stages.map((entry) => entry.stage); @@ -119,4 +52,3 @@ assert.ok(stages.includes('fusion'), 'expected fusion stage'); assert.ok(stages.includes('rank'), 'expected rank stage'); console.log('retrieval stage checkpoints test passed'); -import { applyTestEnv } from '../../helpers/test-env.js'; diff --git a/tests/retrieval/pipeline/search-startup-fastpath.test.js b/tests/retrieval/pipeline/search-startup-fastpath.test.js index 466fe96ac..6407d0fad 100644 --- a/tests/retrieval/pipeline/search-startup-fastpath.test.js +++ b/tests/retrieval/pipeline/search-startup-fastpath.test.js @@ -1,22 +1,24 @@ #!/usr/bin/env node import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { runNode } from '../../helpers/run-node.js'; -applyTestEnv(); +const env = applyTestEnv({ syncProcess: false }); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const searchScript = path.join(ROOT, 'search.js'); const repoDir = await makeTempDir('pairofcleats-search-help-'); try { - const helpResult = spawnSync( - process.execPath, + const helpResult = runNode( [searchScript, '--help', '--repo', repoDir], - { encoding: 'utf8' } + 'search startup fastpath help', + ROOT, + env, + { stdio: 'pipe' } ); assert.equal(helpResult.status, 0, 'expected search --help to exit 0'); assert.ok( @@ -24,10 +26,12 @@ try { 'expected help output' ); - const versionResult = spawnSync( - process.execPath, + const versionResult = runNode( [searchScript, '--version'], - { encoding: 'utf8' } + 'search startup fastpath version', + ROOT, + env, + { stdio: 'pipe' } ); assert.equal(versionResult.status, 0, 'expected search --version to exit 0'); assert.ok(versionResult.stdout.trim().length > 0, 'expected version output'); diff --git a/tests/retrieval/pipeline/topk-contract-matrix.test.js b/tests/retrieval/pipeline/topk-contract-matrix.test.js new file mode 100644 index 000000000..9fb881d63 --- /dev/null +++ b/tests/retrieval/pipeline/topk-contract-matrix.test.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { compareTopKEntries, createTopKReducer, selectTopK } from '../../../src/retrieval/pipeline/topk.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const cases = [ + { + name: 'sorted top-k reducer cuts off early without changing winners', + run() { + const total = 2000; + const k = 8; + const items = Array.from({ length: total }, (_, index) => ({ + idx: index, + score: total - index, + sourceRank: index + })); + + const reducer = createTopKReducer({ k, sorted: true }); + for (const item of items) { + const stop = reducer.pushRaw(item.score, item.idx, item.sourceRank); + if (stop) break; + } + + const result = reducer.finish({ limit: k }); + const baseline = items + .slice() + .sort((a, b) => compareTopKEntries( + { score: a.score, id: a.idx, sourceRank: a.sourceRank }, + { score: b.score, id: b.idx, sourceRank: b.sourceRank } + )) + .slice(0, k); + + assert.ok(reducer.stats.cutoffs > 0); + assert.deepEqual( + result.map((entry) => ({ idx: entry.idx, score: entry.score })), + baseline.map((entry) => ({ idx: entry.idx, score: entry.score })) + ); + } + }, + { + name: 'heap path plateaus memory near k plus slack', + run() { + const total = 50_000; + const k = 10; + const slack = 5; + const reducer = createTopKReducer({ k, slack, sorted: true }); + + for (let index = 0; index < total; index += 1) { + const stop = reducer.pushRaw(total - index, index, index); + if (stop) break; + } + + assert.equal(reducer.stats.usedHeap, true); + assert.ok(reducer.stats.maxSize <= k + slack); + } + }, + { + name: 'selectTopK fastpath matches full sort order', + run() { + const items = Array.from({ length: 500 }, (_, index) => ({ + idx: index, + score: ((index * 73) % 97) + (index % 5) * 0.01 + })); + const k = 5; + const stats = {}; + const result = selectTopK(items, { + k, + score: (item) => item.score, + id: (item) => item.idx, + sourceRank: (_, index) => index, + stats + }); + + const expected = items + .map((item, index) => ({ + item, + score: item.score, + id: item.idx, + sourceRank: index + })) + .sort(compareTopKEntries) + .slice(0, k) + .map((entry) => entry.item.idx); + + assert.deepEqual(result.map((item) => item.idx), expected); + assert.equal(stats.usedHeap, true); + assert.equal(stats.usedSort, false); + } + }, + { + name: 'selectTopK matches deterministic baselines across randomized seeds', + run() { + const makeRng = (seed) => { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; + }; + + const buildExpected = (items, k) => items + .map((item, index) => ({ + item, + score: item.score, + id: item.idx, + sourceRank: index + })) + .slice() + .sort(compareTopKEntries) + .slice(0, k) + .map((entry) => entry.item.idx); + + for (const seed of [11, 42, 1337]) { + const rng = makeRng(seed); + const items = Array.from({ length: 200 }, (_, index) => ({ + idx: index, + score: Math.round(rng() * 1000) / 1000 + })); + const result = selectTopK(items, { + k: 15, + score: (item) => item.score, + id: (item) => item.idx, + sourceRank: (_, index) => index + }); + assert.deepEqual(result.map((item) => item.idx), buildExpected(items, 15), `seed ${seed}`); + } + } + }, + { + name: 'tie-breaks remain deterministic across mixed id types and duplicate ids', + run() { + const items = [ + { idx: 'b', score: 1 }, + { idx: 2, score: 1 }, + { idx: 'a', score: 1 }, + { idx: 1, score: 1 }, + { idx: 1, score: 1, label: 'second-dup' } + ]; + + const expected = items + .map((item, index) => ({ + item, + score: item.score, + id: item.idx, + sourceRank: index + })) + .slice() + .sort(compareTopKEntries) + .map((entry) => entry.item); + + const result = selectTopK(items, { + k: items.length, + score: (item) => item.score, + id: (item) => item.idx, + sourceRank: (_, index) => index + }); + + assert.deepEqual(result.map((item) => item.idx), expected.map((item) => item.idx)); + assert.equal( + result.findIndex((item) => item.label === 'second-dup') > result.findIndex((item) => item.idx === 1), + true + ); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('topk contract matrix test passed'); diff --git a/tests/retrieval/pipeline/topk-equivalence.test.js b/tests/retrieval/pipeline/topk-equivalence.test.js deleted file mode 100644 index 664db17d7..000000000 --- a/tests/retrieval/pipeline/topk-equivalence.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { compareTopKEntries, selectTopK } from '../../../src/retrieval/pipeline/topk.js'; - -applyTestEnv(); - -const makeRng = (seed) => { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0xffffffff; - }; -}; - -const buildExpected = (items, k) => { - const entries = items.map((item, index) => ({ - item, - score: item.score, - id: item.idx, - sourceRank: index - })); - return entries - .slice() - .sort(compareTopKEntries) - .slice(0, k) - .map((entry) => entry.item); -}; - -const runSeed = (seed) => { - const rng = makeRng(seed); - const items = Array.from({ length: 200 }, (_, i) => ({ - idx: i, - score: Math.round(rng() * 1000) / 1000 - })); - const k = 15; - const expected = buildExpected(items, k); - const result = selectTopK(items, { - k, - score: (item) => item.score, - id: (item) => item.idx, - sourceRank: (_, index) => index - }); - assert.deepEqual( - result.map((item) => item.idx), - expected.map((item) => item.idx), - `topk mismatch for seed ${seed}` - ); -}; - -runSeed(11); -runSeed(42); -runSeed(1337); - -console.log('topk equivalence test passed'); diff --git a/tests/retrieval/pipeline/topk-tie-break.test.js b/tests/retrieval/pipeline/topk-tie-break.test.js deleted file mode 100644 index 59dc2ca36..000000000 --- a/tests/retrieval/pipeline/topk-tie-break.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import { compareTopKEntries, selectTopK } from '../../../src/retrieval/pipeline/topk.js'; - -applyTestEnv(); - -const items = [ - { idx: 'b', score: 1 }, - { idx: 2, score: 1 }, - { idx: 'a', score: 1 }, - { idx: 1, score: 1 }, - { idx: 1, score: 1, label: 'second-dup' } -]; - -const entries = items.map((item, index) => ({ - item, - score: item.score, - id: item.idx, - sourceRank: index -})); - -const expected = entries - .slice() - .sort(compareTopKEntries) - .map((entry) => entry.item); - -const result = selectTopK(items, { - k: items.length, - score: (item) => item.score, - id: (item) => item.idx, - sourceRank: (_, index) => index -}); - -assert.deepEqual( - result.map((item) => item.idx), - expected.map((item) => item.idx), - 'expected deterministic tie-break ordering' -); -assert.equal( - result.findIndex((item) => item.label === 'second-dup') > result.findIndex((item) => item.idx === 1), - true, - 'expected duplicate id ordering to follow source rank' -); - -console.log('topk tie-break test passed'); diff --git a/tests/retrieval/providers/provider-adaptive-ordering-contract.test.js b/tests/retrieval/providers/provider-adaptive-ordering-contract.test.js deleted file mode 100644 index 2b83c0e8f..000000000 --- a/tests/retrieval/providers/provider-adaptive-ordering-contract.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { INDEX_PROFILE_VECTOR_ONLY } from '../../../src/contracts/index-profile.js'; -import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; - -let primaryCalls = 0; -let fallbackCalls = 0; - -const primaryProvider = { - id: ANN_PROVIDER_IDS.LANCEDB, - isAvailable: () => true, - preflight: async () => true, - query: async () => { - primaryCalls += 1; - if (primaryCalls === 1) throw new Error('transient provider failure'); - return [{ idx: 0, sim: 0.95 }]; - } -}; - -const fallbackProvider = { - id: ANN_PROVIDER_IDS.SQLITE_VECTOR, - isAvailable: () => true, - preflight: async () => true, - query: async () => { - fallbackCalls += 1; - return [{ idx: 1, sim: 0.94 }]; - } -}; - -const { context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([ - [ANN_PROVIDER_IDS.LANCEDB, primaryProvider], - [ANN_PROVIDER_IDS.SQLITE_VECTOR, fallbackProvider] - ]) -}); -idx.state = { profile: { id: INDEX_PROFILE_VECTOR_ONLY } }; -context.annBackend = 'auto'; -context.annAdaptiveProviders = true; - -const pipeline = createSearchPipeline(context); - -const originalNow = Date.now; -let now = originalNow(); -Date.now = () => now; - -let run1 = null; -let run2 = null; -try { - run1 = await pipeline(idx, 'code', [0.1, 0.2]); - now += 1100; // advance past retry cooldown so adaptive ordering controls selection - run2 = await pipeline(idx, 'code', [0.1, 0.2]); -} finally { - Date.now = originalNow; -} - -assert.ok(Array.isArray(run1) && run1.length > 0, 'expected fallback results when primary provider fails'); -assert.ok(Array.isArray(run2) && run2.length > 0, 'expected ANN results after adaptive provider ordering'); -assert.equal(primaryCalls, 1, 'expected adaptive ordering to avoid retrying degraded provider immediately'); -assert.equal(fallbackCalls, 2, 'expected fallback provider to handle both queries'); -assert.ok( - run2.some((entry) => entry.annSource === ANN_PROVIDER_IDS.SQLITE_VECTOR), - 'expected adaptive run to source ANN hits from fallback provider' -); - -console.log('provider adaptive ordering contract test passed'); diff --git a/tests/retrieval/providers/provider-empty-ann-success-reset-contract.test.js b/tests/retrieval/providers/provider-empty-ann-success-reset-contract.test.js deleted file mode 100644 index 4ce92eda6..000000000 --- a/tests/retrieval/providers/provider-empty-ann-success-reset-contract.test.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; - -let queryCalls = 0; - -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - preflight: async () => true, - query: async () => { - queryCalls += 1; - if (queryCalls === 1) throw new Error('transient ann error #1'); - if (queryCalls === 2) return []; - if (queryCalls === 3) throw new Error('transient ann error #2'); - return [{ idx: 0, sim: 0.95 }]; - } -}; - -const { context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); -const pipeline = createSearchPipeline(context); - -const originalNow = Date.now; -let now = originalNow(); -Date.now = () => now; - -let run1 = null; -let run2 = null; -let run3 = null; -let run4 = null; -let run5 = null; -try { - run1 = await pipeline(idx, 'code', [0.1, 0.2]); - run2 = await pipeline(idx, 'code', [0.1, 0.2]); - now += 1100; - run3 = await pipeline(idx, 'code', [0.1, 0.2]); - run4 = await pipeline(idx, 'code', [0.1, 0.2]); - now += 1100; - run5 = await pipeline(idx, 'code', [0.1, 0.2]); -} finally { - Date.now = originalNow; -} - -assert.ok(Array.isArray(run1) && run1.length > 0, 'expected sparse fallback after initial ANN failure'); -assert.ok(Array.isArray(run2) && run2.length > 0, 'expected sparse fallback during first cooldown'); -assert.ok(Array.isArray(run3) && run3.length > 0, 'expected sparse fallback when ANN returns empty result'); -assert.ok(Array.isArray(run4) && run4.length > 0, 'expected sparse fallback after second ANN failure'); -assert.ok(Array.isArray(run5) && run5.length > 0, 'expected results on retry after second failure'); -assert.equal(queryCalls, 4, 'expected retry cadence to reset after empty-result ANN success'); -assert.ok(run5.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE), 'expected ANN source after second retry'); - -console.log('provider empty ANN success reset contract test passed'); diff --git a/tests/retrieval/providers/provider-retry-reset-contract.test.js b/tests/retrieval/providers/provider-retry-reset-contract.test.js deleted file mode 100644 index 96f52be0f..000000000 --- a/tests/retrieval/providers/provider-retry-reset-contract.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; - -let preflightCalls = 0; -let queryCalls = 0; - -const provider = { - id: ANN_PROVIDER_IDS.DENSE, - isAvailable: () => true, - preflight: async () => { - preflightCalls += 1; - return preflightCalls > 1; - }, - query: async () => { - queryCalls += 1; - return [{ idx: 0, sim: 0.99 }]; - } -}; - -const { stageTracker, context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) -}); -const pipeline = createSearchPipeline(context); - -const originalNow = Date.now; -let now = originalNow(); -Date.now = () => now; - -let run1 = null; -let run2 = null; -let run3 = null; -try { - run1 = await pipeline(idx, 'code', [0.1, 0.2]); - run2 = await pipeline(idx, 'code', [0.1, 0.2]); - now += 1500; - run3 = await pipeline(idx, 'code', [0.1, 0.2]); -} finally { - Date.now = originalNow; -} - -assert.ok(Array.isArray(run1) && run1.length > 0, 'expected sparse fallback results on first run'); -assert.ok(Array.isArray(run2) && run2.length > 0, 'expected sparse fallback results during cooldown'); -assert.ok(Array.isArray(run3) && run3.length > 0, 'expected results after provider retry'); -assert.equal(preflightCalls, 2, 'expected provider preflight to retry after cooldown'); -assert.equal(queryCalls, 1, 'expected provider query once after successful preflight reset'); -assert.ok(run3.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE), 'expected ANN source after retry'); -assert.ok(run3.some((entry) => entry.annType === 'vector'), 'expected annType to reflect vector source'); - -const annStages = stageTracker.stages.filter((entry) => entry.stage === 'ann'); -assert.ok(annStages.length >= 3, 'expected ann stage telemetry for each run'); - -console.log('provider retry reset contract test passed'); diff --git a/tests/retrieval/providers/provider-runtime-contract-matrix.test.js b/tests/retrieval/providers/provider-runtime-contract-matrix.test.js new file mode 100644 index 000000000..e3c6fb27b --- /dev/null +++ b/tests/retrieval/providers/provider-runtime-contract-matrix.test.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { INDEX_PROFILE_VECTOR_ONLY } from '../../../src/contracts/index-profile.js'; +import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; +import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; +import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; + +const withMockedNow = async (action) => { + const originalNow = Date.now; + let now = originalNow(); + Date.now = () => now; + try { + return await action({ + advance: (ms) => { + now += ms; + return now; + } + }); + } finally { + Date.now = originalNow; + } +}; + +const cases = [ + { + name: 'empty ANN success resets retry cadence after transient failures', + async run() { + let queryCalls = 0; + const provider = { + id: ANN_PROVIDER_IDS.DENSE, + isAvailable: () => true, + preflight: async () => true, + query: async () => { + queryCalls += 1; + if (queryCalls === 1) throw new Error('transient ann error #1'); + if (queryCalls === 2) return []; + if (queryCalls === 3) throw new Error('transient ann error #2'); + return [{ idx: 0, sim: 0.95 }]; + } + }; + const { context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) + }); + const pipeline = createSearchPipeline(context); + await withMockedNow(async (clock) => { + const run1 = await pipeline(idx, 'code', [0.1, 0.2]); + const run2 = await pipeline(idx, 'code', [0.1, 0.2]); + clock.advance(1100); + const run3 = await pipeline(idx, 'code', [0.1, 0.2]); + const run4 = await pipeline(idx, 'code', [0.1, 0.2]); + clock.advance(1100); + const run5 = await pipeline(idx, 'code', [0.1, 0.2]); + assert.ok(Array.isArray(run1) && run1.length > 0); + assert.ok(Array.isArray(run2) && run2.length > 0); + assert.ok(Array.isArray(run3) && run3.length > 0); + assert.ok(Array.isArray(run4) && run4.length > 0); + assert.ok(Array.isArray(run5) && run5.length > 0); + assert.equal(queryCalls, 4); + assert.ok(run5.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE)); + }); + } + }, + { + name: 'provider retry state resets after cooldown and successful preflight', + async run() { + let preflightCalls = 0; + let queryCalls = 0; + const provider = { + id: ANN_PROVIDER_IDS.DENSE, + isAvailable: () => true, + preflight: async () => { + preflightCalls += 1; + return preflightCalls > 1; + }, + query: async () => { + queryCalls += 1; + return [{ idx: 0, sim: 0.99 }]; + } + }; + const { stageTracker, context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([[ANN_PROVIDER_IDS.DENSE, provider]]) + }); + const pipeline = createSearchPipeline(context); + await withMockedNow(async (clock) => { + const run1 = await pipeline(idx, 'code', [0.1, 0.2]); + const run2 = await pipeline(idx, 'code', [0.1, 0.2]); + clock.advance(1500); + const run3 = await pipeline(idx, 'code', [0.1, 0.2]); + assert.ok(Array.isArray(run1) && run1.length > 0); + assert.ok(Array.isArray(run2) && run2.length > 0); + assert.ok(Array.isArray(run3) && run3.length > 0); + assert.equal(preflightCalls, 2); + assert.equal(queryCalls, 1); + assert.ok(run3.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE)); + assert.ok(run3.some((entry) => entry.annType === 'vector')); + const annStages = stageTracker.stages.filter((entry) => entry.stage === 'ann'); + assert.ok(annStages.length >= 3); + }); + } + }, + { + name: 'unnamed provider state stays isolated across fallback providers', + async run() { + let primaryPreflightCalls = 0; + let fallbackPreflightCalls = 0; + let fallbackQueryCalls = 0; + const primaryProvider = { + isAvailable: () => true, + preflight: async () => { + primaryPreflightCalls += 1; + return false; + }, + query: async () => [] + }; + const fallbackProvider = { + isAvailable: () => true, + preflight: async () => { + fallbackPreflightCalls += 1; + return true; + }, + query: async () => { + fallbackQueryCalls += 1; + return [{ idx: 0, sim: 0.97 }]; + } + }; + const { context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([ + [ANN_PROVIDER_IDS.HNSW, primaryProvider], + [ANN_PROVIDER_IDS.DENSE, fallbackProvider] + ]) + }); + context.annBackend = 'auto'; + const pipeline = createSearchPipeline(context); + const results = await pipeline(idx, 'code', [0.1, 0.2]); + assert.ok(Array.isArray(results) && results.length > 0); + assert.equal(primaryPreflightCalls, 1); + assert.equal(fallbackPreflightCalls, 1); + assert.equal(fallbackQueryCalls, 1); + assert.ok(results.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE)); + } + }, + { + name: 'adaptive ordering prefers healthy fallback after degraded primary', + async run() { + let primaryCalls = 0; + let fallbackCalls = 0; + const primaryProvider = { + id: ANN_PROVIDER_IDS.LANCEDB, + isAvailable: () => true, + preflight: async () => true, + query: async () => { + primaryCalls += 1; + if (primaryCalls === 1) throw new Error('transient provider failure'); + return [{ idx: 0, sim: 0.95 }]; + } + }; + const fallbackProvider = { + id: ANN_PROVIDER_IDS.SQLITE_VECTOR, + isAvailable: () => true, + preflight: async () => true, + query: async () => { + fallbackCalls += 1; + return [{ idx: 1, sim: 0.94 }]; + } + }; + const { context, idx } = buildAnnPipelineFixture({ + createAnnProviders: () => new Map([ + [ANN_PROVIDER_IDS.LANCEDB, primaryProvider], + [ANN_PROVIDER_IDS.SQLITE_VECTOR, fallbackProvider] + ]) + }); + idx.state = { profile: { id: INDEX_PROFILE_VECTOR_ONLY } }; + context.annBackend = 'auto'; + context.annAdaptiveProviders = true; + const pipeline = createSearchPipeline(context); + await withMockedNow(async (clock) => { + const run1 = await pipeline(idx, 'code', [0.1, 0.2]); + clock.advance(1100); + const run2 = await pipeline(idx, 'code', [0.1, 0.2]); + assert.ok(Array.isArray(run1) && run1.length > 0); + assert.ok(Array.isArray(run2) && run2.length > 0); + assert.equal(primaryCalls, 1); + assert.equal(fallbackCalls, 2); + assert.ok(run2.some((entry) => entry.annSource === ANN_PROVIDER_IDS.SQLITE_VECTOR)); + }); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('provider runtime contract matrix test passed'); diff --git a/tests/retrieval/providers/provider-unnamed-state-isolation-contract.test.js b/tests/retrieval/providers/provider-unnamed-state-isolation-contract.test.js deleted file mode 100644 index 2d86f358d..000000000 --- a/tests/retrieval/providers/provider-unnamed-state-isolation-contract.test.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSearchPipeline } from '../../../src/retrieval/pipeline.js'; -import { ANN_PROVIDER_IDS } from '../../../src/retrieval/ann/types.js'; -import { buildAnnPipelineFixture } from '../pipeline/helpers/ann-scenarios.js'; - -let primaryPreflightCalls = 0; -let fallbackPreflightCalls = 0; -let fallbackQueryCalls = 0; - -const primaryProvider = { - isAvailable: () => true, - preflight: async () => { - primaryPreflightCalls += 1; - return false; - }, - query: async () => [] -}; - -const fallbackProvider = { - isAvailable: () => true, - preflight: async () => { - fallbackPreflightCalls += 1; - return true; - }, - query: async () => { - fallbackQueryCalls += 1; - return [{ idx: 0, sim: 0.97 }]; - } -}; - -const { context, idx } = buildAnnPipelineFixture({ - createAnnProviders: () => new Map([ - [ANN_PROVIDER_IDS.HNSW, primaryProvider], - [ANN_PROVIDER_IDS.DENSE, fallbackProvider] - ]) -}); -context.annBackend = 'auto'; - -const pipeline = createSearchPipeline(context); -const results = await pipeline(idx, 'code', [0.1, 0.2]); - -assert.ok(Array.isArray(results) && results.length > 0, 'expected non-empty results'); -assert.equal(primaryPreflightCalls, 1, 'expected primary unnamed provider preflight to run once'); -assert.equal( - fallbackPreflightCalls, - 1, - 'expected fallback unnamed provider preflight not to inherit failure state' -); -assert.equal(fallbackQueryCalls, 1, 'expected fallback unnamed provider query to run'); -assert.ok( - results.some((entry) => entry.annSource === ANN_PROVIDER_IDS.DENSE), - 'expected dense provider results after primary preflight failure' -); - -console.log('provider unnamed state isolation contract test passed'); diff --git a/tests/retrieval/query/golden-query-corpus.test.js b/tests/retrieval/query/golden-corpus.test.js similarity index 100% rename from tests/retrieval/query/golden-query-corpus.test.js rename to tests/retrieval/query/golden-corpus.test.js diff --git a/tests/retrieval/query/query-intent-path-heuristics.test.js b/tests/retrieval/query/intent-path-heuristics.test.js similarity index 100% rename from tests/retrieval/query/query-intent-path-heuristics.test.js rename to tests/retrieval/query/intent-path-heuristics.test.js diff --git a/tests/retrieval/query/query-parse-language-profile.test.js b/tests/retrieval/query/parse-language-profile.test.js similarity index 100% rename from tests/retrieval/query/query-parse-language-profile.test.js rename to tests/retrieval/query/parse-language-profile.test.js diff --git a/tests/retrieval/query/query-contract-matrix.test.js b/tests/retrieval/query/query-contract-matrix.test.js new file mode 100644 index 000000000..8e66fa4e1 --- /dev/null +++ b/tests/retrieval/query/query-contract-matrix.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { classifyQuery, resolveIntentFieldWeights, resolveIntentVectorMode } from '../../../src/retrieval/query-intent.js'; +import { parseQueryInput, parseQueryWithFallback } from '../../../src/retrieval/query.js'; + +const cases = [ + { + name: 'query parsing covers phrases, negation, nesting, and syntax errors', + run() { + const basic = parseQueryInput('alpha "beta gamma"'); + assert.deepEqual(basic.includeTerms, ['alpha']); + assert.deepEqual(basic.phrases, ['beta gamma']); + + const implicit = parseQueryInput('alpha beta'); + assert.deepEqual(implicit.includeTerms, ['alpha', 'beta']); + + const negated = parseQueryInput('alpha NOT "beta"'); + assert.deepEqual(negated.excludePhrases, ['beta']); + + const unary = parseQueryInput('-alpha'); + assert.deepEqual(unary.excludeTerms, ['alpha']); + + const nestedQuote = parseQueryInput('"alpha \'beta\'"'); + assert.deepEqual(nestedQuote.phrases, ["alpha 'beta'"]); + + const nested = parseQueryInput('alpha OR (beta AND gamma)'); + assert.equal(nested.ast.type, 'or'); + assert.equal(nested.ast.right.type, 'and'); + + assert.throws(() => parseQueryInput('alpha "beta'), /Unbalanced quote/i); + assert.throws(() => parseQueryInput('(alpha'), /Missing closing/i); + assert.throws(() => parseQueryInput('AND alpha'), /Unexpected token/i); + } + }, + { + name: 'intent classification drives field weights, vector mode, and miss taxonomy', + run() { + const intentCases = [ + { query: 'src/utils/file.ts', tokens: ['src/utils/file.ts'], phrases: [], expect: 'path' }, + { query: 'renderToString', tokens: ['renderToString'], phrases: [], expect: 'code' }, + { query: 'how to configure proxy headers', tokens: ['how', 'to', 'configure', 'proxy', 'headers'], phrases: [], expect: 'prose' }, + { query: 'parse json', tokens: ['parse', 'json'], phrases: ['parse json'], expect: 'mixed' } + ]; + for (const sample of intentCases) { + const info = classifyQuery({ + query: sample.query, + tokens: sample.tokens, + phrases: sample.phrases + }); + assert.equal(info.type, sample.expect, `intent mismatch for ${sample.query}`); + } + + const proseIntent = classifyQuery({ + query: 'how to configure proxy headers', + tokens: ['how', 'to', 'configure', 'proxy', 'headers'], + phrases: [] + }); + const weights = resolveIntentFieldWeights(null, proseIntent); + assert.ok(weights && weights.doc > weights.name); + assert.equal(resolveIntentVectorMode('auto', proseIntent), 'doc'); + + const cjkIntent = classifyQuery({ + query: '検索 機能', + tokens: ['検索', '機能'], + phrases: [] + }); + assert.equal(cjkIntent?.missTaxonomy?.labels?.includes('lexical_language_segmentation'), true); + + const symbolHeavyIntent = classifyQuery({ + query: 'foo::bar && baz', + tokens: ['foo', '::', 'bar', '&&', 'baz'], + phrases: [] + }); + assert.equal(symbolHeavyIntent?.missTaxonomy?.labels?.includes('rank_symbol_heavy_query'), true); + } + }, + { + name: 'compound negation stays a hard error across parser paths', + run() { + const simple = parseQueryInput('NOT alpha'); + assert.deepEqual(simple.excludeTerms, ['alpha']); + assert.throws( + () => parseQueryInput('NOT (alpha AND beta)'), + /Compound negation is not supported/i + ); + assert.throws( + () => parseQueryInput('NOT (alpha OR "beta gamma")'), + /Compound negation is not supported/i + ); + assert.throws( + () => parseQueryWithFallback('NOT (alpha AND beta)'), + /Compound negation is not supported/i + ); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('query contract matrix test passed'); diff --git a/tests/retrieval/query/query-intent.test.js b/tests/retrieval/query/query-intent.test.js deleted file mode 100644 index 6173e4292..000000000 --- a/tests/retrieval/query/query-intent.test.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -import { classifyQuery, resolveIntentFieldWeights, resolveIntentVectorMode } from '../../../src/retrieval/query-intent.js'; - -const cases = [ - { query: 'src/utils/file.ts', tokens: ['src/utils/file.ts'], phrases: [], expect: 'path' }, - { query: 'renderToString', tokens: ['renderToString'], phrases: [], expect: 'code' }, - { query: 'how to configure proxy headers', tokens: ['how', 'to', 'configure', 'proxy', 'headers'], phrases: [], expect: 'prose' }, - { query: 'parse json', tokens: ['parse', 'json'], phrases: ['parse json'], expect: 'mixed' } -]; - -for (const sample of cases) { - const info = classifyQuery({ - query: sample.query, - tokens: sample.tokens, - phrases: sample.phrases - }); - if (info.type !== sample.expect) { - console.error(`Expected intent ${sample.expect} for "${sample.query}", got ${info.type}`); - process.exit(1); - } -} - -const proseIntent = classifyQuery({ - query: 'how to configure proxy headers', - tokens: ['how', 'to', 'configure', 'proxy', 'headers'], - phrases: [] -}); -const weights = resolveIntentFieldWeights(null, proseIntent); -if (!weights || !(weights.doc > weights.name)) { - console.error('Expected prose intent to emphasize doc weights.'); - process.exit(1); -} - -const vectorMode = resolveIntentVectorMode('auto', proseIntent); -if (vectorMode !== 'doc') { - console.error(`Expected auto vector mode to resolve to doc for prose, got ${vectorMode}`); - process.exit(1); -} - -const cjkIntent = classifyQuery({ - query: '検索 機能', - tokens: ['検索', '機能'], - phrases: [] -}); -if (!cjkIntent?.missTaxonomy?.labels?.includes('lexical_language_segmentation')) { - console.error('Expected CJK intent to include lexical_language_segmentation miss taxonomy label.'); - process.exit(1); -} - -const symbolHeavyIntent = classifyQuery({ - query: 'foo::bar && baz', - tokens: ['foo', '::', 'bar', '&&', 'baz'], - phrases: [] -}); -if (!symbolHeavyIntent?.missTaxonomy?.labels?.includes('rank_symbol_heavy_query')) { - console.error('Expected symbol-heavy intent to include rank_symbol_heavy_query miss taxonomy label.'); - process.exit(1); -} - -console.log('query intent test passed'); diff --git a/tests/retrieval/query/query-negation-compound-contract.test.js b/tests/retrieval/query/query-negation-compound-contract.test.js deleted file mode 100644 index 820531c6a..000000000 --- a/tests/retrieval/query/query-negation-compound-contract.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { parseQueryInput, parseQueryWithFallback } from '../../../src/retrieval/query.js'; - -const simple = parseQueryInput('NOT alpha'); -assert.deepEqual(simple.excludeTerms, ['alpha'], 'expected direct NOT term to populate excludes'); - -assert.throws( - () => parseQueryInput('NOT (alpha AND beta)'), - /Compound negation is not supported/i, - 'expected compound AND negation to be rejected explicitly' -); - -assert.throws( - () => parseQueryInput('NOT (alpha OR "beta gamma")'), - /Compound negation is not supported/i, - 'expected compound OR negation to be rejected explicitly' -); - -assert.throws( - () => parseQueryWithFallback('NOT (alpha AND beta)'), - /Compound negation is not supported/i, - 'expected fallback parser path to treat compound negation as hard error' -); - -console.log('query negation compound contract test passed'); diff --git a/tests/retrieval/query/query-parse.test.js b/tests/retrieval/query/query-parse.test.js deleted file mode 100644 index 9ea1e13cd..000000000 --- a/tests/retrieval/query/query-parse.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { parseQueryInput } from '../../../src/retrieval/query.js'; - -const basic = parseQueryInput('alpha "beta gamma"'); -assert.deepEqual(basic.includeTerms, ['alpha']); -assert.deepEqual(basic.phrases, ['beta gamma']); - -const implicit = parseQueryInput('alpha beta'); -assert.deepEqual(implicit.includeTerms, ['alpha', 'beta']); - -const negated = parseQueryInput('alpha NOT "beta"'); -assert.deepEqual(negated.excludePhrases, ['beta']); - -const unary = parseQueryInput('-alpha'); -assert.deepEqual(unary.excludeTerms, ['alpha']); - -const nestedQuote = parseQueryInput('"alpha \'beta\'"'); -assert.deepEqual(nestedQuote.phrases, ["alpha 'beta'"]); - -const nested = parseQueryInput('alpha OR (beta AND gamma)'); -assert.equal(nested.ast.type, 'or'); -assert.equal(nested.ast.right.type, 'and'); - -assert.throws(() => parseQueryInput('alpha "beta'), /Unbalanced quote/i); -assert.throws(() => parseQueryInput('(alpha'), /Missing closing/i); -assert.throws(() => parseQueryInput('AND alpha'), /Unexpected token/i); - -console.log('query parse tests passed'); diff --git a/tests/retrieval/query/sqlite-fts-query-escape.test.js b/tests/retrieval/query/sqlite-fts-escape.test.js similarity index 100% rename from tests/retrieval/query/sqlite-fts-query-escape.test.js rename to tests/retrieval/query/sqlite-fts-escape.test.js diff --git a/tests/retrieval/ranking/dense-ranking-contract-matrix.test.js b/tests/retrieval/ranking/dense-ranking-contract-matrix.test.js new file mode 100644 index 000000000..2f7a7442f --- /dev/null +++ b/tests/retrieval/ranking/dense-ranking-contract-matrix.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { rankDenseVectors } from '../../../src/retrieval/rankers.js'; + +const cases = [ + { + name: 'binary-buffer dense vectors rank expected document first', + run() { + const dims = 2; + const scale = 2 / 255; + const minVal = -1; + const buffer = new Uint8Array([ + 255, 128, + 128, 255 + ]); + const idx = { + denseVec: { + dims, + scale, + minVal, + maxVal: 1, + levels: 256, + buffer + } + }; + const results = rankDenseVectors(idx, [1, 0], 2, null); + assert.equal(results.length, 2); + assert.equal(results[0].idx, 0); + } + }, + { + name: 'dimension mismatch truncates consistently and warns once', + run() { + const idx = { + denseVec: { + dims: 2, + scale: 1, + vectors: [new Uint8Array([2, 2])] + } + }; + const query = [1, 2, 3]; + let warnings = 0; + const originalWarn = console.warn; + console.warn = () => { + warnings += 1; + }; + try { + const hitsA = rankDenseVectors(idx, query, 1, null); + const hitsB = rankDenseVectors(idx, query, 1, null); + assert.equal(hitsA.length, 1); + assert.equal(hitsB.length, 1); + assert.ok(Math.abs(hitsA[0].sim - 3) < 1e-9); + assert.equal(warnings, 1); + } finally { + console.warn = originalWarn; + } + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('dense ranking contract matrix test passed'); diff --git a/tests/retrieval/ranking/fielded-bm25.test.js b/tests/retrieval/ranking/fielded-bm25.test.js index 452ef8315..54047a8f2 100644 --- a/tests/retrieval/ranking/fielded-bm25.test.js +++ b/tests/retrieval/ranking/fielded-bm25.test.js @@ -2,34 +2,70 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getIndexDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'fielded-bm25'); const cacheRoot = path.join(tempRoot, 'cache'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); await fsPromises.rm(tempRoot, { recursive: true, force: true }); +const fixtureRoot = path.join(tempRoot, 'repo'); +await fsPromises.mkdir(path.join(fixtureRoot, 'src'), { recursive: true }); await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.writeFile( + path.join(fixtureRoot, 'src', 'greet.js'), + [ + 'export function greet(name = "world") {', + ' return `greet ${name}`;', + '}', + '' + ].join('\n') +); const env = applyTestEnv({ cacheRoot, - embeddings: 'stub' + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } }); -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', fixtureRoot], - { env, stdio: 'inherit' } +runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--repo', + fixtureRoot, + '--stage', + 'stage1', + '--mode', + 'code', + '--no-sqlite', + '--scm-provider', + 'none' + ], + 'fielded bm25 build index', + root, + env, + { stdio: 'inherit' } ); -if (buildResult.status !== 0) { - console.error('fielded bm25 test failed: build_index failed'); - process.exit(buildResult.status ?? 1); -} const userConfig = loadUserConfig(fixtureRoot); const fieldPostings = path.join( @@ -42,8 +78,7 @@ if (!fs.existsSync(fieldPostings)) { process.exit(1); } -const result = spawnSync( - process.execPath, +const result = runNode( [ path.join(root, 'search.js'), 'greet', @@ -57,15 +92,12 @@ const result = spawnSync( '--repo', fixtureRoot ], - { env, encoding: 'utf8' } + 'fielded bm25 search', + root, + env, + { stdio: 'pipe' } ); -if (result.status !== 0) { - console.error('fielded bm25 test failed: search returned error'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - let payload = null; try { payload = JSON.parse(result.stdout || '{}'); diff --git a/tests/retrieval/ranking/graph-ranking-contract-matrix.test.js b/tests/retrieval/ranking/graph-ranking-contract-matrix.test.js new file mode 100644 index 000000000..40dcc7ec2 --- /dev/null +++ b/tests/retrieval/ranking/graph-ranking-contract-matrix.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { applyGraphRanking } from '../../../src/retrieval/pipeline/graph-ranking.js'; +import { graphFromEdges } from '../../graph/helpers/graph-fixtures.js'; + +const baseConfig = { + enabled: true, + weights: { degree: 0.1, proximity: 0.5 }, + maxGraphWorkUnits: 100, + seedSelection: 'top1' +}; + +const createEntries = (chunkUids) => chunkUids.map((chunkUid, index) => ({ + idx: index, + score: 1 - (index * 0.1), + chunk: { chunkUid }, + scoreBreakdown: {} +})); + +const createCallGraphRelations = (edges = []) => ({ + callGraph: graphFromEdges(edges), + usageGraph: { nodes: [] } +}); + +const cases = [ + { + name: 'explain mode emits graph breakdown', + run() { + const entries = createEntries(['a']); + const graphRelations = createCallGraphRelations([['a', []]]); + const result = applyGraphRanking({ + entries, + graphRelations, + config: baseConfig, + explain: true + }); + assert.ok(result.entries[0].scoreBreakdown.graph); + } + }, + { + name: 'determinism holds across repeated runs', + run() { + const entries = createEntries(['a', 'b']); + const graphRelations = createCallGraphRelations([['a', 'b']]); + const first = JSON.stringify(applyGraphRanking({ entries, graphRelations, config: baseConfig, explain: true })); + const second = JSON.stringify(applyGraphRanking({ entries, graphRelations, config: baseConfig, explain: true })); + assert.equal(first, second); + } + }, + { + name: 'membership remains invariant after graph ranking', + run() { + const entries = createEntries(['a', 'b', 'c']); + const graphRelations = createCallGraphRelations([['a', ['b', 'c']]]); + const result = applyGraphRanking({ entries, graphRelations, config: baseConfig }); + const before = entries.map((entry) => entry.idx).sort().join(','); + const after = result.entries.map((entry) => entry.idx).sort().join(','); + assert.equal(before, after); + } + }, + { + name: 'disabled mode is a no-op while enabled mode preserves entry count', + run() { + const entries = createEntries(['a', 'b']); + const graphRelations = createCallGraphRelations([['a', 'b']]); + const disabled = applyGraphRanking({ + entries, + graphRelations, + config: { enabled: false } + }); + assert.equal(disabled.entries[0].score, 1); + const enabled = applyGraphRanking({ + entries, + graphRelations, + config: baseConfig + }); + assert.equal(enabled.entries.length, entries.length); + } + } +]; + +for (const testCase of cases) { + testCase.run(); +} + +console.log('graph ranking contract matrix test passed'); diff --git a/tests/retrieval/ranking/graph-ranking-determinism.test.js b/tests/retrieval/ranking/graph-ranking-determinism.test.js deleted file mode 100644 index cfb197f79..000000000 --- a/tests/retrieval/ranking/graph-ranking-determinism.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import { applyGraphRanking } from '../../../src/retrieval/pipeline/graph-ranking.js'; - -const entries = [ - { idx: 0, score: 1, chunk: { chunkUid: 'a' }, scoreBreakdown: {} }, - { idx: 1, score: 0.9, chunk: { chunkUid: 'b' }, scoreBreakdown: {} } -]; - -const graphRelations = { - callGraph: { - nodes: [ - { id: 'a', out: ['b'], in: [] }, - { id: 'b', out: [], in: ['a'] } - ] - }, - usageGraph: { nodes: [] } -}; - -const config = { - enabled: true, - weights: { degree: 0.1, proximity: 0.5 }, - maxGraphWorkUnits: 100, - seedSelection: 'top1' -}; - -const first = JSON.stringify(applyGraphRanking({ entries, graphRelations, config, explain: true })); -const second = JSON.stringify(applyGraphRanking({ entries, graphRelations, config, explain: true })); - -if (first !== second) { - console.error('Expected deterministic graph ranking output.'); - process.exit(1); -} - -console.log('graph ranking determinism test passed'); diff --git a/tests/retrieval/ranking/graph-ranking-explain.test.js b/tests/retrieval/ranking/graph-ranking-explain.test.js deleted file mode 100644 index a2af75d71..000000000 --- a/tests/retrieval/ranking/graph-ranking-explain.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { applyGraphRanking } from '../../../src/retrieval/pipeline/graph-ranking.js'; - -const entries = [ - { idx: 0, score: 1, chunk: { chunkUid: 'a' }, scoreBreakdown: {} } -]; - -const graphRelations = { - callGraph: { nodes: [{ id: 'a', out: [], in: [] }] }, - usageGraph: { nodes: [] } -}; - -const result = applyGraphRanking({ - entries, - graphRelations, - config: { - enabled: true, - weights: { degree: 0.1, proximity: 0.5 }, - maxGraphWorkUnits: 100, - seedSelection: 'top1' - }, - explain: true -}); - -assert(result.entries[0].scoreBreakdown.graph, 'expected graph score breakdown'); -console.log('graph ranking explain test passed'); diff --git a/tests/retrieval/ranking/graph-ranking-membership-invariant.test.js b/tests/retrieval/ranking/graph-ranking-membership-invariant.test.js deleted file mode 100644 index 7bd4d8837..000000000 --- a/tests/retrieval/ranking/graph-ranking-membership-invariant.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { applyGraphRanking } from '../../../src/retrieval/pipeline/graph-ranking.js'; - -const entries = [ - { idx: 0, score: 1, chunk: { chunkUid: 'a' }, scoreBreakdown: {} }, - { idx: 1, score: 0.9, chunk: { chunkUid: 'b' }, scoreBreakdown: {} }, - { idx: 2, score: 0.8, chunk: { chunkUid: 'c' }, scoreBreakdown: {} } -]; - -const graphRelations = { - callGraph: { - nodes: [ - { id: 'a', out: ['b', 'c'], in: [] }, - { id: 'b', out: [], in: ['a'] }, - { id: 'c', out: [], in: ['a'] } - ] - }, - usageGraph: { nodes: [] } -}; - -const result = applyGraphRanking({ - entries, - graphRelations, - config: { - enabled: true, - weights: { degree: 0.1, proximity: 0.5 }, - maxGraphWorkUnits: 100, - seedSelection: 'top1' - } -}); - -const before = entries.map((entry) => entry.idx).sort().join(','); -const after = result.entries.map((entry) => entry.idx).sort().join(','); -assert.strictEqual(before, after, 'expected membership invariant under graph ranking'); - -console.log('graph ranking membership invariant test passed'); diff --git a/tests/retrieval/ranking/graph-ranking-toggle.test.js b/tests/retrieval/ranking/graph-ranking-toggle.test.js deleted file mode 100644 index 6030f368e..000000000 --- a/tests/retrieval/ranking/graph-ranking-toggle.test.js +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { applyGraphRanking } from '../../../src/retrieval/pipeline/graph-ranking.js'; - -const entries = [ - { idx: 0, score: 1, chunk: { chunkUid: 'a' }, scoreBreakdown: {} }, - { idx: 1, score: 0.9, chunk: { chunkUid: 'b' }, scoreBreakdown: {} } -]; - -const graphRelations = { - callGraph: { - nodes: [ - { id: 'a', out: ['b'], in: [] }, - { id: 'b', out: [], in: ['a'] } - ] - }, - usageGraph: { nodes: [] } -}; - -const disabled = applyGraphRanking({ - entries, - graphRelations, - config: { enabled: false } -}); -assert.strictEqual(disabled.entries[0].score, 1, 'disabled graph ranking should not change scores'); - -const enabled = applyGraphRanking({ - entries, - graphRelations, - config: { - enabled: true, - weights: { degree: 0.1, proximity: 0.5 }, - maxGraphWorkUnits: 100, - seedSelection: 'top1' - } -}); -assert.strictEqual(enabled.entries.length, entries.length, 'membership should remain the same'); - -console.log('graph ranking toggle test passed'); diff --git a/tests/retrieval/ranking/rank-dense-binary-buffer.test.js b/tests/retrieval/ranking/rank-dense-binary-buffer.test.js deleted file mode 100644 index 3ccf6fa2a..000000000 --- a/tests/retrieval/ranking/rank-dense-binary-buffer.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { rankDenseVectors } from '../../../src/retrieval/rankers.js'; - -const dims = 2; -const scale = 2 / 255; -const minVal = -1; -const buffer = new Uint8Array([ - 255, 128, // doc 0 -> approx [1, 0] - 128, 255 // doc 1 -> approx [0, 1] -]); - -const idx = { - denseVec: { - dims, - scale, - minVal, - maxVal: 1, - levels: 256, - buffer - } -}; - -const results = rankDenseVectors(idx, [1, 0], 2, null); -assert.equal(results.length, 2, 'expected dense ranking results'); -assert.equal(results[0].idx, 0, 'expected first vector to rank highest'); - -console.log('rank dense binary buffer test passed'); diff --git a/tests/retrieval/ranking/rank-dense-dims.test.js b/tests/retrieval/ranking/rank-dense-dims.test.js deleted file mode 100644 index b356a2262..000000000 --- a/tests/retrieval/ranking/rank-dense-dims.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { rankDenseVectors } from '../../../src/retrieval/rankers.js'; - -const idx = { - denseVec: { - dims: 2, - scale: 1, - vectors: [new Uint8Array([2, 2])] - } -}; - -const query = [1, 2, 3]; -let warnings = 0; -const originalWarn = console.warn; -console.warn = () => { - warnings += 1; -}; - -try { - const hitsA = rankDenseVectors(idx, query, 1, null); - const hitsB = rankDenseVectors(idx, query, 1, null); - assert.equal(hitsA.length, 1); - assert.equal(hitsB.length, 1); - assert.ok(Math.abs(hitsA[0].sim - 3) < 1e-9, 'expected dot product using truncated dims'); - assert.equal(warnings, 1, 'expected mismatch warning to log once'); -} finally { - console.warn = originalWarn; -} - -console.log('dense dims mismatch test passed'); diff --git a/tests/retrieval/request-path/no-sync-fs-contract.test.js b/tests/retrieval/request-path/no-sync-fs-contract.test.js index a978be74a..b6281c73d 100644 --- a/tests/retrieval/request-path/no-sync-fs-contract.test.js +++ b/tests/retrieval/request-path/no-sync-fs-contract.test.js @@ -9,7 +9,7 @@ ensureTestingEnv(process.env); const root = process.cwd(); const requestPathFiles = [ 'src/retrieval/cli/load-indexes.js', - 'src/retrieval/cli/run-search.js', + 'src/retrieval/cli/run-search/plan-runner.js', 'src/retrieval/cli/index-loader.js', 'src/retrieval/cli/run-search-session.js' ]; diff --git a/tests/run.js b/tests/run.js index 71386a4fd..99a0ad053 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,19 +1,20 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { isAbsolutePathNative } from '../src/shared/files.js'; +import { isAbsolutePathNative } from '../src/shared/file-paths.js'; import { stableStringify } from '../src/shared/stable-json.js'; import { normalizePathForRepo } from '../src/shared/path-normalize.js'; import { validateTestCoverageArtifact, validateTestTimingsArtifact, - validateTestProfileArtifact + validateTestProfileArtifact, + validateTestStabilityArtifact } from '../src/contracts/validators/test-artifacts.js'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadRunConfig, loadRunRules } from './runner/run-config.js'; import { applyFilters, - assignLane, + assignLaneWithReason, buildTags, compileMatchers, discoverTests, @@ -22,6 +23,14 @@ import { resolveLanes, splitCsv } from './runner/run-discovery.js'; +import { + buildSuiteCategorySummary, + inferSuiteCategory +} from './runner/suite-taxonomy.js'; +import { + loadLaneManifestConfig, + loadOrderedLaneManifest +} from './runner/lane-manifests.js'; import { parseArgs } from './runner/run-args.js'; import { mergeNodeOptions, @@ -32,6 +41,7 @@ import { resolveTimeout } from './runner/run-helpers.js'; import { ensureTestingEnv } from './helpers/test-env.js'; +import { applyToolchainDaemonPolicyEnv } from '../src/shared/toolchain-env.js'; import { runTests } from './runner/run-execution.js'; import { summarizeResults } from './runner/run-results.js'; import { @@ -47,6 +57,12 @@ import { writeTestRunTimes, writeTimings } from './runner/run-reporting.js'; +import { + buildStabilityArtifact, + loadStabilityHistory, + writeStabilityArtifact +} from './runner/run-stability.js'; +import { loadDiagnosticsGovernance } from './runner/diagnostics-governance.js'; import { buildCoverageArtifact, collectV8CoverageEntries, @@ -66,17 +82,13 @@ const SKIP_EXIT_CODE = 77; const REDO_EXIT_CODES = [3221226356, 3221225477]; const DEFAULT_TIMEOUT_GRACE_MS = 2000; const DEFAULT_LOG_DIR = path.join(ROOT, '.testLogs'); -const ORDERED_LANES = new Set([ - 'ci-lite', - 'ci', - 'ci-long' -]); const INHERITED_PAIROFCLEATS_ENV_ALLOWLIST = new Set([ 'PAIROFCLEATS_TEST_API_STARTUP_TIMEOUT_MS', 'PAIROFCLEATS_TEST_CACHE_SUFFIX', 'PAIROFCLEATS_TEST_ALLOW_MISSING_COMPAT_KEY', 'PAIROFCLEATS_TEST_LOG_SILENT', 'PAIROFCLEATS_TEST_ALLOW_TIMEOUT_TARGET', + 'PAIROFCLEATS_TEST_ALLOW_TIMEOUT_PASS_SIGNAL_TARGET', 'PAIROFCLEATS_TEST_PID_FILE', 'NODE_V8_COVERAGE' ]); @@ -180,17 +192,8 @@ const main = async () => { const laneInfo = normalizeLaneArgs(argv.lane); const requestedLanes = laneInfo.requested; const runRules = loadRunRules({ root: ROOT }); - const ciLiteOrderPath = path.join(TESTS_DIR, 'ci-lite', 'ci-lite.order.txt'); - let ciLiteOrderSet = new Set(); - try { - const ciLiteRaw = await fsPromises.readFile(ciLiteOrderPath, 'utf8'); - ciLiteOrderSet = new Set( - ciLiteRaw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - ); - } catch {} + const laneManifestConfig = await loadLaneManifestConfig({ root: ROOT }); + const orderedLaneNames = new Set(laneManifestConfig.orderedLanes.keys()); /** * Resolve the default timeout from requested lanes. @@ -222,18 +225,14 @@ const main = async () => { const isCiLiteOnly = requestedLanes.length === 1 && requestedLanes[0] === 'ci-lite'; const isCiOnly = requestedLanes.length === 1 && requestedLanes[0] === 'ci'; - const isCiLongOnly = requestedLanes.length === 1 && requestedLanes[0] === 'ci-long'; const orderedLane = (() => { const normalized = requestedLanes.filter((lane) => lane && lane !== 'all'); if (normalized.length !== 1) { return ''; } const lane = normalized[0]; - return ORDERED_LANES.has(lane) ? lane : ''; + return orderedLaneNames.has(lane) ? lane : ''; })(); - if (requestedLanes.includes('ci-long') && !tagInclude.includes('long')) { - tagInclude.push('long'); - } if (argv['list-lanes'] || argv['list-tags']) { const payload = {}; @@ -272,6 +271,7 @@ const main = async () => { : {}; const tagExclude = splitCsv(argv['exclude-tag']); const ignoreConfigExcludes = laneInfo.includeAll || isCiLiteOnly; + const isCiLongSelected = requestedLanes.includes('ci-long'); const configExclude = new Set(); if (!ignoreConfigExcludes) { const baseExcludes = Array.isArray(runConfig.excludeTags) @@ -294,10 +294,20 @@ const main = async () => { if (laneInfo.includeDestructive) { configExclude.delete('destructive'); } + if (isCiLongSelected) { + configExclude.delete('long'); + } for (const tag of configExclude) { if (!tag || tagInclude.includes(tag) || tagExclude.includes(tag)) continue; tagExclude.push(tag); } + if (isCiLongSelected) { + for (let index = tagExclude.length - 1; index >= 0; index -= 1) { + if (tagExclude[index] === 'long') { + tagExclude.splice(index, 1); + } + } + } const dropTags = []; const dropLongFromCi = requestedLanes.includes('ci') && !requestedLanes.includes('ci-long') @@ -310,9 +320,18 @@ const main = async () => { excludedDirs: runRules.excludedDirs, excludedFiles: runRules.excludedFiles })).map((test) => { - const lane = assignLane(test.id, runRules.laneRules); - const adjustedLane = ciLiteOrderSet.has(test.id) ? 'ci-lite' : lane; - return { ...test, lane: adjustedLane, tags: buildTags(test.id, adjustedLane, runRules.tagRules) }; + const laneReason = assignLaneWithReason(test.id, runRules.laneRules); + const lane = laneReason.lane; + const tags = buildTags(test.id, lane, runRules.tagRules); + const suiteCategory = inferSuiteCategory({ id: test.id, lane, tags }); + return { + ...test, + lane, + laneReason, + tags, + suiteCategory: suiteCategory.category, + suiteCategoryReason: suiteCategory.reason + }; }); const includeMatchers = compileMatchers(includePatterns, 'match'); @@ -321,24 +340,32 @@ const main = async () => { let selection = null; if (orderedLane) { - const orderPath = path.join(TESTS_DIR, orderedLane, `${orderedLane}.order.txt`); const orderLane = orderedLane; - let orderRaw = ''; + let manifest = null; try { - orderRaw = await fsPromises.readFile(orderPath, 'utf8'); - } catch (error) { - console.error(`${orderLane} lane requires an order file at ${path.relative(ROOT, orderPath)}.`); - console.error('Create the file with one test id per line (e.g., "run-results").'); + manifest = await loadOrderedLaneManifest({ + root: ROOT, + lane: orderLane, + config: laneManifestConfig + }); + } catch { + manifest = null; + } + + if (!manifest) { + const laneConfig = laneManifestConfig.orderedLanes.get(orderLane); + const manifestPath = laneConfig?.manifestPath || path.join(TESTS_DIR, orderLane, `${orderLane}.manifest.json`); + console.error(`${orderLane} lane requires a manifest file at ${path.relative(ROOT, manifestPath)}.`); + console.error('Generate manifests with: node tools/testing/generate-lane-manifests.js'); process.exit(2); } - const orderIds = orderRaw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')); + const orderIds = Array.isArray(manifest.tests) + ? manifest.tests.map((entry) => String(entry?.id || '').trim()).filter(Boolean) + : []; if (!orderIds.length) { - console.error(`${orderLane} order file is empty: ${path.relative(ROOT, orderPath)}`); + console.error(`${orderLane} manifest is empty: ${path.relative(ROOT, manifest.manifestPath)}`); process.exit(2); } @@ -354,7 +381,15 @@ const main = async () => { } const count = (seen.get(id) || 0) + 1; seen.set(id, count); - ordered.push(count === 1 ? test : { ...test, id: `${id}#${count}` }); + const decorated = count === 1 ? test : { ...test, id: `${id}#${count}` }; + ordered.push({ + ...decorated, + selectionReason: { + source: 'ordered-manifest', + lane: orderLane, + detail: path.relative(ROOT, manifest.manifestPath).replace(/\\/g, '/') + } + }); } if (missing.length) { @@ -379,7 +414,12 @@ const main = async () => { return { ...test, presetStatus: 'skipped', - skipReason: `excluded tag: ${excluded.join(', ')}` + skipReason: `excluded tag: ${excluded.join(', ')}`, + selectionReason: test.selectionReason || { + source: 'ordered-manifest', + lane: orderLane, + detail: path.relative(ROOT, manifest.manifestPath).replace(/\\/g, '/') + } }; }); } else { @@ -392,7 +432,14 @@ const main = async () => { tagExclude, dropTags }); - selection = [...selected, ...skipped]; + selection = [...selected, ...skipped].map((test) => ({ + ...test, + selectionReason: { + source: 'lane-filter', + lane: test.lane, + detail: test.laneReason?.detail || '' + } + })); } if (!selection.length) { @@ -406,12 +453,26 @@ const main = async () => { if (argv.list) { if (argv.json) { - const payload = { total: selection.length, tests: selection.map((test) => ({ + const testsPayload = selection.map((test) => ({ id: test.id, path: test.relPath, lane: test.lane, - tags: test.tags - })) }; + tags: test.tags, + suiteCategory: test.suiteCategory || '', + suiteCategoryReason: test.suiteCategoryReason || '', + laneSource: test.laneReason?.source || '', + laneDetail: test.laneReason?.detail || '', + selectionSource: test.selectionReason?.source || '', + selectionLane: test.selectionReason?.lane || '', + selectionDetail: test.selectionReason?.detail || '', + presetStatus: test.presetStatus || '', + skipReason: test.skipReason || '' + })); + const payload = { + total: selection.length, + suiteCategorySummary: buildSuiteCategorySummary(testsPayload), + tests: testsPayload + }; process.stdout.write(`${JSON.stringify(payload)}\n`); return; } @@ -472,6 +533,14 @@ const main = async () => { const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const runLogDir = logDir ? path.join(logDir, `run-${runId}`) : ''; const timingsPath = argv['timings-file'] ? path.resolve(ROOT, argv['timings-file']) : ''; + const stabilityPath = argv['stability-file'] ? path.resolve(ROOT, argv['stability-file']) : ''; + const stabilityHistoryDir = argv['stability-history-dir'] + ? path.resolve(ROOT, argv['stability-history-dir']) + : ''; + const stabilityHistoryLimit = Number.isFinite(Number(argv['stability-history-limit'])) + ? Math.max(1, Math.floor(Number(argv['stability-history-limit']))) + : 12; + const reportFilePath = argv['report-file'] ? path.resolve(ROOT, argv['report-file']) : ''; const hasCoverageFlag = process.argv.some((arg) => arg === '--coverage' || arg.startsWith('--coverage=')); const coveragePathProvided = typeof argv.coverage === 'string' && argv.coverage.trim().length > 0; const coverageRequested = ( @@ -510,7 +579,7 @@ const main = async () => { await writeLatestLogPointer({ root: ROOT, runLogDir }); } - const baseEnv = { ...process.env }; + const baseEnv = applyToolchainDaemonPolicyEnv(process.env); scrubInheritedPairOfCleatsEnv(baseEnv); ensureTestingEnv(baseEnv); if (!baseEnv.PAIROFCLEATS_CACHE_ROOT) { @@ -663,17 +732,24 @@ const main = async () => { }); } + const reportPayload = buildJsonReport({ + summary, + results: finalResults, + root: ROOT, + runLogDir, + junitPath: argv.junit || '' + }); + if (argv.json) { - const payload = buildJsonReport({ - summary, - results: finalResults, - root: ROOT, - runLogDir, - junitPath: argv.junit || '' - }); + const payload = reportPayload; process.stdout.write(`${JSON.stringify(payload)}\n`); } + if (reportFilePath) { + await fsPromises.mkdir(path.dirname(reportFilePath), { recursive: true }); + await fsPromises.writeFile(reportFilePath, `${stableStringify(reportPayload)}\n`, 'utf8'); + } + if (argv.junit) { const junitPath = path.resolve(ROOT, argv.junit); await writeJUnit({ junitPath, results: finalResults, totalMs }); @@ -696,6 +772,53 @@ const main = async () => { payload: timingsArtifact }); } + if (stabilityPath || stabilityHistoryDir) { + const diagnosticsGovernance = await loadDiagnosticsGovernance({ root: ROOT }); + const stabilityHistory = await loadStabilityHistory({ + historyDir: stabilityHistoryDir, + historyLimit: stabilityHistoryLimit + }); + const resolveStabilityTimeoutBudget = (result) => { + const selectionLane = String(result?.selectionReason?.lane || '').trim(); + const selectionLaneTargetMs = selectionLane && laneManifestConfig.orderedLanes.has(selectionLane) + ? Number(laneManifestConfig.orderedLanes.get(selectionLane)?.targetMaxDurationSeconds || 0) * 1000 + : 0; + if (Number.isFinite(selectionLaneTargetMs) && selectionLaneTargetMs > 0) { + return selectionLaneTargetMs; + } + const intrinsicLaneTargetMs = result?.lane && laneManifestConfig.orderedLanes.has(result.lane) + ? Number(laneManifestConfig.orderedLanes.get(result.lane)?.targetMaxDurationSeconds || 0) * 1000 + : 0; + if (Number.isFinite(intrinsicLaneTargetMs) && intrinsicLaneTargetMs > 0) { + return intrinsicLaneTargetMs; + } + return context.timeoutMs; + }; + const stabilityArtifact = buildStabilityArtifact({ + results: finalResults, + runId, + root: ROOT, + laneLabel, + retries, + history: stabilityHistory, + timeoutResolver: resolveStabilityTimeoutBudget, + baseEnv, + diagnosticsGovernance: diagnosticsGovernance.payload + }); + const stabilityValidation = validateTestStabilityArtifact(stabilityArtifact); + if (!stabilityValidation.ok) { + console.error(`stability artifact validation failed: ${stabilityValidation.errors.join('; ')}`); + process.exit(2); + } + await writeStabilityArtifact({ + artifactPath: stabilityPath, + archiveDir: stabilityHistoryDir, + artifact: stabilityArtifact + }); + if (!argv.quiet && stabilityPath) { + consoleStream.write(`stability artifact: ${stabilityPath}\n`); + } + } if (logTimesPath) { await writeTestRunTimes({ logTimesPath, results: finalResults }); } @@ -768,15 +891,21 @@ const main = async () => { } } - const timeoutCount = finalResults.filter((result) => result.timedOut).length; + const timeoutResults = finalResults.filter((result) => result.timedOut); + const timeoutCount = timeoutResults.length; + const nonBlockingTimeouts = timeoutResults.filter((result) => String(result.timeoutClass || '') === 'timed_out_after_pass'); + const blockingTimeouts = timeoutCount - nonBlockingTimeouts.length; const failCount = finalResults.filter((result) => result.status === 'failed' && !result.timedOut).length; const baseExitCode = argv['allow-timeouts'] ? (failCount > 0 ? 1 : 0) - : (summary.failed > 0 ? 1 : 0); + : ((failCount > 0 || blockingTimeouts > 0) ? 1 : 0); const exitCode = perfBudgetViolations.length > 0 ? 1 : baseExitCode; process.exit(exitCode); }; -main(); +main().catch((error) => { + console.error(error?.stack || error?.message || String(error)); + process.exit(1); +}); diff --git a/tests/run.rules.jsonc b/tests/run.rules.jsonc index 47914178a..30a95becf 100644 --- a/tests/run.rules.jsonc +++ b/tests/run.rules.jsonc @@ -11,13 +11,14 @@ "suggest-tests" ], "excludedFiles": ["run.js"], - "knownLanes": ["smoke", "unit", "ci-lite", "ci-long", "integration", "services", "api", "storage", "perf", "mcp", "iq", "ci", "gate", "backcompat", "diagnostics-summary", "decomposed-drift"], + "knownLanes": ["smoke", "unit", "ci-lite", "ci-long", "integration", "services", "api", "storage", "perf", "mcp", "iq", "ci", "gate", "backcompat", "diagnostics-summary", "decomposed-drift", "usr-full-conformance"], "laneRules": [ { "lane": "perf", "match": ["^perf/"] }, { "lane": "iq", "match": ["^retrieval/eval/"] }, { "lane": "ci-long", "match": [ + "^services/soak/", "^lang/contracts/(go|javascript|misc-buildfiles|python|sql|typescript)$", "^indexing/contracts/golden-surface-suite$" ] @@ -27,12 +28,12 @@ "match": [ "^contracts/", "^benchmarks/regressions/", - "^backcompat/", "^diagnostics/diagnostics-transition-validation$", "^indexing/policy/", "^indexing/contracts/", "^tooling/api-contracts/", - "^ci/capability-gate\\.smoke$" + "^ci/capability-gate\\.smoke$", + "^ci/tooling-doctor-gate\\.smoke$" ] }, { "lane": "ci-lite", "match": ["^ci-lite/"] }, @@ -62,6 +63,7 @@ { "lane": "unit", "match": [ + "^unit/", "^shared/", "^tooling/", "^lang/", @@ -94,45 +96,19 @@ { "tag": "long", "match": [ + "^services/soak/", "(^|/)parity$", "(^|/)fixture-parity$", "(^|/)fixture-eval$", - "(^|/)incremental-tokenization-cache$", "(^|/)summary-report-parity-sqlite$", "(^|/)summary-report-parity-sqlite-fts$", - "(^|/)code-map-performance$", "(^|/)ann-parity$", - "(^|/)incremental-cache-signature$", - "(^|/)retrieval-strict-manifest-embeddings$", - "(^|/)hnsw-ann$", - "(^|/)code-map-guardrails$", - "(^|/)comment-join$", - "(^|/)chunkuid-determinism$", - "(^|/)eval-quality$", - "(^|/)fielded-bm25$", - "(^|/)artifact-size-guardrails$", - "(^|/)churn-filter$", - "(^|/)manifest-embeddings-pieces$", - "(^|/)hnsw-atomic$", - "(^|/)embeddings-cache-identity$", - "^tooling/triage/records-index-and-search$", - "(^|/)embeddings-dims-mismatch$", - "(^|/)repo-root$", - "(^|/)search-tie-order$", - "(^|/)subprocess-quoting$", - "(^|/)lancedb-ann$", - "(^|/)piece-assembly$", - "(^|/)query-cache$", - "(^|/)search-rrf$", - "(^|/)shard-merge$", - "^tooling/script-coverage/script-coverage-(core|storage|indexing|language|benchmarks|search|embeddings|services|fixtures|tools)$", - "^indexing/runtime/two-stage-state$" + "^tooling/script-coverage/script-coverage-(core|storage|indexing|language|benchmarks|search|embeddings|services|fixtures|tools)$" ] }, { "tag": "long", "match": [ - "^tooling/triage/context-pack$", "^services/mcp/tool-search-defaults-and-filters$" ] }, diff --git a/tests/runner/ci-long-order-selection.test.js b/tests/runner/ci-long-order-selection.test.js new file mode 100644 index 000000000..20dea5d33 --- /dev/null +++ b/tests/runner/ci-long-order-selection.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runNode } from '../helpers/run-node.js'; +import { loadLaneManifestConfig, loadOrderedLaneManifest } from './lane-manifests.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const manifestConfig = await loadLaneManifestConfig({ root: ROOT }); +const manifest = await loadOrderedLaneManifest({ root: ROOT, lane: 'ci-long', config: manifestConfig }); +const expectedIds = Array.isArray(manifest?.tests) + ? manifest.tests.map((entry) => entry.id) + : []; + +const result = runNode([runnerPath, '--lane', 'ci-long', '--list', '--json'], 'ci-long ordered lane list', ROOT, process.env, { + stdio: 'pipe', + allowFailure: true +}); + +assert.equal(result.status, 0, `expected ci-long list to succeed, got ${result.status}`); + +let payload; +try { + payload = JSON.parse(result.stdout || '{}'); +} catch (error) { + assert.fail(`expected ci-long list JSON output, got parse error: ${error?.message || error}`); +} + +const actualIds = Array.isArray(payload?.tests) + ? payload.tests.map((test) => test.id) + : []; +const nonLongSelected = Array.isArray(payload?.tests) + ? payload.tests.filter((test) => !Array.isArray(test?.tags) || !test.tags.includes('long')) + : []; +const longSelected = Array.isArray(payload?.tests) + ? payload.tests.filter((test) => Array.isArray(test?.tags) && test.tags.includes('long')) + : []; + +assert.deepEqual( + actualIds, + expectedIds, + 'ci-long ordered lane should match ci-long.manifest.json exactly' +); +assert.equal( + payload.tests[0]?.selectionSource, + 'ordered-manifest', + 'ci-long ordered selection should explain manifest-based selection' +); +assert.equal( + payload.tests[0]?.selectionLane, + 'ci-long', + 'ci-long ordered selection should report the selected ordered lane' +); +assert( + actualIds.length > 0, + 'ci-long selection should include ordered manifest entries' +); +assert( + nonLongSelected.length > 0, + 'ci-long selection should include current non-long ordered entries' +); +for (const entry of longSelected) { + assert.equal( + entry?.presetStatus || '', + '', + 'ci-long selection should not preset-skip long-tagged ordered entries' + ); + assert.equal( + entry?.skipReason || '', + '', + 'ci-long selection should not carry an excluded-tag skip reason for ordered long entries' + ); +} + +console.log('ci-long ordered selection test passed'); diff --git a/tests/runner/consolidation-ownership.js b/tests/runner/consolidation-ownership.js new file mode 100644 index 000000000..1657e9c95 --- /dev/null +++ b/tests/runner/consolidation-ownership.js @@ -0,0 +1,88 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_OWNERSHIP_PATH = path.join('docs', 'testing', 'consolidation-ownership.json'); + +const normalizeId = (value) => String(value || '').trim().replace(/\\/g, '/'); + +export const normalizeOwnershipPayload = (payload) => { + const suites = Array.isArray(payload?.suites) ? payload.suites : []; + return { + schemaVersion: Number(payload?.schemaVersion) || 0, + suites: suites.map((entry) => ({ + id: normalizeId(entry?.id), + suiteCategory: String(entry?.suiteCategory || '').trim(), + coverageOwner: String(entry?.coverageOwner || '').trim(), + replacementIds: Array.from(new Set( + (Array.isArray(entry?.replacementIds) ? entry.replacementIds : []) + .map((item) => normalizeId(item)) + .filter(Boolean) + )).sort(), + overlapPolicy: String(entry?.overlapPolicy || '').trim(), + matrixStrategy: String(entry?.matrixStrategy || '').trim(), + processIsolationRequired: Boolean(entry?.processIsolationRequired), + notes: String(entry?.notes || '').trim() + })).filter((entry) => entry.id) + }; +}; + +export const validateOwnershipPayload = (payload) => { + const errors = []; + if (payload.schemaVersion !== 1) { + errors.push('expected schemaVersion=1'); + } + + const suiteIds = new Set(); + const replacementOwners = new Map(); + for (const entry of payload.suites) { + if (!entry.id) { + errors.push('ownership entry missing id'); + continue; + } + if (suiteIds.has(entry.id)) { + errors.push(`duplicate owner suite id: ${entry.id}`); + } + suiteIds.add(entry.id); + if (!entry.suiteCategory) { + errors.push(`ownership entry ${entry.id} missing suiteCategory`); + } + if (!entry.coverageOwner) { + errors.push(`ownership entry ${entry.id} missing coverageOwner`); + } + if (!entry.overlapPolicy) { + errors.push(`ownership entry ${entry.id} missing overlapPolicy`); + } + for (const replacementId of entry.replacementIds) { + const existingOwner = replacementOwners.get(replacementId); + if (existingOwner && existingOwner !== entry.id) { + errors.push(`replacement id ${replacementId} claimed by multiple owner suites: ${existingOwner}, ${entry.id}`); + continue; + } + replacementOwners.set(replacementId, entry.id); + } + } + + return { + valid: errors.length === 0, + errors + }; +}; + +export const loadConsolidationOwnership = async ({ root = process.cwd(), ownershipPath } = {}) => { + const resolvedPath = ownershipPath + ? path.resolve(root, ownershipPath) + : path.join(root, DEFAULT_OWNERSHIP_PATH); + const raw = await fsPromises.readFile(resolvedPath, 'utf8'); + const parsed = JSON.parse(raw); + const payload = normalizeOwnershipPayload(parsed); + const validation = validateOwnershipPayload(payload); + if (!validation.valid) { + const error = new Error(`Invalid consolidation ownership payload: ${validation.errors.join('; ')}`); + error.code = 'ERR_INVALID_CONSOLIDATION_OWNERSHIP'; + throw error; + } + return { + path: resolvedPath, + payload + }; +}; diff --git a/tests/runner/consolidation-ownership.test.js b/tests/runner/consolidation-ownership.test.js new file mode 100644 index 000000000..e92c9a070 --- /dev/null +++ b/tests/runner/consolidation-ownership.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + normalizeOwnershipPayload, + validateOwnershipPayload +} from './consolidation-ownership.js'; + +const payload = normalizeOwnershipPayload({ + schemaVersion: 1, + suites: [ + { + id: 'lang/contracts/language-fixture-contracts', + suiteCategory: 'matrix', + coverageOwner: 'Language fixture contract matrix.', + replacementIds: ['lang/contracts/go', 'lang/contracts/go', 'lang/contracts/python'], + overlapPolicy: 'legacy-removed-after-direct-parity', + matrixStrategy: 'shared-fixture-index', + processIsolationRequired: false + } + ] +}); + +assert.equal(payload.suites[0].replacementIds.length, 2); +assert.deepEqual(validateOwnershipPayload(payload), { valid: true, errors: [] }); + +const invalid = validateOwnershipPayload(normalizeOwnershipPayload({ + schemaVersion: 1, + suites: [ + { + id: 'a', + suiteCategory: 'matrix', + coverageOwner: 'owner a', + replacementIds: ['legacy/shared'], + overlapPolicy: 'parity', + matrixStrategy: 'shared', + processIsolationRequired: false + }, + { + id: 'b', + suiteCategory: 'matrix', + coverageOwner: 'owner b', + replacementIds: ['legacy/shared'], + overlapPolicy: 'parity', + matrixStrategy: 'shared', + processIsolationRequired: false + } + ] +})); +assert.equal(invalid.valid, false); +assert.ok(invalid.errors.some((entry) => entry.includes('legacy/shared'))); + +console.log('consolidation ownership test passed'); diff --git a/tests/runner/diagnostics-governance.js b/tests/runner/diagnostics-governance.js new file mode 100644 index 000000000..2f12a675d --- /dev/null +++ b/tests/runner/diagnostics-governance.js @@ -0,0 +1,78 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { TEST_SUITE_CATEGORIES } from './suite-taxonomy.js'; + +const DEFAULT_GOVERNANCE_PATH = path.join('docs', 'testing', 'diagnostics-governance.json'); + +const normalizeId = (value) => String(value || '').trim().replace(/\\/g, '/'); + +export const normalizeDiagnosticsGovernance = (payload) => ({ + schemaVersion: Number(payload?.schemaVersion) || 0, + instabilityClasses: Array.from(new Set( + (Array.isArray(payload?.instabilityClasses) ? payload.instabilityClasses : []) + .map((item) => String(item || '').trim()) + .filter(Boolean) + )), + diagnosticsClasses: Array.from(new Set( + (Array.isArray(payload?.diagnosticsClasses) ? payload.diagnosticsClasses : []) + .map((item) => String(item || '').trim()) + .filter(Boolean) + )), + expectedNegativeStderrIds: new Set( + (Array.isArray(payload?.expectedNegativeStderrIds) ? payload.expectedNegativeStderrIds : []) + .map((item) => normalizeId(item)) + .filter(Boolean) + ), + retryPolicyBySuiteCategory: Object.fromEntries( + TEST_SUITE_CATEGORIES.map((category) => { + const policy = payload?.retryPolicyBySuiteCategory?.[category]; + return [category, { + maxRetries: Number.isFinite(Number(policy?.maxRetries)) ? Math.max(0, Math.floor(Number(policy.maxRetries))) : 0, + quarantine: String(policy?.quarantine || '').trim(), + note: String(policy?.note || '').trim() + }]; + }) + ) +}); + +export const validateDiagnosticsGovernance = (payload) => { + const errors = []; + if (payload.schemaVersion !== 1) { + errors.push('expected schemaVersion=1'); + } + for (const category of TEST_SUITE_CATEGORIES) { + const policy = payload.retryPolicyBySuiteCategory?.[category]; + if (!policy) { + errors.push(`missing retry policy for suite category ${category}`); + continue; + } + if (!policy.quarantine) { + errors.push(`retry policy for suite category ${category} missing quarantine`); + } + if (!policy.note) { + errors.push(`retry policy for suite category ${category} missing note`); + } + } + return { + valid: errors.length === 0, + errors + }; +}; + +export const loadDiagnosticsGovernance = async ({ root = process.cwd(), governancePath } = {}) => { + const resolvedPath = governancePath + ? path.resolve(root, governancePath) + : path.join(root, DEFAULT_GOVERNANCE_PATH); + const raw = await fsPromises.readFile(resolvedPath, 'utf8'); + const payload = normalizeDiagnosticsGovernance(JSON.parse(raw)); + const validation = validateDiagnosticsGovernance(payload); + if (!validation.valid) { + const error = new Error(`Invalid diagnostics governance payload: ${validation.errors.join('; ')}`); + error.code = 'ERR_INVALID_DIAGNOSTICS_GOVERNANCE'; + throw error; + } + return { + path: resolvedPath, + payload + }; +}; diff --git a/tests/runner/diagnostics-governance.test.js b/tests/runner/diagnostics-governance.test.js new file mode 100644 index 000000000..98bafcc75 --- /dev/null +++ b/tests/runner/diagnostics-governance.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + normalizeDiagnosticsGovernance, + validateDiagnosticsGovernance +} from './diagnostics-governance.js'; + +const payload = normalizeDiagnosticsGovernance({ + schemaVersion: 1, + instabilityClasses: ['stable', 'flaky', 'slow'], + diagnosticsClasses: ['clean', 'expected-negative-stderr', 'unexpected-stderr'], + expectedNegativeStderrIds: ['cli/error-contract', 'cli/error-contract'], + retryPolicyBySuiteCategory: { + hero: { maxRetries: 0, quarantine: 'manual', note: 'hero' }, + matrix: { maxRetries: 1, quarantine: 'owner', note: 'matrix' }, + meta: { maxRetries: 0, quarantine: 'none', note: 'meta' }, + soak: { maxRetries: 0, quarantine: 'manual', note: 'soak' }, + 'heavy-runtime': { maxRetries: 1, quarantine: 'owner', note: 'heavy' } + } +}); + +assert.equal(payload.expectedNegativeStderrIds.size, 1); +assert.deepEqual(validateDiagnosticsGovernance(payload), { valid: true, errors: [] }); + +const invalid = validateDiagnosticsGovernance(normalizeDiagnosticsGovernance({ + schemaVersion: 1, + retryPolicyBySuiteCategory: { + hero: { maxRetries: 0, quarantine: 'manual', note: 'hero' } + } +})); +assert.equal(invalid.valid, false); +assert.ok(invalid.errors.some((entry) => entry.includes('matrix'))); + +console.log('diagnostics governance test passed'); diff --git a/tests/runner/harness/contract-matrix.test.js b/tests/runner/harness/contract-matrix.test.js new file mode 100644 index 000000000..596e50815 --- /dev/null +++ b/tests/runner/harness/contract-matrix.test.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { copyFixtureToTemp } from '../../helpers/fixtures.js'; +import { repoRoot } from '../../helpers/root.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; + +const ROOT = repoRoot(); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); + +const cases = [ + { + name: 'pass target remains executable', + async run() { + const result = spawnSync(process.execPath, [path.join(ROOT, 'tests', 'runner', 'harness', 'pass-target.test.js')], { + encoding: 'utf8' + }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'pass target failed'); + } + if (!(result.stdout || '').includes('pass target ok')) { + throw new Error('missing pass target success output'); + } + } + }, + { + name: 'tests run correctly from the tests directory cwd', + async run() { + const target = path.join(ROOT, 'tests', 'tooling', 'config', 'contract-matrix.test.js'); + const result = spawnSync(process.execPath, [target], { + cwd: path.join(ROOT, 'tests'), + encoding: 'utf8' + }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'cwd independence failed'); + } + } + }, + { + name: 'copyFixtureToTemp does not mutate the source fixture', + async run() { + const fixturePath = path.join(ROOT, 'tests', 'fixtures', 'sample', 'README.md'); + const original = await fsPromises.readFile(fixturePath, 'utf8'); + const tempFixture = await copyFixtureToTemp('sample'); + const tempRoot = path.dirname(tempFixture); + try { + const tempReadme = path.join(tempFixture, 'README.md'); + await fsPromises.writeFile(tempReadme, `${original}\nmutation`); + const updated = await fsPromises.readFile(fixturePath, 'utf8'); + if (updated !== original) { + throw new Error('fixture source was mutated'); + } + } finally { + await rmDirRecursive(tempRoot); + } + } + }, + { + name: 'skip targets are reported as skipped with a reason', + async run() { + const result = spawnSync(process.execPath, [runnerPath, '--lane', 'all', '--match', 'runner/harness/skip-target', '--json'], { + encoding: 'utf8' + }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'skip semantics failed'); + } + const payload = JSON.parse(result.stdout || '{}'); + const test = payload.tests?.[0]; + if (!payload.summary || payload.summary.skipped !== 1 || test?.status !== 'skipped') { + throw new Error('expected one skipped test'); + } + if (!test.skipReason || !test.skipReason.includes('skip target')) { + throw new Error('missing skip reason'); + } + } + }, + { + name: 'unit suffix tests are discoverable by runner selector', + async run() { + const result = spawnSync(process.execPath, [ + runnerPath, + '--lane', 'unit', + '--match', 'unit/retrieval-cache-key-asof', + '--list', + '--json' + ], { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'unit suffix discovery failed'); + } + const payload = JSON.parse(result.stdout || '{}'); + const test = payload.tests?.find((entry) => entry.id === 'unit/retrieval-cache-key-asof'); + if (!test) { + throw new Error('expected .unit.js test to be discoverable as unit/retrieval-cache-key-asof'); + } + if (test.lane !== 'unit' || test.laneSource !== 'rule') { + throw new Error('expected .unit.js test to be assigned to the unit lane by rule'); + } + } + }, + { + name: 'redo targets are retried once and reported cleanly', + async run() { + if (process.platform !== 'win32') { + return; + } + const markerPath = path.join(os.tmpdir(), `poc-redo-semantics-${process.pid}.marker`); + fs.rmSync(markerPath, { force: true }); + try { + const result = spawnSync( + process.execPath, + [runnerPath, '--lane', 'unit', '--match', 'harness/redo-target', '--json', '--retries', '0'], + { + encoding: 'utf8', + env: { + ...process.env, + REDO_TARGET_HELPER: '1', + REDO_TARGET_MARKER: markerPath + } + } + ); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'redo semantics failed'); + } + const payload = JSON.parse(result.stdout || '{}'); + const test = payload.tests?.[0]; + if (!payload.summary || payload.summary.passed !== 1 || payload.summary.failed !== 0) { + throw new Error('expected one passing redo target'); + } + if (!test || test.status !== 'passed' || test.attempts !== 2) { + throw new Error('redo target attempts/status contract failed'); + } + } finally { + fs.rmSync(markerPath, { force: true }); + } + } + }, + { + name: 'timings ledgers are emitted with the expected schema', + async run() { + const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-timings-')); + const timingsPath = path.join(tmpDir, 'timings.json'); + try { + const result = spawnSync(process.execPath, [ + runnerPath, + '--lane', 'all', + '--match', 'harness/pass-target', + '--json', + '--timings-file', timingsPath + ], { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'timings ledger run failed'); + } + const payload = JSON.parse(await fsPromises.readFile(timingsPath, 'utf8')); + const row = payload.tests?.[0]; + if (payload.schemaVersion !== 1 || payload.pathPolicy !== 'repo-relative-posix' || payload.timeUnit !== 'ms') { + throw new Error('timings ledger schema contract failed'); + } + if (!payload.watchdog || typeof payload.watchdog.triggered !== 'boolean') { + throw new Error('missing watchdog block'); + } + if (!Array.isArray(payload.tests) || payload.tests.length !== 1) { + throw new Error('expected one timings row'); + } + if (typeof row.path !== 'string' || row.path.includes('\\')) { + throw new Error('expected POSIX-normalized path'); + } + if (!Number.isFinite(Number(row.durationMs))) { + throw new Error('expected numeric durationMs'); + } + } finally { + await fsPromises.rm(tmpDir, { recursive: true, force: true }); + } + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('runner harness contract matrix test passed'); diff --git a/tests/runner/harness/copy-fixture.test.js b/tests/runner/harness/copy-fixture.test.js deleted file mode 100644 index fe97b33bf..000000000 --- a/tests/runner/harness/copy-fixture.test.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { copyFixtureToTemp } from '../../helpers/fixtures.js'; -import { repoRoot } from '../../helpers/root.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; - -const root = repoRoot(); -const fixturePath = path.join(root, 'tests', 'fixtures', 'sample', 'README.md'); -const original = await fsPromises.readFile(fixturePath, 'utf8'); - -const tempFixture = await copyFixtureToTemp('sample'); -const tempRoot = path.dirname(tempFixture); -const tempReadme = path.join(tempFixture, 'README.md'); -await fsPromises.writeFile(tempReadme, `${original}\nmutation`); - -const updated = await fsPromises.readFile(fixturePath, 'utf8'); -if (updated !== original) { - console.error('copy fixture test failed: original fixture was mutated'); - process.exit(1); -} - -await rmDirRecursive(tempRoot); -console.log('copy fixture test passed'); diff --git a/tests/runner/harness/coverage-changed-untracked.test.js b/tests/runner/harness/coverage-changed-untracked.test.js index cbe16870c..1a372a696 100644 --- a/tests/runner/harness/coverage-changed-untracked.test.js +++ b/tests/runner/harness/coverage-changed-untracked.test.js @@ -16,7 +16,7 @@ try { root, entries: [ { path: relPath, coveredRanges: 1, totalRanges: 1 }, - { path: 'src/shared/files.js', coveredRanges: 1, totalRanges: 2 } + { path: 'src/shared/file-paths.js', coveredRanges: 1, totalRanges: 2 } ] }); diff --git a/tests/runner/harness/coverage-equals-form.test.js b/tests/runner/harness/coverage-equals-form.test.js index 92e9bcffd..650cdf39c 100644 --- a/tests/runner/harness/coverage-equals-form.test.js +++ b/tests/runner/harness/coverage-equals-form.test.js @@ -2,16 +2,18 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const ROOT = repoRoot(); const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-coverage-equals-')); const coveragePath = path.join(tmpDir, 'coverage.json'); -const result = spawnSync(process.execPath, [ +runNode([ runnerPath, '--lane', 'all', @@ -19,15 +21,7 @@ const result = spawnSync(process.execPath, [ 'harness/pass-target', `--coverage=${coveragePath}`, '--json' -], { - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('coverage equals-form test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} +], 'runner coverage equals-form', ROOT, env, { stdio: 'pipe' }); let artifact; try { diff --git a/tests/runner/harness/coverage-flags.test.js b/tests/runner/harness/coverage-flags.test.js index 13cc4e023..c4d023f73 100644 --- a/tests/runner/harness/coverage-flags.test.js +++ b/tests/runner/harness/coverage-flags.test.js @@ -1,12 +1,14 @@ #!/usr/bin/env node import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const ROOT = repoRoot(); const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); -const result = spawnSync(process.execPath, [ +const result = runNode([ runnerPath, '--list', '--lane', @@ -15,15 +17,7 @@ const result = spawnSync(process.execPath, [ '--coverage-merge', '.c8', '--coverage-changed' -], { - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('coverage flags test failed: expected parse/list success'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} +], 'runner coverage flags list', ROOT, env, { stdio: 'pipe' }); const lines = String(result.stdout || '') .split(/\r?\n/) diff --git a/tests/runner/harness/coverage-malformed-json-skip.test.js b/tests/runner/harness/coverage-malformed-json-skip.test.js index 2fc88d3bb..b58a9cf9d 100644 --- a/tests/runner/harness/coverage-malformed-json-skip.test.js +++ b/tests/runner/harness/coverage-malformed-json-skip.test.js @@ -17,7 +17,7 @@ await fsPromises.writeFile(path.join(tempDir, 'bad.json'), '{not-json}\n', 'utf8 await fsPromises.writeFile(path.join(tempDir, 'good.json'), JSON.stringify({ result: [ { - url: path.join(root, 'src', 'shared', 'files.js'), + url: path.join(root, 'src', 'shared', 'file-paths.js'), functions: [ { ranges: [ diff --git a/tests/runner/harness/coverage-policy-schema-validation.test.js b/tests/runner/harness/coverage-policy-schema-validation.test.js new file mode 100644 index 000000000..61a0920b5 --- /dev/null +++ b/tests/runner/harness/coverage-policy-schema-validation.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { validateTestCoveragePolicyReportArtifact } from '../../../src/contracts/validators/test-artifacts.js'; + +const valid = validateTestCoveragePolicyReportArtifact({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + kind: 'test-coverage-policy-report', + policyVersion: '1.0.0', + mode: 'ci', + sourceCoverageKind: 'v8-range-summary', + sourceCoverageRunId: 'run-1', + overall: { + files: 2, + coveredRanges: 8, + totalRanges: 10, + coverageFraction: 0.8 + }, + changedFiles: { + available: true, + strategy: 'explicit-git-range', + baseRef: 'base', + headRef: 'head', + reason: null, + summary: { + files: 1, + coveredRanges: 3, + totalRanges: 4, + coverageFraction: 0.75 + }, + files: [ + { + path: 'bin/pairofcleats.js', + coveredRanges: 3, + totalRanges: 4, + coverageFraction: 0.75 + } + ] + }, + criticalSurfaces: [ + { + id: 'cli', + label: 'CLI', + patterns: ['bin/**'], + summary: { + files: 1, + coveredRanges: 3, + totalRanges: 4, + coverageFraction: 0.75 + }, + topUncoveredFiles: [ + { + path: 'bin/pairofcleats.js', + coveredRanges: 3, + totalRanges: 4, + coverageFraction: 0.75 + } + ] + } + ], + policy: { + phase: 'report-only', + progression: ['report', 'review', 'gate'] + } +}); + +if (!valid.ok) { + console.error('coverage policy schema validation test failed: expected valid payload pass'); + process.exit(1); +} + +const invalid = validateTestCoveragePolicyReportArtifact({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + kind: 'test-coverage-policy-report' +}); + +if (invalid.ok) { + console.error('coverage policy schema validation test failed: expected invalid payload fail'); + process.exit(1); +} + +console.log('coverage policy schema validation test passed'); diff --git a/tests/runner/harness/cwd-independence.test.js b/tests/runner/harness/cwd-independence.test.js deleted file mode 100644 index ea5e0e792..000000000 --- a/tests/runner/harness/cwd-independence.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { repoRoot } from '../../helpers/root.js'; - -const ROOT = repoRoot(); -const testsDir = path.join(ROOT, 'tests'); -const target = path.join(ROOT, 'tests', 'tooling', 'config', 'config-validate.test.js'); - -const result = spawnSync(process.execPath, [target], { - cwd: testsDir, - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('cwd independence test failed'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -console.log('cwd independence test passed'); diff --git a/tests/runner/harness/log-runid.test.js b/tests/runner/harness/log-runid.test.js index 573b3ad01..6848a84ec 100644 --- a/tests/runner/harness/log-runid.test.js +++ b/tests/runner/harness/log-runid.test.js @@ -2,29 +2,24 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const ROOT = repoRoot(); const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-logs-')); const runOnce = () => { - const result = spawnSync(process.execPath, [ + const result = runNode([ runnerPath, '--lane', 'all', '--match', 'harness/pass-target', '--json', '--log-dir', tmpDir - ], { - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error('log runId test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } + ], 'runner log runId contract', ROOT, env, { stdio: 'pipe' }); return JSON.parse(result.stdout || '{}'); }; diff --git a/tests/runner/harness/profile-artifact-contract.test.js b/tests/runner/harness/profile-artifact-contract.test.js index a7cd0d155..c7a743840 100644 --- a/tests/runner/harness/profile-artifact-contract.test.js +++ b/tests/runner/harness/profile-artifact-contract.test.js @@ -2,16 +2,18 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const ROOT = repoRoot(); const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-profile-')); const profilePath = path.join(tmpDir, 'profile.json'); -const result = spawnSync(process.execPath, [ +runNode([ runnerPath, '--lane', 'all', @@ -20,15 +22,7 @@ const result = spawnSync(process.execPath, [ '--profile', profilePath, '--json' -], { - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('profile artifact contract test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} +], 'runner profile artifact contract', ROOT, env, { stdio: 'pipe' }); let payload; try { diff --git a/tests/runner/harness/profile-normalization-determinism.test.js b/tests/runner/harness/profile-normalization-determinism.test.js index 015f468b8..f906741ea 100644 --- a/tests/runner/harness/profile-normalization-determinism.test.js +++ b/tests/runner/harness/profile-normalization-determinism.test.js @@ -2,16 +2,18 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const ROOT = repoRoot(); const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-profile-order-')); const profilePath = path.join(tmpDir, 'profile.json'); -const result = spawnSync(process.execPath, [ +runNode([ runnerPath, '--lane', 'all', @@ -22,15 +24,7 @@ const result = spawnSync(process.execPath, [ '--profile', profilePath, '--json' -], { - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('profile normalization test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} +], 'runner profile normalization determinism', ROOT, env, { stdio: 'pipe' }); let payload; try { diff --git a/tests/runner/harness/redo-semantics.test.js b/tests/runner/harness/redo-semantics.test.js deleted file mode 100644 index ea51470c1..000000000 --- a/tests/runner/harness/redo-semantics.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { repoRoot } from '../../helpers/root.js'; -import { skip } from '../../helpers/skip.js'; - -if (process.platform !== 'win32') { - skip('redo semantics are windows-specific'); -} - -const ROOT = repoRoot(); -const runnerPath = path.join(ROOT, 'tests', 'run.js'); -const markerPath = path.join(os.tmpdir(), `poc-redo-semantics-${process.pid}.marker`); -fs.rmSync(markerPath, { force: true }); - -const result = spawnSync( - process.execPath, - [runnerPath, '--lane', 'unit', '--match', 'harness/redo-target', '--json', '--retries', '0'], - { - encoding: 'utf8', - env: { - ...process.env, - REDO_TARGET_HELPER: '1', - REDO_TARGET_MARKER: markerPath - } - } -); - -fs.rmSync(markerPath, { force: true }); - -if (result.status !== 0) { - console.error('redo semantics test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(result.stdout || '{}'); -} catch { - console.error('redo semantics test failed: invalid JSON output'); - process.exit(1); -} - -if (!payload.summary || payload.summary.passed !== 1 || payload.summary.failed !== 0) { - console.error('redo semantics test failed: expected one passing test'); - process.exit(1); -} - -const test = payload.tests?.[0]; -if (!test || test.status !== 'passed') { - console.error('redo semantics test failed: expected passed status'); - process.exit(1); -} -if (test.attempts !== 2) { - console.error(`redo semantics test failed: expected 2 attempts, got ${test.attempts}`); - process.exit(1); -} - -console.log('redo semantics test passed'); diff --git a/tests/runner/harness/report-file-contract.test.js b/tests/runner/harness/report-file-contract.test.js new file mode 100644 index 000000000..845ef4105 --- /dev/null +++ b/tests/runner/harness/report-file-contract.test.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const ROOT = repoRoot(); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); + +const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-report-')); +const reportPath = path.join(tmpDir, 'report.json'); + +runNode([ + runnerPath, + '--lane', + 'all', + '--match', + 'harness/pass-target', + '--report-file', + reportPath, + '--json' +], 'runner report file contract', ROOT, env, { stdio: 'pipe' }); + +let payload; +try { + payload = JSON.parse(await fsPromises.readFile(reportPath, 'utf8')); +} catch { + console.error('report file contract test failed: missing report artifact'); + process.exit(1); +} + +if (payload?.summary?.total !== 1 + || payload?.summary?.passed !== 1 + || !Array.isArray(payload.tests) + || payload.tests.length !== 1) { + console.error('report file contract test failed: invalid summary payload'); + process.exit(1); +} + +console.log('report file contract test passed'); diff --git a/tests/runner/harness/skip-semantics.test.js b/tests/runner/harness/skip-semantics.test.js deleted file mode 100644 index 36cfdb156..000000000 --- a/tests/runner/harness/skip-semantics.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { repoRoot } from '../../helpers/root.js'; - -const ROOT = repoRoot(); -const runnerPath = path.join(ROOT, 'tests', 'run.js'); - -const result = spawnSync(process.execPath, [runnerPath, '--lane', 'all', '--match', 'runner/harness/skip-target', '--json'], { - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error('skip semantics test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(result.stdout || '{}'); -} catch { - console.error('skip semantics test failed: invalid JSON output'); - process.exit(1); -} - -if (!payload.summary || payload.summary.skipped !== 1) { - console.error('skip semantics test failed: expected one skipped test'); - process.exit(1); -} - -const test = payload.tests?.[0]; -if (!test || test.status !== 'skipped') { - console.error('skip semantics test failed: expected skipped status'); - process.exit(1); -} -if (!test.skipReason || !test.skipReason.includes('skip target')) { - console.error('skip semantics test failed: missing skip reason'); - process.exit(1); -} - -console.log('skip semantics test passed'); diff --git a/tests/runner/harness/stability-artifact-contract.test.js b/tests/runner/harness/stability-artifact-contract.test.js new file mode 100644 index 000000000..6c55eed89 --- /dev/null +++ b/tests/runner/harness/stability-artifact-contract.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const ROOT = repoRoot(); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); + +const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-stability-')); +const stabilityPath = path.join(tmpDir, 'stability.json'); +const historyDir = path.join(tmpDir, 'history'); + +runNode([ + runnerPath, + '--lane', + 'all', + '--match', + 'harness/pass-target', + '--json', + '--stability-file', + stabilityPath, + '--stability-history-dir', + historyDir +], 'runner stability artifact contract', ROOT, env, { stdio: 'pipe' }); + +let payload; +try { + payload = JSON.parse(await fsPromises.readFile(stabilityPath, 'utf8')); +} catch { + console.error('stability artifact contract test failed: missing stability artifact'); + process.exit(1); +} + +if (payload.schemaVersion !== 1 || payload.pathPolicy !== 'repo-relative-posix' || payload.timeUnit !== 'ms') { + console.error('stability artifact contract test failed: missing artifact contract fields'); + process.exit(1); +} +if (!payload.summary || payload.summary.tests !== 1) { + console.error('stability artifact contract test failed: incorrect summary values'); + process.exit(1); +} +if (!Array.isArray(payload.tests) || payload.tests.length !== 1) { + console.error('stability artifact contract test failed: expected one test row'); + process.exit(1); +} +if (!Array.isArray(payload.families) || payload.families.length !== 1) { + console.error('stability artifact contract test failed: expected one family row'); + process.exit(1); +} +const archived = (await fsPromises.readdir(historyDir)).filter((entry) => entry.endsWith('.json')); +if (!archived.length) { + console.error('stability artifact contract test failed: expected archived stability history file'); + process.exit(1); +} + +console.log('stability artifact contract test passed'); diff --git a/tests/runner/harness/stability-schema-validation.test.js b/tests/runner/harness/stability-schema-validation.test.js new file mode 100644 index 000000000..ee5a745a8 --- /dev/null +++ b/tests/runner/harness/stability-schema-validation.test.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node +import { validateTestStabilityArtifact } from '../../../src/contracts/validators/test-artifacts.js'; + +const GENERATED_AT = new Date().toISOString(); +const RETRY_BY_SUITE_CATEGORY = { + hero: { maxRetries: 0, quarantine: 'manual', note: 'hero' }, + matrix: { maxRetries: 1, quarantine: 'owner', note: 'matrix' }, + meta: { maxRetries: 0, quarantine: 'none', note: 'meta' }, + soak: { maxRetries: 0, quarantine: 'manual', note: 'soak' }, + 'heavy-runtime': { maxRetries: 1, quarantine: 'owner', note: 'heavy' } +}; +const RUNNER_HARNESS_FAMILY = { + id: 'runner/harness', + tests: 1, + unstable: 1, + flaky: 1, + slow: 0, + environmentSensitive: 0, + failed: 0, + timedOut: 0, + redo: 0, + avgDurationMs: 1, + maxDurationMs: 1 +}; + +const createStabilityArtifact = ({ + history, + environment, + policy, + diagnostics, + summary, + suiteCategories, + familyTrends, + families, + tests +}) => ({ + schemaVersion: 1, + generatedAt: GENERATED_AT, + runId: 'run-1', + pathPolicy: 'repo-relative-posix', + timeUnit: 'ms', + lane: 'ci-lite', + history, + environment, + policy, + diagnostics, + summary, + suiteCategories, + familyTrends, + families, + tests +}); + +const valid = validateTestStabilityArtifact(createStabilityArtifact({ + history: { + sourceDir: '.testLogs/stability-history/ci-lite', + loadedArtifacts: 1, + historyLimit: 12 + }, + environment: { + fingerprint: 'win32|x64|v1|ci|ci-lite', + platform: 'win32', + arch: 'x64', + node: 'v1', + ci: true, + suiteMode: 'ci' + }, + policy: { + retry: { + runnerRetries: 1, + automaticRetryEnabled: true, + note: 'visible retries' + }, + quarantine: { + automaticQuarantine: false, + note: 'manual only' + }, + escalation: { + flaky: 'owner-review-and-repeat-run', + slow: 'budget-review-shard-or-harness-reuse', + environmentSensitive: 'fingerprint-review-and-environment-normalization' + }, + retryBySuiteCategory: RETRY_BY_SUITE_CATEGORY + }, + diagnostics: { + expectedNegativeStderrIds: ['cli/error-contract'], + diagnosticsClasses: ['clean', 'expected-negative-stderr', 'unexpected-stderr'] + }, + summary: { + tests: 1, + unstable: 1, + flaky: 1, + slow: 0, + environmentSensitive: 0, + failed: 0, + timedOut: 0, + redo: 0, + expectedNegativeStderr: 1, + unexpectedStderr: 0 + }, + suiteCategories: { + hero: 1, + matrix: 0, + meta: 0, + soak: 0, + 'heavy-runtime': 0 + }, + familyTrends: [RUNNER_HARNESS_FAMILY], + families: [RUNNER_HARNESS_FAMILY], + tests: [ + { + id: 'runner/harness/pass-target', + path: 'tests/runner/harness/pass-target.test.js', + lane: 'unit', + family: 'runner/harness', + suiteCategory: 'hero', + status: 'passed', + durationMs: 1, + timeoutBudgetMs: 15000, + stabilityClass: 'flaky', + diagnosticsClass: 'expected-negative-stderr', + outcomeClass: 'passed', + historyWindow: 1, + historyOutcomes: ['failed'], + historyEnvironments: ['win32|x64|v1|ci|ci-lite'], + environmentFingerprint: 'win32|x64|v1|ci|ci-lite' + } + ] +})); + +if (!valid.ok) { + console.error('stability schema validation test failed: expected valid payload pass'); + process.exit(1); +} + +const invalid = validateTestStabilityArtifact(createStabilityArtifact({ + history: { + sourceDir: null, + loadedArtifacts: 0, + historyLimit: 12 + }, + environment: { + fingerprint: 'x', + platform: 'win32', + arch: 'x64', + node: 'v1', + ci: true, + suiteMode: 'ci' + }, + policy: { + retry: { + runnerRetries: 0, + automaticRetryEnabled: false, + note: 'none' + }, + quarantine: { + automaticQuarantine: false, + note: 'manual' + }, + escalation: { + flaky: 'review', + slow: 'review', + environmentSensitive: 'review' + }, + retryBySuiteCategory: RETRY_BY_SUITE_CATEGORY + }, + diagnostics: { + expectedNegativeStderrIds: [], + diagnosticsClasses: ['clean', 'expected-negative-stderr', 'unexpected-stderr'] + }, + summary: { + tests: 0, + unstable: 0, + flaky: 0, + slow: 0, + environmentSensitive: 0, + failed: 0, + timedOut: 0, + redo: 0, + expectedNegativeStderr: 0, + unexpectedStderr: 0 + }, + suiteCategories: { + hero: 0, + matrix: 0, + meta: 0, + soak: 0, + 'heavy-runtime': 0 + }, + familyTrends: [], + families: [], + tests: [ + { + id: 'runner/harness/pass-target', + path: 'tests/runner/harness/pass-target.test.js', + lane: 'unit', + family: 'runner/harness', + suiteCategory: 'hero', + status: 'passed', + durationMs: 1, + timeoutBudgetMs: 15000, + stabilityClass: 'unknown', + diagnosticsClass: 'clean', + outcomeClass: 'passed', + historyWindow: 0, + historyOutcomes: [], + historyEnvironments: [], + environmentFingerprint: 'x' + } + ] +})); + +if (invalid.ok) { + console.error('stability schema validation test failed: expected invalid payload fail'); + process.exit(1); +} + +console.log('stability schema validation test passed'); diff --git a/tests/runner/harness/timeout-pass-signal-classification.test.js b/tests/runner/harness/timeout-pass-signal-classification.test.js new file mode 100644 index 000000000..3c56c15ea --- /dev/null +++ b/tests/runner/harness/timeout-pass-signal-classification.test.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { repoRoot } from '../../helpers/root.js'; + +const ROOT = repoRoot(); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); + +const result = spawnSync(process.execPath, [ + runnerPath, + '--lane', + 'unit', + '--match', + 'harness/timeout-pass-signal-target', + '--timeout-ms', + '500', + '--json' +], { + encoding: 'utf8', + env: { + ...process.env, + PAIROFCLEATS_TEST_ALLOW_TIMEOUT_PASS_SIGNAL_TARGET: '1' + } +}); + +if (result.status !== 0) { + console.error(`timeout pass-signal classification test failed: expected runner exit 0, got ${result.status}`); + process.exit(1); +} + +let payload; +try { + payload = JSON.parse(result.stdout || '{}'); +} catch { + console.error('timeout pass-signal classification test failed: invalid JSON output'); + process.exit(1); +} + +const test = payload.tests?.find((entry) => entry?.id === 'runner/harness/timeout-pass-signal-target'); +if (!test) { + console.error('timeout pass-signal classification test failed: missing target test result'); + process.exit(1); +} +if (!test.timedOut) { + console.error('timeout pass-signal classification test failed: expected timedOut=true'); + process.exit(1); +} +if (String(test.timeoutClass || '') !== 'timed_out_after_pass') { + console.error(`timeout pass-signal classification test failed: expected timed_out_after_pass, got ${test.timeoutClass || 'null'}`); + process.exit(1); +} + +console.log('timeout pass-signal classification test passed'); diff --git a/tests/runner/harness/timeout-pass-signal-target.test.js b/tests/runner/harness/timeout-pass-signal-target.test.js new file mode 100644 index 000000000..9a60f711d --- /dev/null +++ b/tests/runner/harness/timeout-pass-signal-target.test.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { skip } from '../../helpers/skip.js'; + +const allowRun = process.env.PAIROFCLEATS_TEST_ALLOW_TIMEOUT_PASS_SIGNAL_TARGET === '1' + || process.env.PAIROFCLEATS_TEST_ALLOW_TIMEOUT_PASS_SIGNAL_TARGET === 'true'; +if (!allowRun) { + skip('timeout pass-signal target is helper-only'); +} + +console.log('timeout pass-signal target test passed'); +setInterval(() => {}, 1000); diff --git a/tests/runner/harness/timings-ledger.test.js b/tests/runner/harness/timings-ledger.test.js deleted file mode 100644 index 33c387157..000000000 --- a/tests/runner/harness/timings-ledger.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { spawnSync } from 'node:child_process'; -import { repoRoot } from '../../helpers/root.js'; - -const ROOT = repoRoot(); -const runnerPath = path.join(ROOT, 'tests', 'run.js'); - -const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-timings-')); -const timingsPath = path.join(tmpDir, 'timings.json'); - -const result = spawnSync(process.execPath, [ - runnerPath, - '--lane', 'all', - '--match', 'harness/pass-target', - '--json', - '--timings-file', timingsPath -], { encoding: 'utf8' }); - -if (result.status !== 0) { - console.error('timings ledger test failed: runner exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(await fsPromises.readFile(timingsPath, 'utf8')); -} catch { - console.error('timings ledger test failed: missing or invalid timings file'); - process.exit(1); -} - -if (payload.schemaVersion !== 1) { - console.error('timings ledger test failed: expected schemaVersion=1'); - process.exit(1); -} -if (payload.pathPolicy !== 'repo-relative-posix' || payload.timeUnit !== 'ms') { - console.error('timings ledger test failed: expected pathPolicy/timeUnit contract'); - process.exit(1); -} -if (!payload.watchdog || typeof payload.watchdog.triggered !== 'boolean') { - console.error('timings ledger test failed: expected watchdog block'); - process.exit(1); -} -if (!Array.isArray(payload.tests) || payload.tests.length !== 1) { - console.error('timings ledger test failed: expected one test entry'); - process.exit(1); -} -const row = payload.tests[0]; -if (typeof row.path !== 'string' || row.path.includes('\\')) { - console.error('timings ledger test failed: expected POSIX-normalized test path'); - process.exit(1); -} -if (!Number.isFinite(Number(row.durationMs))) { - console.error('timings ledger test failed: expected numeric durationMs'); - process.exit(1); -} - -console.log('timings ledger test passed'); diff --git a/tests/runner/lane-audit.js b/tests/runner/lane-audit.js new file mode 100644 index 000000000..e2560a524 --- /dev/null +++ b/tests/runner/lane-audit.js @@ -0,0 +1,143 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { discoverTests } from './run-discovery.js'; +import { loadRunRules } from './run-config.js'; +import { + loadLaneManifestConfig, + loadOrderedLaneManifest +} from './lane-manifests.js'; + +const parseOrderFile = async (filePath) => { + const raw = await fsPromises.readFile(filePath, 'utf8'); + return raw + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +}; + +export const buildLaneAuditReport = async ({ root = process.cwd(), config } = {}) => { + const laneConfig = config || await loadLaneManifestConfig({ root }); + const runRules = loadRunRules({ root }); + const discovered = await discoverTests({ + testsDir: path.join(root, 'tests'), + excludedDirs: runRules.excludedDirs, + excludedFiles: runRules.excludedFiles + }); + const knownIds = new Set(discovered.map((entry) => entry.id)); + + const lanes = []; + const ownership = new Map(); + const duplicates = []; + const missingIds = []; + const manifestMismatches = []; + const timingOverruns = []; + + for (const currentLane of laneConfig.orderedLanes.values()) { + const orderIds = await parseOrderFile(currentLane.orderFilePath); + const manifest = await loadOrderedLaneManifest({ + root, + lane: currentLane.lane, + config: laneConfig + }); + const manifestIds = Array.isArray(manifest?.tests) + ? manifest.tests.map((entry) => entry.id) + : []; + + if (JSON.stringify(orderIds) !== JSON.stringify(manifestIds)) { + manifestMismatches.push({ + lane: currentLane.lane, + orderCount: orderIds.length, + manifestCount: manifestIds.length + }); + } + + for (const id of orderIds) { + if (!knownIds.has(id)) { + missingIds.push({ lane: currentLane.lane, id }); + } + const existing = ownership.get(id); + if (existing) { + duplicates.push({ id, lanes: [existing, currentLane.lane] }); + } else { + ownership.set(id, currentLane.lane); + } + } + + const targetMaxDurationMs = Number(currentLane.targetMaxDurationSeconds) * 1000; + for (const entry of manifest?.tests || []) { + if (!Number.isFinite(entry?.durationMs)) continue; + if (entry.durationMs > targetMaxDurationMs) { + timingOverruns.push({ + lane: currentLane.lane, + id: entry.id, + durationMs: entry.durationMs, + targetMaxDurationMs + }); + } + } + + lanes.push({ + lane: currentLane.lane, + totalTests: orderIds.length, + missingIds: missingIds.filter((entry) => entry.lane === currentLane.lane).length, + duplicates: duplicates.filter((entry) => entry.lanes.includes(currentLane.lane)).length, + timingOverruns: timingOverruns.filter((entry) => entry.lane === currentLane.lane).length + }); + } + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + summary: { + lanes: lanes.length, + missingIds: missingIds.length, + duplicateIds: duplicates.length, + manifestMismatches: manifestMismatches.length, + timingOverruns: timingOverruns.length + }, + lanes, + missingIds, + duplicates, + manifestMismatches, + timingOverruns + }; +}; + +export const formatLaneAuditReport = (report) => { + const lines = [ + 'Lane audit summary', + `- lanes: ${report.summary.lanes}`, + `- missing ids: ${report.summary.missingIds}`, + `- duplicate ids: ${report.summary.duplicateIds}`, + `- manifest mismatches: ${report.summary.manifestMismatches}`, + `- timing overruns: ${report.summary.timingOverruns}` + ]; + + if (report.missingIds.length) { + lines.push('', 'Missing ids:'); + for (const entry of report.missingIds) { + lines.push(`- [${entry.lane}] ${entry.id}`); + } + } + if (report.duplicates.length) { + lines.push('', 'Duplicate ids:'); + for (const entry of report.duplicates) { + lines.push(`- ${entry.id}: ${entry.lanes.join(', ')}`); + } + } + if (report.manifestMismatches.length) { + lines.push('', 'Manifest mismatches:'); + for (const entry of report.manifestMismatches) { + lines.push(`- [${entry.lane}] order=${entry.orderCount} manifest=${entry.manifestCount}`); + } + } + if (report.timingOverruns.length) { + lines.push('', 'Timing overruns:'); + for (const entry of report.timingOverruns) { + lines.push(`- [${entry.lane}] ${entry.id}: ${entry.durationMs}ms > ${entry.targetMaxDurationMs}ms`); + } + } + + return `${lines.join('\n')}\n`; +}; diff --git a/tests/runner/lane-audit.test.js b/tests/runner/lane-audit.test.js new file mode 100644 index 000000000..6dcc4bf9a --- /dev/null +++ b/tests/runner/lane-audit.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { buildLaneAuditReport, formatLaneAuditReport } from './lane-audit.js'; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pairofcleats-lane-audit-')); +const testsDir = path.join(tempRoot, 'tests'); +const runnerDir = path.join(testsDir, 'runner'); +const ciLiteDir = path.join(testsDir, 'ci-lite'); +const ciDir = path.join(testsDir, 'ci'); +const logDir = path.join(tempRoot, '.testLogs'); +fs.mkdirSync(runnerDir, { recursive: true }); +fs.mkdirSync(ciLiteDir, { recursive: true }); +fs.mkdirSync(ciDir, { recursive: true }); +fs.mkdirSync(logDir, { recursive: true }); + +fs.writeFileSync( + path.join(runnerDir, 'lane-manifests.jsonc'), + `${JSON.stringify({ + orderedLanes: { + 'ci-lite': { + targetMaxDurationSeconds: 15, + orderFile: 'tests/ci-lite/ci-lite.order.txt', + manifestFile: 'tests/ci-lite/ci-lite.manifest.json', + timingArtifactPaths: ['.testLogs/ci-lite-testRunTimes.txt'] + }, + ci: { + targetMaxDurationSeconds: 60, + orderFile: 'tests/ci/ci.order.txt', + manifestFile: 'tests/ci/ci.manifest.json', + timingArtifactPaths: ['.testLogs/ci-testRunTimes.txt'] + } + } + }, null, 2)}\n`, + 'utf8' +); + +fs.writeFileSync(path.join(ciLiteDir, 'ci-lite.order.txt'), 'alpha\nmissing/test\n', 'utf8'); +fs.writeFileSync(path.join(ciDir, 'ci.order.txt'), 'alpha\nbeta\n', 'utf8'); + +fs.writeFileSync( + path.join(ciLiteDir, 'ci-lite.manifest.json'), + `${JSON.stringify({ + lane: 'ci-lite', + tests: [{ id: 'alpha', durationMs: 20000 }] + }, null, 2)}\n`, + 'utf8' +); +fs.writeFileSync( + path.join(ciDir, 'ci.manifest.json'), + `${JSON.stringify({ + lane: 'ci', + tests: [{ id: 'alpha', durationMs: 500 }] + }, null, 2)}\n`, + 'utf8' +); + +fs.writeFileSync(path.join(logDir, 'ci-lite-testRunTimes.txt'), '20000ms\talpha\n', 'utf8'); +fs.writeFileSync(path.join(logDir, 'ci-testRunTimes.txt'), '500ms\talpha\n', 'utf8'); + +fs.writeFileSync(path.join(testsDir, 'alpha.test.js'), 'console.log("alpha");\n', 'utf8'); +fs.writeFileSync(path.join(testsDir, 'beta.test.js'), 'console.log("beta");\n', 'utf8'); + +const report = await buildLaneAuditReport({ root: tempRoot }); + +assert.equal(report.summary.missingIds, 1); +assert.equal(report.summary.duplicateIds, 1); +assert.equal(report.summary.manifestMismatches, 2); +assert.equal(report.summary.timingOverruns, 1); +assert.match(formatLaneAuditReport(report), /missing ids/i); +assert.match(formatLaneAuditReport(report), /timing overruns/i); + +console.log('lane audit test passed'); diff --git a/tests/runner/lane-evidence.js b/tests/runner/lane-evidence.js new file mode 100644 index 000000000..bdacf405e --- /dev/null +++ b/tests/runner/lane-evidence.js @@ -0,0 +1,422 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { discoverTests } from './run-discovery.js'; +import { loadRunRules } from './run-config.js'; +import { loadLaneManifestConfig } from './lane-manifests.js'; +import { writeStableGeneratedJsonReport, writeTextIfChanged } from '../../tools/shared/generated-report.js'; + +const DEFAULT_HISTORICAL_TIMINGS_PATH = 'tools/test_times/TEST_TIMES.md'; + +const HOTSPOT_RULES = [ + { kind: 'search-cli-contract', match: /^cli\/search\//u, note: 'Search CLI help, explain, and contract surfaces share fixture/index bootstrap.' }, + { kind: 'smoke-wiring', match: /^smoke\//u, note: 'Smoke suites should stay thin and avoid chaining lower-level contract tests.' }, + { kind: 'sqlite-heavy', match: /^storage\/sqlite\//u, note: 'SQLite maintenance, fail-closed, and migration tests often rebuild the same sample fixture.' }, + { kind: 'lmdb-report', match: /^storage\/lmdb\//u, note: 'LMDB report/corruption tests can often share one built fixture and diverge only in tamper steps.' }, + { kind: 'embeddings-cache', match: /^indexing\/embeddings\//u, note: 'Embedding cache and stub fast-path families frequently overlap on the same fixture/setup.' }, + { kind: 'map-build', match: /^indexing\/map\//u, note: 'Code-map suites often share the same repo build and render pipeline.' }, + { kind: 'lsp-bootstrap', match: /^tooling\/lsp\//u, note: 'Dedicated/configured provider bootstrap and session reuse.' }, + { kind: 'api-server-boot', match: /^services\/api\//u, note: 'HTTP server startup, routing, and streaming harness reuse.' }, + { kind: 'cli-cold-start', match: /^cli\//u, note: 'CLI process startup and argument-routing overlap.' }, + { kind: 'index-build-heavy', match: /^(indexing\/|tooling\/triage\/|storage\/sqlite\/build)/u, note: 'Index construction, replay, and build-heavy setup overlap.' } +]; + +const toPosix = (value) => String(value || '').replace(/\\/g, '/'); +const stripTestsPrefix = (value) => toPosix(value).replace(/^tests\//u, ''); + +const parseOrderFile = async (filePath) => { + const raw = await fsPromises.readFile(filePath, 'utf8'); + return raw + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +}; + +const toRoundedMs = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.max(0, Number(numeric.toFixed(3))); +}; + +const quantile = (values, ratio) => { + if (!Array.isArray(values) || !values.length) return null; + const numericRatio = Number(ratio); + if (!Number.isFinite(numericRatio)) return null; + const sorted = values + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .sort((a, b) => a - b); + if (!sorted.length) return null; + const boundedRatio = Math.min(1, Math.max(0, numericRatio)); + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(sorted.length * boundedRatio) - 1) + ); + return toRoundedMs(sorted[index]); +}; + +const familyFromId = (id) => { + const parts = String(id || '').split('/').filter(Boolean); + return parts.slice(0, Math.min(2, parts.length)).join('/'); +}; + +const hotspotKindForId = (id) => { + for (const rule of HOTSPOT_RULES) { + if (rule.match.test(String(id || ''))) { + return rule.kind; + } + } + return 'other'; +}; + +export const parseHistoricalTestTimes = (raw) => { + const lines = String(raw || '').split(/\r?\n/u); + const byPath = new Map(); + for (const line of lines) { + const match = line.match(/^- ([0-9-]{10} [0-9:]{8}) \| `([^`]+)` \| ([0-9.]+)s \| (.+)$/u); + if (!match) continue; + const recordedAt = `${match[1]}Z`; + const relPath = toPosix(match[2]); + const durationMs = Math.round(Number(match[3]) * 1000); + if (!Number.isFinite(durationMs)) continue; + const normalizedPaths = new Set([relPath, stripTestsPrefix(relPath)]); + for (const normalizedPath of normalizedPaths) { + if (!normalizedPath) continue; + const existing = byPath.get(normalizedPath); + if (existing && String(existing.recordedAt) >= recordedAt) continue; + byPath.set(normalizedPath, { + path: normalizedPath, + durationMs, + recordedAt, + status: match[4] + }); + } + } + return byPath; +}; + +const loadHistoricalTestTimes = async (filePath) => { + try { + const raw = await fsPromises.readFile(filePath, 'utf8'); + return parseHistoricalTestTimes(raw); + } catch { + return new Map(); + } +}; + +const parseLogTimesArtifact = async (filePath) => { + const raw = await fsPromises.readFile(filePath, 'utf8'); + const byId = new Map(); + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)ms\t(.+)$/u); + if (!match) continue; + const id = String(match[2] || '').trim(); + const durationMs = Number(match[1]); + if (!id || !Number.isFinite(durationMs)) continue; + if (!byId.has(id)) byId.set(id, durationMs); + } + return byId; +}; + +const parseJsonTimingsArtifact = async (filePath) => { + const parsed = JSON.parse(await fsPromises.readFile(filePath, 'utf8')); + const byId = new Map(); + const tests = Array.isArray(parsed?.tests) ? parsed.tests : []; + for (const row of tests) { + const id = String(row?.id || '').trim(); + const durationMs = Number(row?.durationMs); + if (!id || !Number.isFinite(durationMs)) continue; + if (!byId.has(id)) byId.set(id, durationMs); + } + return byId; +}; + +const loadTimingArtifacts = async ({ root, timingArtifactPaths = [] }) => { + const maps = []; + const resolvedPaths = []; + for (const rawPath of timingArtifactPaths) { + const trimmed = String(rawPath || '').trim(); + if (!trimmed) continue; + const absolutePath = path.resolve(root, trimmed); + try { + await fsPromises.access(absolutePath); + } catch { + continue; + } + const parser = absolutePath.endsWith('.json') + ? parseJsonTimingsArtifact + : parseLogTimesArtifact; + try { + maps.push(await parser(absolutePath)); + resolvedPaths.push(absolutePath); + } catch {} + } + return { maps, resolvedPaths }; +}; + +const getDurationFromTimingMaps = (maps, id) => { + for (const map of maps) { + if (map.has(id)) { + return map.get(id); + } + } + return null; +}; + +const buildPathById = async (root) => { + const runRules = loadRunRules({ root }); + const testsDir = path.join(root, 'tests'); + const discovered = await discoverTests({ + testsDir, + excludedDirs: runRules.excludedDirs, + excludedFiles: runRules.excludedFiles + }); + return new Map(discovered.map((entry) => [entry.id, toPosix(entry.relPath)])); +}; + +const summarizeRows = (rows, keyFn) => { + const map = new Map(); + for (const row of rows) { + const key = keyFn(row); + if (!key) continue; + const current = map.get(key) || { + key, + count: 0, + knownDurationMs: 0, + unknownDurationCount: 0 + }; + current.count += 1; + if (Number.isFinite(row.durationMs)) { + current.knownDurationMs += Number(row.durationMs); + } else { + current.unknownDurationCount += 1; + } + map.set(key, current); + } + return Array.from(map.values()).sort((a, b) => ( + (b.knownDurationMs - a.knownDurationMs) + || (b.count - a.count) + || String(a.key).localeCompare(String(b.key)) + )); +}; + +export const buildLaneEvidenceReport = ({ laneRows = [] } = {}) => { + const allRows = laneRows.flatMap((lane) => lane.rows || []); + const duplicates = new Map(); + for (const lane of laneRows) { + for (const row of lane.rows || []) { + const existing = duplicates.get(row.id) || { + id: row.id, + path: row.path || '', + lanes: [], + durationsByLane: {} + }; + existing.lanes.push(lane.lane); + if (Number.isFinite(row.durationMs)) { + existing.durationsByLane[lane.lane] = row.durationMs; + } + duplicates.set(row.id, existing); + } + } + + const exactDuplicates = Array.from(duplicates.values()) + .filter((entry) => entry.lanes.length > 1) + .sort((a, b) => ( + (b.lanes.length - a.lanes.length) + || String(a.id).localeCompare(String(b.id)) + )); + + const families = summarizeRows(allRows, (row) => familyFromId(row.id)); + const topSlowest = allRows + .filter((row) => Number.isFinite(row.durationMs)) + .sort((a, b) => ( + Number(b.durationMs) - Number(a.durationMs) + ) || String(a.id).localeCompare(String(b.id))) + .slice(0, 10) + .map((row) => ({ + id: row.id, + path: row.path, + durationMs: row.durationMs, + family: familyFromId(row.id), + hotspotKind: hotspotKindForId(row.id) + })); + const hotspots = summarizeRows( + allRows.filter((row) => hotspotKindForId(row.id) !== 'other'), + (row) => hotspotKindForId(row.id) + ).map((entry) => ({ + ...entry, + note: HOTSPOT_RULES.find((rule) => rule.kind === entry.key)?.note || '' + })); + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + summary: { + lanes: laneRows.length, + tests: allRows.length, + exactDuplicates: exactDuplicates.length, + hotspotKinds: hotspots.length, + families: families.length, + topSlowest: topSlowest.length, + timingCoverage: { + freshArtifactTests: allRows.filter((row) => row.timingSource === 'timing-artifact').length, + historicalFallbackTests: allRows.filter((row) => row.timingSource === 'historical-test-times').length, + missingTests: allRows.filter((row) => row.timingSource === 'missing').length + } + }, + lanes: laneRows.map((lane) => ({ + lane: lane.lane, + targetMaxDurationSeconds: lane.targetMaxDurationSeconds, + orderFile: lane.orderFile, + timingArtifactPath: lane.timingArtifactPath, + totalTests: lane.rows.length, + knownDurationTests: lane.rows.filter((row) => Number.isFinite(row.durationMs)).length, + knownDurationMs: toRoundedMs( + lane.rows.reduce((sum, row) => sum + (Number.isFinite(row.durationMs) ? Number(row.durationMs) : 0), 0) + ), + p50DurationMs: quantile( + lane.rows.filter((row) => Number.isFinite(row.durationMs)).map((row) => row.durationMs), + 0.5 + ), + p95DurationMs: quantile( + lane.rows.filter((row) => Number.isFinite(row.durationMs)).map((row) => row.durationMs), + 0.95 + ), + resolvedTimingArtifactPaths: Array.isArray(lane.resolvedTimingArtifactPaths) + ? lane.resolvedTimingArtifactPaths + : [], + timingSources: Object.fromEntries( + summarizeRows(lane.rows, (row) => row.timingSource) + .map((entry) => [entry.key, entry.count]) + ) + })), + exactDuplicates, + families, + hotspots, + topSlowest + }; +}; + +const renderMarkdown = ({ report, root, historicalTimingsPath }) => { + const lines = [ + '# Lane Evidence', + '', + `Generated: ${report.generatedAt}`, + '', + '## How To Use', + '', + '- Use this report before deleting or merging tests; cite the family, duplicate, and hotspot rows you relied on.', + '- Prefer consolidating repeated setup families before deleting deep-path edge cases.', + '- When a standalone test is removed, name the surviving owner suite that now covers the behavior.', + '- Prefer fresh lane timing artifacts when available; historical fallback rows are a stopgap, not a replacement for current lane measurements.', + `- Historical timing fallback source: \`${toPosix(path.relative(root, historicalTimingsPath))}\`.`, + '', + '## Lane Summary', + '' + ]; + for (const lane of report.lanes) { + lines.push( + `- \`${lane.lane}\`: ${lane.totalTests} tests, ${lane.knownDurationTests} with timings, ` + + `${lane.knownDurationMs ?? 0} ms known duration, ` + + `p50=${lane.p50DurationMs ?? 'n/a'} ms, p95=${lane.p95DurationMs ?? 'n/a'} ms, ` + + `target ${lane.targetMaxDurationSeconds}s` + ); + if (lane.resolvedTimingArtifactPaths?.length) { + lines.push(` fresh artifacts: ${lane.resolvedTimingArtifactPaths.map((item) => `\`${item}\``).join(', ')}`); + } + } + lines.push('', '## Exact Cross-Lane Duplicates', ''); + if (!report.exactDuplicates.length) { + lines.push('- None'); + } else { + for (const entry of report.exactDuplicates) { + lines.push(`- \`${entry.id}\` in ${entry.lanes.join(', ')}`); + } + } + lines.push('', '## Top Families', ''); + for (const entry of report.families.slice(0, 15)) { + lines.push(`- \`${entry.key}\`: ${entry.count} tests, ${entry.knownDurationMs} ms known duration`); + } + lines.push('', '## Setup Hotspots', ''); + for (const entry of report.hotspots) { + lines.push(`- \`${entry.key}\`: ${entry.count} tests, ${entry.knownDurationMs} ms known duration`); + if (entry.note) lines.push(` ${entry.note}`); + } + lines.push('', '## Top Slowest Tests', ''); + for (const entry of report.topSlowest) { + lines.push(`- \`${entry.id}\`: ${entry.durationMs} ms`); + if (entry.family) lines.push(` family: \`${entry.family}\``); + if (entry.hotspotKind && entry.hotspotKind !== 'other') lines.push(` hotspot: \`${entry.hotspotKind}\``); + } + lines.push(''); + return `${lines.join('\n')}\n`; +}; + +export const generateLaneEvidence = async ({ + root = process.cwd(), + historicalTimingsPath = path.join(process.cwd(), DEFAULT_HISTORICAL_TIMINGS_PATH), + outputJsonPath = path.join(process.cwd(), 'docs', 'testing', 'lane-evidence.json'), + outputMarkdownPath = path.join(process.cwd(), 'docs', 'testing', 'lane-evidence.md'), + writeTimingArtifacts = true +} = {}) => { + const config = await loadLaneManifestConfig({ root }); + const pathById = await buildPathById(root); + const historicalByPath = await loadHistoricalTestTimes(historicalTimingsPath); + const laneRows = []; + + for (const laneConfig of config.orderedLanes.values()) { + const ids = await parseOrderFile(laneConfig.orderFilePath); + const { maps: timingMaps, resolvedPaths } = await loadTimingArtifacts({ + root, + timingArtifactPaths: laneConfig.timingArtifactPaths + }); + const timingArtifactPath = laneConfig.timingArtifactPaths[0] + ? path.resolve(root, laneConfig.timingArtifactPaths[0]) + : ''; + const rows = ids.map((id) => { + const relPath = pathById.get(id) || ''; + const durationFromArtifact = getDurationFromTimingMaps(timingMaps, id); + const historical = relPath ? historicalByPath.get(relPath) : null; + const durationMs = Number.isFinite(durationFromArtifact) + ? Number(durationFromArtifact) + : (historical?.durationMs ?? null); + return { + id, + path: relPath, + durationMs, + timingSource: Number.isFinite(durationFromArtifact) + ? 'timing-artifact' + : (historical ? 'historical-test-times' : 'missing') + }; + }); + laneRows.push({ + lane: laneConfig.lane, + orderFile: toPosix(path.relative(root, laneConfig.orderFilePath)), + timingArtifactPath: timingArtifactPath ? toPosix(path.relative(root, timingArtifactPath)) : '', + resolvedTimingArtifactPaths: resolvedPaths.map((item) => toPosix(path.relative(root, item))), + targetMaxDurationSeconds: laneConfig.targetMaxDurationSeconds, + rows + }); + if (writeTimingArtifacts && timingArtifactPath) { + const timingLines = rows + .filter((row) => Number.isFinite(row.durationMs)) + .map((row) => `${Math.round(Number(row.durationMs))}ms\t${row.id}`); + await writeTextIfChanged(timingArtifactPath, `${timingLines.join('\n')}\n`, { encoding: 'utf8' }); + } + } + + const initialReport = buildLaneEvidenceReport({ laneRows }); + const report = await writeStableGeneratedJsonReport(outputJsonPath, initialReport); + await writeTextIfChanged( + outputMarkdownPath, + renderMarkdown({ report, root, historicalTimingsPath }), + { encoding: 'utf8' } + ); + return { + report, + outputJsonPath, + outputMarkdownPath, + laneRows + }; +}; diff --git a/tests/runner/lane-evidence.test.js b/tests/runner/lane-evidence.test.js new file mode 100644 index 000000000..be36869e5 --- /dev/null +++ b/tests/runner/lane-evidence.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildLaneEvidenceReport, parseHistoricalTestTimes } from './lane-evidence.js'; + +const parsed = parseHistoricalTestTimes(` +- 2026-01-18 07:50:10 | \`tests\\cli\\search\\search-removed-flags.test.js\` | 1.03s | timeout +- 2026-01-18 08:08:55 | \`tests\\cli\\search\\search-removed-flags.test.js\` | 3.73s | exit 0 +`); + +assert.equal(parsed.get('tests/cli/search/search-removed-flags.test.js')?.durationMs, 3730); +assert.equal(parsed.get('cli/search/search-removed-flags.test.js')?.durationMs, 3730); + +const report = buildLaneEvidenceReport({ + laneRows: [ + { + lane: 'ci', + targetMaxDurationSeconds: 60, + orderFile: 'tests/ci/ci.order.txt', + timingArtifactPath: '.testLogs/ci-testRunTimes.txt', + resolvedTimingArtifactPaths: ['.testLogs/ci-testRunTimes.txt'], + rows: [ + { id: 'tooling/lsp/a', path: 'tests/tooling/lsp/a.test.js', durationMs: 1200, timingSource: 'timing-artifact' }, + { id: 'cli/search/b', path: 'tests/cli/search/b.test.js', durationMs: 800, timingSource: 'historical-test-times' } + ] + }, + { + lane: 'ci-long', + targetMaxDurationSeconds: 180, + orderFile: 'tests/ci-long/ci-long.order.txt', + timingArtifactPath: '.testLogs/ci-long-testRunTimes.txt', + rows: [ + { id: 'tooling/lsp/a', path: 'tests/tooling/lsp/a.test.js', durationMs: 1400, timingSource: 'historical-test-times' }, + { id: 'storage/sqlite/c', path: 'tests/storage/sqlite/c.test.js', durationMs: 2200, timingSource: 'historical-test-times' } + ] + } + ] +}); + +assert.equal(report.summary.exactDuplicates, 1); +assert.equal(report.exactDuplicates[0].id, 'tooling/lsp/a'); +assert.equal(report.families[0].key, 'tooling/lsp'); +assert.equal(report.hotspots[0].key, 'lsp-bootstrap'); +assert.equal(report.topSlowest[0].id, 'storage/sqlite/c'); +assert.equal(report.lanes[0].p50DurationMs, 800); +assert.equal(report.lanes[1].p95DurationMs, 2200); +assert.equal(report.summary.timingCoverage.freshArtifactTests, 1); +assert.deepEqual(report.lanes[0].resolvedTimingArtifactPaths, ['.testLogs/ci-testRunTimes.txt']); + +console.log('lane evidence test passed'); diff --git a/tests/runner/lane-manifest-generation.test.js b/tests/runner/lane-manifest-generation.test.js new file mode 100644 index 000000000..a37ad7751 --- /dev/null +++ b/tests/runner/lane-manifest-generation.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { generateLaneManifests, loadOrderedLaneManifest } from './lane-manifests.js'; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pairofcleats-lane-manifests-')); +const testsDir = path.join(tempRoot, 'tests'); +const runnerDir = path.join(testsDir, 'runner'); +const ciDir = path.join(testsDir, 'ci'); +const logDir = path.join(tempRoot, '.testLogs'); +fs.mkdirSync(runnerDir, { recursive: true }); +fs.mkdirSync(ciDir, { recursive: true }); +fs.mkdirSync(logDir, { recursive: true }); + +fs.writeFileSync( + path.join(runnerDir, 'lane-manifests.jsonc'), + `${JSON.stringify({ + orderedLanes: { + ci: { + durationBucket: 'ci', + targetMaxDurationSeconds: 60, + orderFile: 'tests/ci/ci.order.txt', + manifestFile: 'tests/ci/ci.manifest.json', + timingArtifactPaths: [ + '.testLogs/ci-testRunTimes.txt', + '.testLogs/ci-timings.json' + ] + } + } + }, null, 2)}\n`, + 'utf8' +); +fs.writeFileSync(path.join(ciDir, 'ci.order.txt'), 'alpha\nbeta\n', 'utf8'); +fs.writeFileSync(path.join(logDir, 'ci-testRunTimes.txt'), '23ms\talpha\n', 'utf8'); +fs.writeFileSync( + path.join(logDir, 'ci-timings.json'), + `${JSON.stringify({ + tests: [ + { id: 'beta', durationMs: 47 } + ] + }, null, 2)}\n`, + 'utf8' +); + +const generated = await generateLaneManifests({ root: tempRoot }); +assert.equal(generated.manifests.size, 1, 'expected generated manifest count'); + +const manifest = await loadOrderedLaneManifest({ + root: tempRoot, + lane: 'ci', + config: generated.config +}); + +assert.equal(manifest?.lane, 'ci'); +assert.equal(manifest?.durationBucket, 'ci'); +assert.equal(manifest?.targetMaxDurationSeconds, 60); +assert.deepEqual( + manifest?.tests?.map((entry) => entry.id), + ['alpha', 'beta'], + 'expected ordered ids preserved' +); +assert.equal(manifest?.tests?.[0]?.durationMs, 23, 'expected log-times duration annotation'); +assert.equal(manifest?.tests?.[1]?.durationMs, 47, 'expected timings artifact annotation'); +assert.deepEqual( + manifest?.timingArtifactPaths, + ['.testLogs/ci-testRunTimes.txt', '.testLogs/ci-timings.json'], + 'expected manifest to record timing artifact sources' +); + +console.log('lane manifest generation test passed'); diff --git a/tests/runner/lane-manifest-suite-taxonomy.test.js b/tests/runner/lane-manifest-suite-taxonomy.test.js new file mode 100644 index 000000000..099621b09 --- /dev/null +++ b/tests/runner/lane-manifest-suite-taxonomy.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + loadLaneManifestConfig, + loadOrderedLaneManifest +} from './lane-manifests.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const config = await loadLaneManifestConfig({ root: ROOT }); +const ciLiteManifest = await loadOrderedLaneManifest({ root: ROOT, lane: 'ci-lite', config }); +const ciLongManifest = await loadOrderedLaneManifest({ root: ROOT, lane: 'ci-long', config }); + +for (const manifest of [ciLiteManifest, ciLongManifest]) { + assert.ok(manifest?.suiteCategorySummary, `expected suiteCategorySummary for ${manifest?.lane || 'unknown lane'}`); + for (const entry of manifest.tests || []) { + assert.ok(entry.suiteCategory, `expected suiteCategory for ${entry.id}`); + assert.ok(entry.suiteCategoryReason, `expected suiteCategoryReason for ${entry.id}`); + } +} + +assert.ok((ciLiteManifest?.suiteCategorySummary?.meta || 0) > 0, 'expected ci-lite to include meta suites'); +assert.ok((ciLiteManifest?.suiteCategorySummary?.matrix || 0) > 0, 'expected ci-lite to include matrix suites'); +assert.ok((ciLongManifest?.suiteCategorySummary?.['heavy-runtime'] || 0) > 0, 'expected ci-long to include heavy-runtime suites'); +assert.ok((ciLongManifest?.suiteCategorySummary?.hero || 0) > 0, 'expected ci-long to include hero suites'); + +console.log('lane manifest suite taxonomy test passed'); diff --git a/tests/runner/lane-manifests.js b/tests/runner/lane-manifests.js new file mode 100644 index 000000000..73fb60a04 --- /dev/null +++ b/tests/runner/lane-manifests.js @@ -0,0 +1,234 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { isAbsolutePathNative } from '../../src/shared/file-paths.js'; +import { + buildSuiteCategorySummary, + inferSuiteCategory +} from './suite-taxonomy.js'; + +const DEFAULT_ORDERED_LANE_TARGETS = { + gate: 15, + 'ci-lite': 15, + ci: 60, + 'ci-long': 180 +}; + +const toRepoRelativePosix = (root, targetPath) => path.relative(root, targetPath).replace(/\\/g, '/'); + +const resolvePath = (root, filePath) => ( + isAbsolutePathNative(filePath) ? filePath : path.resolve(root, filePath) +); + +const readJsonc = async (filePath, fallback = {}) => { + try { + const raw = await fsPromises.readFile(filePath, 'utf8'); + const parsed = parseJsonc(raw); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch { + return fallback; + } +}; + +const parseOrderFile = async (filePath) => { + const raw = await fsPromises.readFile(filePath, 'utf8'); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +}; + +const parseLogTimesArtifact = async (filePath) => { + const raw = await fsPromises.readFile(filePath, 'utf8'); + const map = new Map(); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)ms\t(.+)$/); + if (!match) continue; + map.set(match[2], Number(match[1])); + } + return map; +}; + +const parseTimingsArtifact = async (filePath) => { + const parsed = JSON.parse(await fsPromises.readFile(filePath, 'utf8')); + const tests = Array.isArray(parsed?.tests) ? parsed.tests : []; + const map = new Map(); + for (const test of tests) { + const id = typeof test?.id === 'string' ? test.id.trim() : ''; + const durationMs = Number(test?.durationMs); + if (!id || !Number.isFinite(durationMs) || durationMs < 0) continue; + map.set(id, durationMs); + } + return map; +}; + +const loadTimingMaps = async ({ root, timingArtifactPaths = [] }) => { + const resolvedPaths = []; + const maps = []; + for (const rawPath of timingArtifactPaths) { + const trimmed = typeof rawPath === 'string' ? rawPath.trim() : ''; + if (!trimmed) continue; + const absolutePath = resolvePath(root, trimmed); + try { + await fsPromises.access(absolutePath); + } catch { + continue; + } + const parser = absolutePath.endsWith('.json') + ? parseTimingsArtifact + : parseLogTimesArtifact; + try { + maps.push(await parser(absolutePath)); + resolvedPaths.push(absolutePath); + } catch {} + } + return { maps, resolvedPaths }; +}; + +const mergeTimingMaps = (maps) => { + const merged = new Map(); + for (const map of maps) { + for (const [id, durationMs] of map.entries()) { + if (!merged.has(id)) { + merged.set(id, durationMs); + } + } + } + return merged; +}; + +const compileOrderedLaneConfig = ({ root, configPath, raw }) => { + const entries = new Map(); + const orderedLanes = raw?.orderedLanes && typeof raw.orderedLanes === 'object' + ? raw.orderedLanes + : {}; + for (const [lane, value] of Object.entries(orderedLanes)) { + if (!value || typeof value !== 'object') continue; + const orderFile = typeof value.orderFile === 'string' ? value.orderFile.trim() : ''; + const manifestFile = typeof value.manifestFile === 'string' ? value.manifestFile.trim() : ''; + if (!lane || !orderFile || !manifestFile) continue; + entries.set(lane, { + lane, + durationBucket: typeof value.durationBucket === 'string' && value.durationBucket.trim() + ? value.durationBucket.trim() + : lane, + targetMaxDurationSeconds: Number.isFinite(Number(value.targetMaxDurationSeconds)) + ? Number(value.targetMaxDurationSeconds) + : (DEFAULT_ORDERED_LANE_TARGETS[lane] || null), + orderFilePath: resolvePath(root, orderFile), + manifestPath: resolvePath(root, manifestFile), + timingArtifactPaths: Array.isArray(value.timingArtifactPaths) + ? value.timingArtifactPaths.map((item) => String(item || '').trim()).filter(Boolean) + : [], + notes: typeof value.notes === 'string' ? value.notes.trim() : '', + configPath + }); + } + return { + configPath, + orderedLanes: entries + }; +}; + +export const loadLaneManifestConfig = async ({ root, configPath } = {}) => { + const resolvedConfigPath = configPath + ? resolvePath(root || process.cwd(), configPath) + : path.join(root || process.cwd(), 'tests', 'runner', 'lane-manifests.jsonc'); + const raw = await readJsonc(resolvedConfigPath, {}); + return compileOrderedLaneConfig({ + root: root || process.cwd(), + configPath: resolvedConfigPath, + raw + }); +}; + +export const buildOrderedLaneManifest = async ({ root, laneConfig }) => { + const orderIds = await parseOrderFile(laneConfig.orderFilePath); + const { maps, resolvedPaths } = await loadTimingMaps({ + root, + timingArtifactPaths: laneConfig.timingArtifactPaths + }); + const mergedTimings = mergeTimingMaps(maps); + const tests = orderIds.map((id, index) => { + const durationMs = mergedTimings.get(id); + const suiteCategory = inferSuiteCategory({ id, lane: laneConfig.lane }); + return { + id, + order: index + 1, + suiteCategory: suiteCategory.category, + suiteCategoryReason: suiteCategory.reason, + ...(Number.isFinite(durationMs) ? { durationMs } : {}) + }; + }); + return { + schemaVersion: 1, + lane: laneConfig.lane, + selectionMode: 'ordered-manifest', + durationBucket: laneConfig.durationBucket, + targetMaxDurationSeconds: laneConfig.targetMaxDurationSeconds, + notes: laneConfig.notes || '', + sourceOrderFile: toRepoRelativePosix(root, laneConfig.orderFilePath), + sourceConfigFile: toRepoRelativePosix(root, laneConfig.configPath), + timingArtifactPaths: resolvedPaths.map((item) => toRepoRelativePosix(root, item)), + suiteCategorySummary: buildSuiteCategorySummary(tests), + tests + }; +}; + +export const writeOrderedLaneManifest = async ({ manifest, outputPath }) => { + await fsPromises.mkdir(path.dirname(outputPath), { recursive: true }); + await fsPromises.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +}; + +export const generateLaneManifests = async ({ root, configPath } = {}) => { + const resolvedRoot = root || process.cwd(); + const config = await loadLaneManifestConfig({ root: resolvedRoot, configPath }); + const manifests = new Map(); + for (const laneConfig of config.orderedLanes.values()) { + const manifest = await buildOrderedLaneManifest({ + root: resolvedRoot, + laneConfig + }); + await writeOrderedLaneManifest({ + manifest, + outputPath: laneConfig.manifestPath + }); + manifests.set(laneConfig.lane, { + ...manifest, + manifestPath: laneConfig.manifestPath + }); + } + return { + config, + manifests + }; +}; + +export const loadOrderedLaneManifest = async ({ root, lane, config }) => { + const resolvedRoot = root || process.cwd(); + const manifestConfig = config || await loadLaneManifestConfig({ root: resolvedRoot }); + const laneConfig = manifestConfig.orderedLanes.get(lane); + if (!laneConfig) return null; + const parsed = JSON.parse(await fsPromises.readFile(laneConfig.manifestPath, 'utf8')); + return { + ...parsed, + manifestPath: laneConfig.manifestPath + }; +}; + +export const loadOrderedLaneManifests = async ({ root, config } = {}) => { + const resolvedRoot = root || process.cwd(); + const manifestConfig = config || await loadLaneManifestConfig({ root: resolvedRoot }); + const manifests = new Map(); + for (const lane of manifestConfig.orderedLanes.keys()) { + const manifest = await loadOrderedLaneManifest({ + root: resolvedRoot, + lane, + config: manifestConfig + }); + if (manifest) manifests.set(lane, manifest); + } + return manifests; +}; diff --git a/tests/runner/lane-manifests.jsonc b/tests/runner/lane-manifests.jsonc new file mode 100644 index 000000000..3bb1db8ef --- /dev/null +++ b/tests/runner/lane-manifests.jsonc @@ -0,0 +1,52 @@ +{ + "orderedLanes": { + "gate": { + "durationBucket": "gate", + "targetMaxDurationSeconds": 15, + "orderFile": "tests/gate/gate.order.txt", + "manifestFile": "tests/gate/gate.manifest.json", + "timingArtifactPaths": [ + ".testLogs/gate-testRunTimes.txt" + ], + "notes": "Generated from the gate lane order source; optional timing artifacts annotate entries when present." + }, + "ci-lite": { + "durationBucket": "ci-lite", + "targetMaxDurationSeconds": 15, + "orderFile": "tests/ci-lite/ci-lite.order.txt", + "manifestFile": "tests/ci-lite/ci-lite.manifest.json", + "timingArtifactPaths": [ + ".testLogs/ci-lite-testRunTimes.txt" + ], + "notes": "Duration-bucket manifest for the contributor default lane." + }, + "ci": { + "durationBucket": "ci", + "targetMaxDurationSeconds": 60, + "orderFile": "tests/ci/ci.order.txt", + "manifestFile": "tests/ci/ci.manifest.json", + "timingArtifactPaths": [ + ".testLogs/ci-testRunTimes.txt" + ], + "notes": "Duration-bucket manifest for the medium integration lane." + }, + "ci-long": { + "durationBucket": "ci-long", + "targetMaxDurationSeconds": 180, + "orderFile": "tests/ci-long/ci-long.order.txt", + "manifestFile": "tests/ci-long/ci-long.manifest.json", + "timingArtifactPaths": [ + ".testLogs/ci-long-testRunTimes.txt" + ], + "notes": "Duration-bucket manifest for the long lane." + }, + "usr-full-conformance": { + "durationBucket": "ci", + "targetMaxDurationSeconds": 60, + "orderFile": "tests/usr-full-conformance/usr-full-conformance.order.txt", + "manifestFile": "tests/usr-full-conformance/usr-full-conformance.manifest.json", + "timingArtifactPaths": [], + "notes": "Ordered USR all-language conformance surface lane." + } + } +} diff --git a/tests/runner/list-json-lane-explanation.test.js b/tests/runner/list-json-lane-explanation.test.js new file mode 100644 index 000000000..883261d27 --- /dev/null +++ b/tests/runner/list-json-lane-explanation.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runNode } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const runnerPath = path.join(ROOT, 'tests', 'run.js'); +const env = applyTestEnv({ syncProcess: false }); + +const result = runNode([runnerPath, '--lane', 'ci-lite', '--list', '--json'], 'ci-lite list json lane explanation', ROOT, env, { + stdio: 'pipe' +}); + +assert.equal(result.status, 0, `expected ci-lite list to succeed, got ${result.status}`); + +const payload = JSON.parse(result.stdout || '{}'); +const tests = Array.isArray(payload?.tests) ? payload.tests : []; +assert.ok(tests.length > 0, 'expected ci-lite test selection'); + +const manifestSelected = tests.find((entry) => entry.selectionSource === 'ordered-manifest'); +assert.ok(manifestSelected, 'expected at least one manifest-selected test'); +assert.equal(manifestSelected.selectionLane, 'ci-lite', 'expected ci-lite selection lane explanation'); +assert.ok( + typeof manifestSelected.suiteCategory === 'string' && manifestSelected.suiteCategory.length > 0, + 'expected suite-category metadata for manifest-selected test' +); +assert.ok( + typeof manifestSelected.suiteCategoryReason === 'string' && manifestSelected.suiteCategoryReason.length > 0, + 'expected suite-category reason for manifest-selected test' +); +assert.ok( + String(manifestSelected.selectionDetail || '').endsWith('tests/ci-lite/ci-lite.manifest.json'), + `expected ci-lite manifest detail, got: ${String(manifestSelected.selectionDetail || '')}` +); +assert.ok( + typeof manifestSelected.laneSource === 'string' && manifestSelected.laneSource.length > 0, + 'expected lane-source explanation for selected test' +); +assert.ok( + payload?.suiteCategorySummary && typeof payload.suiteCategorySummary === 'object', + 'expected suite-category summary in list JSON payload' +); + +console.log('list json lane explanation test passed'); diff --git a/tests/runner/run-args.js b/tests/runner/run-args.js index 070537b9c..74dd174ca 100644 --- a/tests/runner/run-args.js +++ b/tests/runner/run-args.js @@ -27,10 +27,14 @@ export const parseArgs = () => { .option('fail-fast', { type: 'boolean', default: false }) .option('quiet', { type: 'boolean', default: false }) .option('json', { type: 'boolean', default: false }) + .option('report-file', { type: 'string', default: '' }) .option('junit', { type: 'string', default: '' }) .option('log-dir', { type: 'string', default: '' }) .option('log-times', { type: 'string', default: null }) .option('timings-file', { type: 'string', default: '' }) + .option('stability-file', { type: 'string', default: '' }) + .option('stability-history-dir', { type: 'string', default: '' }) + .option('stability-history-limit', { type: 'number', default: 12 }) .option('perf-budget-file', { type: 'string', default: '' }) .option('watchdog-ms', { type: 'number' }) .option('coverage', { type: 'string', default: '' }) diff --git a/tests/runner/run-config.js b/tests/runner/run-config.js index 51fe57ab0..2d07d14ce 100644 --- a/tests/runner/run-config.js +++ b/tests/runner/run-config.js @@ -1,6 +1,6 @@ import fsSync from 'node:fs'; import path from 'node:path'; -import { isAbsolutePathNative } from '../../src/shared/files.js'; +import { isAbsolutePathNative } from '../../src/shared/file-paths.js'; import { parse as parseJsonc } from 'jsonc-parser'; const readJsonc = (filePath, fallback = {}) => { diff --git a/tests/runner/run-discovery.js b/tests/runner/run-discovery.js index d735eb570..172f8bde8 100644 --- a/tests/runner/run-discovery.js +++ b/tests/runner/run-discovery.js @@ -1,7 +1,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { compileSafeRegex } from '../../src/shared/safe-regex.js'; -import { toPosix } from '../../src/shared/files.js'; +import { toPosix } from '../../src/shared/file-paths.js'; export const splitCsv = (values) => values .flatMap((value) => String(value).split(',')) @@ -21,6 +21,13 @@ const isExcludedFile = (relPath, excludedDirs, excludedFiles) => { return excludedFiles.has(base); }; +const TEST_FILE_SUFFIXES = Object.freeze(['.test.js', '.unit.js']); + +const resolveTestId = (relPath) => { + const suffix = TEST_FILE_SUFFIXES.find((candidate) => relPath.endsWith(candidate)); + return suffix ? relPath.slice(0, -suffix.length) : relPath; +}; + export const discoverTests = async ({ testsDir, excludedDirs, excludedFiles }) => { const results = []; const walk = async (dir, relDir) => { @@ -32,7 +39,7 @@ export const discoverTests = async ({ testsDir, excludedDirs, excludedFiles }) = await walk(path.join(dir, entry.name), relPath); continue; } - if (!entry.isFile() || !entry.name.endsWith('.test.js')) continue; + if (!entry.isFile() || !TEST_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue; if (isExcludedFile(relPath, excludedDirs, excludedFiles)) continue; results.push({ path: path.join(dir, entry.name), @@ -44,18 +51,31 @@ export const discoverTests = async ({ testsDir, excludedDirs, excludedFiles }) = results.sort((a, b) => a.relPath.localeCompare(b.relPath)); return results.map((entry) => ({ ...entry, - id: entry.relPath.replace(/\.test\.js$/, ''), + id: resolveTestId(entry.relPath), relPath: normalizeSegments(entry.relPath) })); }; -export const assignLane = (id, laneRules) => { +export const assignLaneWithReason = (id, laneRules) => { for (const rule of laneRules) { - if (rule.match.some((regex) => regex.test(id))) return rule.lane; + const matched = rule.match.find((regex) => regex.test(id)); + if (matched) { + return { + lane: rule.lane, + source: 'rule', + detail: matched.source || String(matched) + }; + } } - return 'integration'; + return { + lane: 'integration', + source: 'default', + detail: 'fallback:integration' + }; }; +export const assignLane = (id, laneRules) => assignLaneWithReason(id, laneRules).lane; + export const buildTags = (id, lane, tagRules) => { const tags = new Set([lane]); for (const rule of tagRules) { diff --git a/tests/runner/run-execution.js b/tests/runner/run-execution.js index b7ed6e3f7..95be9f73d 100644 --- a/tests/runner/run-execution.js +++ b/tests/runner/run-execution.js @@ -61,7 +61,20 @@ const resolveTestTimeout = ({ test, defaultTimeoutMs, overrides }) => { return Math.min(defaultTimeoutMs, override); }; -const writeLogFile = async ({ logDir, test, attempt, stdout, stderr, status, exitCode, signal, timedOut, skipReason, termination }) => { +const writeLogFile = async ({ + logDir, + test, + attempt, + stdout, + stderr, + status, + exitCode, + signal, + timedOut, + timeoutClass, + skipReason, + termination +}) => { if (!logDir) return ''; const safeId = sanitizeId(test.id); const filePath = path.join(logDir, `${safeId}.attempt-${attempt}.log`); @@ -73,6 +86,7 @@ const writeLogFile = async ({ logDir, test, attempt, stdout, stderr, status, exi `exit: ${exitCode ?? 'null'}`, `signal: ${signal ?? 'null'}`, `timedOut: ${timedOut ? 'true' : 'false'}`, + `timeoutClass: ${timeoutClass || ''}`, `skipReason: ${skipReason || ''}`, `termination: ${termination ? JSON.stringify(termination) : ''}`, '' @@ -104,6 +118,12 @@ const runTestOnce = async ({ const start = Date.now(); const args = [test.path, ...passThrough]; const testEnv = { ...env }; + if (!testEnv.PAIROFCLEATS_TEST_LANE && typeof test.lane === 'string' && test.lane.trim()) { + testEnv.PAIROFCLEATS_TEST_LANE = test.lane.trim(); + } + if (!testEnv.PAIROFCLEATS_TEST_ID && typeof test.id === 'string' && test.id.trim()) { + testEnv.PAIROFCLEATS_TEST_ID = test.id.trim(); + } if (!testEnv.PAIROFCLEATS_TEST_CACHE_SUFFIX) { testEnv.PAIROFCLEATS_TEST_CACHE_SUFFIX = sanitizeId(test.id); } @@ -235,6 +255,7 @@ const runTestWithRetries = async ({ exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, + timeoutClass: result.timeoutClass, skipReason: result.skipReason, termination: result.termination }); @@ -249,6 +270,42 @@ const runTestWithRetries = async ({ return normalizeResult({ ...(lastResult || { status: 'failed' }), attempts: attemptOffset + maxAttempts, logs }); }; +const createRetryRunOptions = ({ + test, + context, + activeChildren, + markActivity, + redoExitCodes = context.redoExitCodes, + attemptOffset = 0 +}) => ({ + test, + passThrough: context.passThrough, + env: context.baseEnv, + cwd: context.root, + timeoutMs: resolveTestTimeout({ + test, + defaultTimeoutMs: context.timeoutMs, + overrides: context.timeoutOverrides + }), + captureOutput: context.captureOutput, + retries: context.retries, + logDir: context.runLogDir, + timeoutGraceMs: context.timeoutGraceMs, + skipExitCode: context.skipExitCode, + maxOutputBytes: context.maxOutputBytes, + redoExitCodes, + attemptOffset, + onChildStart: (pid) => { + activeChildren.add(pid); + markActivity(); + }, + onChildStop: (pid) => { + activeChildren.delete(pid); + markActivity(); + }, + onActivity: markActivity +}); + export const runTests = async ({ selection, context, reportResult, reportDirect }) => { const results = new Array(selection.length); let failFastTriggered = false; @@ -279,107 +336,76 @@ export const runTests = async ({ selection, context, reportResult, reportDirect }, 500); if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref(); } - selection.forEach((test, index) => { - queue.add(async () => { - let result = null; - if (test.presetStatus === 'skipped') { - result = normalizeResult({ status: 'skipped', durationMs: 0, skipReason: test.skipReason || '' }); - } else if (context.failFast && failFastTriggered) { - result = normalizeResult({ status: 'skipped', durationMs: 0, skipReason: '' }); - } else { - if (context.initReporter?.start) { - context.initReporter.start(test); - } - result = await runTestWithRetries({ - test, - passThrough: context.passThrough, - env: context.baseEnv, - cwd: context.root, - timeoutMs: resolveTestTimeout({ + try { + selection.forEach((test, index) => { + queue.add(async () => { + let result = null; + if (test.presetStatus === 'skipped') { + result = normalizeResult({ status: 'skipped', durationMs: 0, skipReason: test.skipReason || '' }); + } else if (context.failFast && failFastTriggered) { + result = normalizeResult({ status: 'skipped', durationMs: 0, skipReason: '' }); + } else { + if (context.initReporter?.start) { + context.initReporter.start(test); + } + result = await runTestWithRetries(createRetryRunOptions({ test, - defaultTimeoutMs: context.timeoutMs, - overrides: context.timeoutOverrides - }), - captureOutput: context.captureOutput, - retries: context.retries, - logDir: context.runLogDir, - timeoutGraceMs: context.timeoutGraceMs, - skipExitCode: context.skipExitCode, - maxOutputBytes: context.maxOutputBytes, - redoExitCodes: context.redoExitCodes, - onChildStart: (pid) => { - activeChildren.add(pid); - markActivity(); - }, - onChildStop: (pid) => { - activeChildren.delete(pid); - markActivity(); - }, - onActivity: markActivity - }); - } - const fullResult = { ...test, ...normalizeResult(result) }; - if (fullResult.status === 'redo') { - redoQueue.push({ test, index, prior: fullResult }); - } - if (context.failFast && fullResult.status === 'failed') { - failFastTriggered = true; - } - results[index] = fullResult; - markActivity(); - if (reportResult) reportResult(fullResult, index); - }); - }); - await queue.onIdle(); - if (redoQueue.length) { - const redoRunner = new PQueue({ concurrency: context.jobs }); - redoQueue.forEach(({ test, index, prior }) => { - redoRunner.add(async () => { - if (context.initReporter?.start) { - context.initReporter.start(test, { label: 'REDO', labelMode: 'redo' }); + context, + activeChildren, + markActivity + })); } - const result = await runTestWithRetries({ - test, - passThrough: context.passThrough, - env: context.baseEnv, - cwd: context.root, - timeoutMs: resolveTestTimeout({ - test, - defaultTimeoutMs: context.timeoutMs, - overrides: context.timeoutOverrides - }), - captureOutput: context.captureOutput, - retries: context.retries, - logDir: context.runLogDir, - timeoutGraceMs: context.timeoutGraceMs, - skipExitCode: context.skipExitCode, - maxOutputBytes: context.maxOutputBytes, - redoExitCodes: null, - attemptOffset: prior.attempts, - onChildStart: (pid) => { - activeChildren.add(pid); - markActivity(); - }, - onChildStop: (pid) => { - activeChildren.delete(pid); - markActivity(); - }, - onActivity: markActivity - }); - const mergedLogs = [...(prior.logs || []), ...(result.logs || [])]; - const finalResult = { ...test, ...normalizeResult({ ...result, logs: mergedLogs }) }; - results[index] = finalResult; - markActivity(); - if (reportResult) { - reportResult(finalResult, index); + const fullResult = { ...test, ...normalizeResult(result) }; + if (fullResult.status === 'redo') { + redoQueue.push({ test, index, prior: fullResult }); } - if (reportDirect && reportDirect !== reportResult) { - reportDirect(finalResult); + if (context.failFast && fullResult.status === 'failed') { + failFastTriggered = true; } + results[index] = fullResult; + markActivity(); + if (reportResult) reportResult(fullResult, index); }); }); - await redoRunner.onIdle(); + await queue.onIdle(); + if (redoQueue.length) { + const redoRunner = new PQueue({ concurrency: context.jobs }); + redoQueue.forEach(({ test, index, prior }) => { + redoRunner.add(async () => { + if (context.initReporter?.start) { + context.initReporter.start(test, { label: 'REDO', labelMode: 'redo' }); + } + const result = await runTestWithRetries(createRetryRunOptions({ + test, + redoExitCodes: null, + attemptOffset: prior.attempts, + context, + activeChildren, + markActivity + })); + const mergedLogs = [...(prior.logs || []), ...(result.logs || [])]; + const finalResult = { ...test, ...normalizeResult({ ...result, logs: mergedLogs }) }; + results[index] = finalResult; + markActivity(); + if (reportResult) { + reportResult(finalResult, index); + } + if (reportDirect && reportDirect !== reportResult) { + reportDirect(finalResult); + } + }); + }); + await redoRunner.onIdle(); + } + return results; + } finally { + if (watchdogTimer) clearInterval(watchdogTimer); + if (activeChildren.size > 0) { + const reapTargets = Array.from(activeChildren); + activeChildren.clear(); + await Promise.allSettled( + reapTargets.map((pid) => killProcessTree(pid, { graceMs: context.timeoutGraceMs })) + ); + } } - if (watchdogTimer) clearInterval(watchdogTimer); - return results; }; diff --git a/tests/runner/run-formatting.js b/tests/runner/run-formatting.js index c84d7d9b1..12b090830 100644 --- a/tests/runner/run-formatting.js +++ b/tests/runner/run-formatting.js @@ -1,5 +1,5 @@ import path from 'node:path'; -import { isAbsolutePathNative, toPosix } from '../../src/shared/files.js'; +import { isAbsolutePathNative, toPosix } from '../../src/shared/file-paths.js'; import { ANSI, applyLineBackground as applyLineBackgroundRaw, diff --git a/tests/runner/run-reporting.js b/tests/runner/run-reporting.js index 826273a89..1831bef8f 100644 --- a/tests/runner/run-reporting.js +++ b/tests/runner/run-reporting.js @@ -305,9 +305,40 @@ const renderCapturedOutput = ({ context, result, mode }) => { consoleStream.write(`${applyLineBackground('', { useColor, columns: consoleStream.columns })}\n`); }; +const completeReportedRender = ({ context, result, render }) => { + if (context.initReporter) { + context.initReporter.complete(result.id, render); + } else { + render(); + } +}; + +const renderFailureResultBlock = ({ context, result, line }) => { + const { consoleStream, useColor, captureOutput, root } = context; + consoleStream.write(`${applyLineBackground(line, { + useColor, + columns: consoleStream.columns, + bg: ANSI.bgFailLine + })}\n`); + let wroteLog = false; + if (result.logs && result.logs.length) { + const logLine = formatLogLine(result.logs[result.logs.length - 1], { useColor, root }); + consoleStream.write(`${applyLineBackground(logLine, { + useColor, + columns: consoleStream.columns, + bg: ANSI.bgLogLine + })}\n`); + wroteLog = true; + } + if (captureOutput && !context.argv.json) { + renderCapturedOutput({ context, result, mode: 'failure' }); + } else if (wroteLog) { + consoleStream.write(`${applyLineBackground('', { useColor, columns: consoleStream.columns })}\n`); + } +}; + export const reportTestResult = ({ context, result }) => { const { consoleStream, useColor, showFailures, showPass, showSkip, captureOutput, root } = context; - const initReporter = context.initReporter; if (result.timedOut && showFailures) { const duration = formatDurationBadge(result.durationMs, { useColor, @@ -319,34 +350,12 @@ export const reportTestResult = ({ context, result }) => { }); const label = formatLabel('TIME', { useColor, mode: 'timeout' }); const gap = ' '; - const timeoutLine = `${label}${gap}${duration} ${result.id} - timeout`; + const timeoutClass = String(result.timeoutClass || '').trim(); + const timeoutLine = `${label}${gap}${duration} ${result.id} - timeout${timeoutClass ? ` (${timeoutClass})` : ''}`; const render = () => { - consoleStream.write(`${applyLineBackground(timeoutLine, { - useColor, - columns: consoleStream.columns, - bg: ANSI.bgFailLine - })}\n`); - let wroteLog = false; - if (result.logs && result.logs.length) { - const logLine = formatLogLine(result.logs[result.logs.length - 1], { useColor, root }); - consoleStream.write(`${applyLineBackground(logLine, { - useColor, - columns: consoleStream.columns, - bg: ANSI.bgLogLine - })}\n`); - wroteLog = true; - } - if (captureOutput && !context.argv.json) { - renderCapturedOutput({ context, result, mode: 'failure' }); - } else if (wroteLog) { - consoleStream.write(`${applyLineBackground('', { useColor, columns: consoleStream.columns })}\n`); - } + renderFailureResultBlock({ context, result, line: timeoutLine }); }; - if (initReporter) { - initReporter.complete(result.id, render); - } else { - render(); - } + completeReportedRender({ context, result, render }); } else if (result.status === 'redo' && showFailures) { const duration = formatDurationBadge(result.durationMs, { useColor }); const detail = formatFailure(result); @@ -361,11 +370,7 @@ export const reportTestResult = ({ context, result }) => { columns: consoleStream.columns })}\n`); }; - if (initReporter) { - initReporter.complete(result.id, render); - } else { - render(); - } + completeReportedRender({ context, result, render }); } else if (result.status === 'failed' && showFailures) { const duration = formatDurationBadge(result.durationMs, { useColor, bg: ANSI.bgFailLine }); const detail = formatFailure(result); @@ -374,32 +379,9 @@ export const reportTestResult = ({ context, result }) => { const gap = ' '; const failLine = `${label}${gap}${duration} ${result.id} ${detail}${attemptInfo}`; const render = () => { - consoleStream.write(`${applyLineBackground(failLine, { - useColor, - columns: consoleStream.columns, - bg: ANSI.bgFailLine - })}\n`); - let wroteLog = false; - if (result.logs && result.logs.length) { - const logLine = formatLogLine(result.logs[result.logs.length - 1], { useColor, root }); - consoleStream.write(`${applyLineBackground(logLine, { - useColor, - columns: consoleStream.columns, - bg: ANSI.bgLogLine - })}\n`); - wroteLog = true; - } - if (captureOutput && !context.argv.json) { - renderCapturedOutput({ context, result, mode: 'failure' }); - } else if (wroteLog) { - consoleStream.write(`${applyLineBackground('', { useColor, columns: consoleStream.columns })}\n`); - } + renderFailureResultBlock({ context, result, line: failLine }); }; - if (initReporter) { - initReporter.complete(result.id, render); - } else { - render(); - } + completeReportedRender({ context, result, render }); } else if (result.status === 'passed' && showPass) { const duration = formatDurationBadge(result.durationMs, { useColor }); const label = formatLabel('PASS', { useColor, mode: 'pass' }); @@ -412,11 +394,7 @@ export const reportTestResult = ({ context, result }) => { renderCapturedOutput({ context, result, mode: 'success' }); } }; - if (initReporter) { - initReporter.complete(result.id, render); - } else { - render(); - } + completeReportedRender({ context, result, render }); } else if (result.status === 'skipped' && showSkip) { const reason = formatSkipReason(result.skipReason, { useColor }); const label = formatLabel('SKIP', { useColor, mode: 'skip' }); @@ -427,11 +405,7 @@ export const reportTestResult = ({ context, result }) => { const render = () => { consoleStream.write(`${applyLineBackground(skipLine, { useColor, columns: consoleStream.columns })}\n`); }; - if (initReporter) { - initReporter.complete(result.id, render); - } else { - render(); - } + completeReportedRender({ context, result, render }); } }; @@ -562,10 +536,25 @@ export const renderSummary = ({ context, summary, results, runLogDir, border, in consoleStream.write(`${applyLineBackground(timeoutHeader, summaryBg)}\n`); for (const timeout of timeouts) { const bullet = useColor ? `${ANSI.fgBrightWhite}- ${ANSI.reset}` : '- '; - const timeoutLine = `${itemIndent}${bullet}${timeout.id} ${formatDurationBadge(timeout.durationMs, { useColor })}`; + const timeoutClass = String(timeout.timeoutClass || '').trim(); + const timeoutLine = `${itemIndent}${bullet}${timeout.id} ${formatDurationBadge(timeout.durationMs, { useColor })}${timeoutClass ? ` (${timeoutClass})` : ''}`; const coloredLine = useColor ? `${ANSI.fgDarkOrange}${timeoutLine}${ANSI.reset}` : timeoutLine; consoleStream.write(`${applyLineBackground(coloredLine, summaryBg)}\n`); } + const timeoutClassCounts = new Map(); + for (const timeout of timeouts) { + const key = String(timeout.timeoutClass || 'timed_out_no_pass_signal'); + timeoutClassCounts.set(key, (timeoutClassCounts.get(key) || 0) + 1); + } + const breakdown = Array.from(timeoutClassCounts.entries()) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))) + .map(([key, count]) => `${key}=${count}`) + .join(', '); + if (breakdown) { + const infoLine = `${itemIndent}${useColor ? `${ANSI.fgBrightWhite}- ${ANSI.reset}` : '- '}classes: ${breakdown}`; + const coloredInfoLine = useColor ? `${ANSI.fgDarkGray}${infoLine}${ANSI.reset}` : infoLine; + consoleStream.write(`${applyLineBackground(coloredInfoLine, summaryBg)}\n`); + } consoleStream.write(`${applyLineBackground('', summaryBg)}\n`); } if (failedOnly.length) { @@ -682,6 +671,7 @@ export const buildJsonReport = ({ summary, results, root, runLogDir, junitPath } exitCode: result.exitCode ?? null, signal: result.signal ?? null, timedOut: result.timedOut ?? false, + timeoutClass: result.timeoutClass || null, skipReason: result.skipReason || null, termination: result.termination || null, logs: result.logs || [] diff --git a/tests/runner/run-results.js b/tests/runner/run-results.js index f2a7d54ed..ac2fb6c16 100644 --- a/tests/runner/run-results.js +++ b/tests/runner/run-results.js @@ -1,16 +1,35 @@ const VALID_STATUSES = new Set(['passed', 'failed', 'skipped', 'redo']); +const TIMEOUT_PASS_SIGNAL_RX = /\b(?:test passed|contract ok|ok\.)\b/i; + +const classifyTimeout = ({ timedOut, exitCode, signal, stdout, stderr }) => { + if (!timedOut) return null; + const combined = `${stdout || ''}\n${stderr || ''}`; + const hasPassSignal = TIMEOUT_PASS_SIGNAL_RX.test(combined); + if (exitCode === 0 || hasPassSignal) return 'timed_out_after_pass'; + if (Number.isFinite(exitCode) || signal) return 'timed_out_with_failure'; + return 'timed_out_no_pass_signal'; +}; export const normalizeResult = (input = {}) => { const status = VALID_STATUSES.has(input.status) ? input.status : 'failed'; const timedOut = Boolean(input.timedOut); + const stdout = input.stdout || ''; + const stderr = input.stderr || ''; const normalized = { status: timedOut ? 'failed' : status, exitCode: Number.isFinite(input.exitCode) ? input.exitCode : null, signal: input.signal || null, timedOut, + timeoutClass: classifyTimeout({ + timedOut, + exitCode: Number.isFinite(input.exitCode) ? input.exitCode : null, + signal: input.signal || null, + stdout, + stderr + }), durationMs: Number.isFinite(input.durationMs) ? input.durationMs : 0, - stdout: input.stdout || '', - stderr: input.stderr || '', + stdout, + stderr, skipReason: input.skipReason || '', termination: input.termination || null, attempts: Number.isFinite(input.attempts) ? input.attempts : 1, @@ -39,7 +58,10 @@ export const summarizeResults = (results, totalMs) => { }; export const formatFailure = (result) => { - if (result.timedOut) return 'timeout'; + if (result.timedOut) { + const timeoutClass = String(result.timeoutClass || '').trim(); + return timeoutClass ? `timeout (${timeoutClass})` : 'timeout'; + } if (result.signal) return `signal ${result.signal}`; if (Number.isFinite(result.exitCode)) return `exit ${result.exitCode}`; return 'failed'; diff --git a/tests/runner/run-stability.js b/tests/runner/run-stability.js new file mode 100644 index 000000000..ac6fbfcdf --- /dev/null +++ b/tests/runner/run-stability.js @@ -0,0 +1,378 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { normalizePathForRepo } from '../../src/shared/path-normalize.js'; +import { inferSuiteCategory } from './suite-taxonomy.js'; + +const DEFAULT_HISTORY_LIMIT = 12; +const SLOW_WARN_FRACTION = 0.5; +const SLOW_CRITICAL_FRACTION = 0.8; + +const toRoundedMs = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Number(numeric.toFixed(3)); +}; + +const toOutcomeClass = (result) => { + if (result?.status === 'redo') return 'redo'; + if (result?.timedOut) return 'timed_out'; + if (result?.status === 'passed') return 'passed'; + if (result?.status === 'skipped') return 'skipped'; + return 'failed'; +}; + +const isFailureLike = (outcomeClass) => ( + outcomeClass === 'failed' || outcomeClass === 'timed_out' || outcomeClass === 'redo' +); + +const deriveFamilyId = (result) => { + const source = String(result?.relPath || result?.path || result?.id || '') + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + const segments = source.split('/').filter(Boolean); + if (segments.length >= 2) return `${segments[0]}/${segments[1]}`; + if (segments.length === 1) return segments[0].replace(/\.test\.js$/, ''); + return 'misc'; +}; + +const buildEnvironmentFingerprint = ({ laneLabel, baseEnv = process.env } = {}) => { + const suiteModeRaw = String( + baseEnv?.PAIROFCLEATS_SUITE_MODE + || process.env.PAIROFCLEATS_SUITE_MODE + || '' + ).trim(); + const suiteMode = suiteModeRaw || null; + const ci = Boolean(baseEnv?.CI || baseEnv?.GITHUB_ACTIONS || process.env.CI || process.env.GITHUB_ACTIONS); + const parts = [ + process.platform, + process.arch, + process.version, + ci ? 'ci' : 'local', + laneLabel || '', + suiteMode || '' + ].filter(Boolean); + return { + fingerprint: parts.join('|'), + platform: process.platform, + arch: process.arch, + node: process.version, + ci, + suiteMode + }; +}; + +const compareByGeneratedAtDesc = (a, b) => { + const timeA = Date.parse(a?.generatedAt || '') || 0; + const timeB = Date.parse(b?.generatedAt || '') || 0; + return timeB - timeA; +}; + +export const loadStabilityHistory = async ({ historyDir, historyLimit = DEFAULT_HISTORY_LIMIT }) => { + const limit = Number.isFinite(Number(historyLimit)) + ? Math.max(1, Math.floor(Number(historyLimit))) + : DEFAULT_HISTORY_LIMIT; + if (!historyDir) { + return { + sourceDir: null, + historyLimit: limit, + artifacts: [] + }; + } + let entries = []; + try { + entries = await fsPromises.readdir(historyDir, { withFileTypes: true }); + } catch { + return { + sourceDir: historyDir, + historyLimit: limit, + artifacts: [] + }; + } + const artifacts = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const filePath = path.join(historyDir, entry.name); + try { + const parsed = JSON.parse(await fsPromises.readFile(filePath, 'utf8')); + if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed?.tests)) continue; + artifacts.push(parsed); + } catch {} + } + artifacts.sort(compareByGeneratedAtDesc); + return { + sourceDir: historyDir, + historyLimit: limit, + artifacts: artifacts.slice(0, limit) + }; +}; + +const classifyHistory = ({ + currentOutcomeClass, + currentEnvironmentFingerprint, + timeoutBudgetMs, + currentDurationMs, + historyRows +}) => { + const outcomes = new Set(historyRows.map((row) => row.outcomeClass).filter(Boolean)); + outcomes.add(currentOutcomeClass); + const envs = new Set(historyRows.map((row) => row.environmentFingerprint).filter(Boolean)); + if (currentEnvironmentFingerprint) envs.add(currentEnvironmentFingerprint); + const envOutcomeMap = new Map(); + for (const row of historyRows) { + if (!row.environmentFingerprint) continue; + const set = envOutcomeMap.get(row.environmentFingerprint) || new Set(); + if (row.outcomeClass) set.add(row.outcomeClass); + envOutcomeMap.set(row.environmentFingerprint, set); + } + if (currentEnvironmentFingerprint) { + const set = envOutcomeMap.get(currentEnvironmentFingerprint) || new Set(); + set.add(currentOutcomeClass); + envOutcomeMap.set(currentEnvironmentFingerprint, set); + } + const currentSlow = Number.isFinite(timeoutBudgetMs) + && timeoutBudgetMs > 0 + && currentOutcomeClass === 'passed' + && currentDurationMs >= timeoutBudgetMs * SLOW_WARN_FRACTION; + const historicalDurations = historyRows + .map((row) => Number(row.durationMs)) + .filter((value) => Number.isFinite(value) && value >= 0); + const maxHistoricalDurationMs = historicalDurations.length ? Math.max(...historicalDurations) : 0; + const historicalSlow = Number.isFinite(timeoutBudgetMs) + && timeoutBudgetMs > 0 + && maxHistoricalDurationMs >= timeoutBudgetMs * SLOW_WARN_FRACTION; + const hasPass = outcomes.has('passed'); + const hasFailureLike = Array.from(outcomes).some(isFailureLike); + + let environmentSensitive = false; + if (envOutcomeMap.size >= 2 && hasPass && hasFailureLike) { + let sawPassOnly = false; + let sawFailureLike = false; + for (const outcomeSet of envOutcomeMap.values()) { + const hasEnvPass = outcomeSet.has('passed'); + const hasEnvFailure = Array.from(outcomeSet).some(isFailureLike); + if (hasEnvPass && !hasEnvFailure) sawPassOnly = true; + if (hasEnvFailure) sawFailureLike = true; + } + environmentSensitive = sawPassOnly && sawFailureLike; + } + + if (environmentSensitive) return 'environment-sensitive'; + if (hasPass && hasFailureLike) return 'flaky'; + if (currentSlow || historicalSlow) return 'slow'; + return 'stable'; +}; + +export const buildStabilityArtifact = ({ + results, + runId, + root, + laneLabel, + retries = 0, + history, + timeoutResolver, + baseEnv, + diagnosticsGovernance +}) => { + const environment = buildEnvironmentFingerprint({ laneLabel, baseEnv }); + const historyArtifacts = Array.isArray(history?.artifacts) ? history.artifacts : []; + const governance = diagnosticsGovernance && typeof diagnosticsGovernance === 'object' + ? diagnosticsGovernance + : { + expectedNegativeStderrIds: new Set(), + diagnosticsClasses: ['clean', 'expected-negative-stderr', 'unexpected-stderr'], + retryPolicyBySuiteCategory: {} + }; + const historyById = new Map(); + for (const artifact of historyArtifacts) { + const previousTests = Array.isArray(artifact?.tests) ? artifact.tests : []; + for (const row of previousTests) { + const id = typeof row?.id === 'string' ? row.id.trim() : ''; + if (!id) continue; + const list = historyById.get(id) || []; + list.push({ + outcomeClass: String(row?.outcomeClass || '').trim(), + durationMs: Number(row?.durationMs), + environmentFingerprint: String(row?.environmentFingerprint || artifact?.environment?.fingerprint || '').trim() + }); + historyById.set(id, list); + } + } + + const tests = results + .slice() + .sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))) + .map((result) => { + const timeoutBudgetMs = Number(timeoutResolver?.(result)) || 0; + const outcomeClass = toOutcomeClass(result); + const historyRows = historyById.get(result.id) || []; + const suiteCategory = String(result?.suiteCategory || '').trim() + || inferSuiteCategory({ id: result.id, lane: result.lane, tags: result.tags }).category; + const stderrText = String(result?.stderr || '').trim(); + const diagnosticsClass = stderrText.length === 0 + ? 'clean' + : (outcomeClass === 'passed' && governance.expectedNegativeStderrIds?.has?.(result.id) + ? 'expected-negative-stderr' + : 'unexpected-stderr'); + const stabilityClass = classifyHistory({ + currentOutcomeClass: outcomeClass, + currentEnvironmentFingerprint: environment.fingerprint, + timeoutBudgetMs, + currentDurationMs: Number(result.durationMs) || 0, + historyRows + }); + const historyOutcomes = Array.from(new Set( + historyRows.map((row) => row.outcomeClass).filter(Boolean) + )).sort((a, b) => a.localeCompare(b)); + const historyEnvironments = Array.from(new Set( + historyRows.map((row) => row.environmentFingerprint).filter(Boolean) + )).sort((a, b) => a.localeCompare(b)); + return { + id: result.id, + path: normalizePathForRepo(result.relPath || '', root, { stripDot: true }) || '', + lane: String(result.lane || ''), + family: deriveFamilyId(result), + suiteCategory, + status: String(result.status || ''), + durationMs: toRoundedMs(result.durationMs), + timeoutBudgetMs: toRoundedMs(timeoutBudgetMs), + stabilityClass, + diagnosticsClass, + outcomeClass, + historyWindow: historyRows.length, + historyOutcomes, + historyEnvironments, + environmentFingerprint: environment.fingerprint + }; + }); + + const familiesMap = new Map(); + for (const row of tests) { + const family = familiesMap.get(row.family) || { + id: row.family, + tests: 0, + unstable: 0, + flaky: 0, + slow: 0, + environmentSensitive: 0, + failed: 0, + timedOut: 0, + redo: 0, + totalDurationMs: 0, + maxDurationMs: 0 + }; + family.tests += 1; + if (row.stabilityClass !== 'stable') family.unstable += 1; + if (row.stabilityClass === 'flaky') family.flaky += 1; + if (row.stabilityClass === 'slow') family.slow += 1; + if (row.stabilityClass === 'environment-sensitive') family.environmentSensitive += 1; + if (row.outcomeClass === 'failed') family.failed += 1; + if (row.outcomeClass === 'timed_out') family.timedOut += 1; + if (row.outcomeClass === 'redo') family.redo += 1; + family.totalDurationMs += Number(row.durationMs) || 0; + family.maxDurationMs = Math.max(family.maxDurationMs, Number(row.durationMs) || 0); + familiesMap.set(row.family, family); + } + + const families = Array.from(familiesMap.values()) + .map((entry) => ({ + id: entry.id, + tests: entry.tests, + unstable: entry.unstable, + flaky: entry.flaky, + slow: entry.slow, + environmentSensitive: entry.environmentSensitive, + failed: entry.failed, + timedOut: entry.timedOut, + redo: entry.redo, + avgDurationMs: toRoundedMs(entry.tests ? entry.totalDurationMs / entry.tests : 0), + maxDurationMs: toRoundedMs(entry.maxDurationMs) + })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + runId, + pathPolicy: 'repo-relative-posix', + timeUnit: 'ms', + lane: laneLabel, + history: { + sourceDir: history?.sourceDir || null, + loadedArtifacts: historyArtifacts.length, + historyLimit: Number.isFinite(Number(history?.historyLimit)) + ? Math.max(1, Math.floor(Number(history.historyLimit))) + : DEFAULT_HISTORY_LIMIT + }, + environment, + policy: { + retry: { + runnerRetries: Math.max(0, Math.floor(Number(retries) || 0)), + automaticRetryEnabled: Math.max(0, Math.floor(Number(retries) || 0)) > 0, + note: 'Retries remain visible in the base test result and do not clear stability classifications.' + }, + quarantine: { + automaticQuarantine: false, + note: 'This artifact reports instability; quarantine decisions remain manual until a suite family proves chronically unstable.' + }, + escalation: { + flaky: 'owner-review-and-repeat-run', + slow: 'budget-review-shard-or-harness-reuse', + environmentSensitive: 'fingerprint-review-and-environment-normalization' + }, + retryBySuiteCategory: governance.retryPolicyBySuiteCategory || {} + }, + diagnostics: { + expectedNegativeStderrIds: Array.from(governance.expectedNegativeStderrIds || []).sort(), + diagnosticsClasses: Array.isArray(governance.diagnosticsClasses) + ? governance.diagnosticsClasses + : ['clean', 'expected-negative-stderr', 'unexpected-stderr'] + }, + summary: { + tests: tests.length, + unstable: tests.filter((row) => row.stabilityClass !== 'stable').length, + flaky: tests.filter((row) => row.stabilityClass === 'flaky').length, + slow: tests.filter((row) => row.stabilityClass === 'slow').length, + environmentSensitive: tests.filter((row) => row.stabilityClass === 'environment-sensitive').length, + failed: tests.filter((row) => row.outcomeClass === 'failed').length, + timedOut: tests.filter((row) => row.outcomeClass === 'timed_out').length, + redo: tests.filter((row) => row.outcomeClass === 'redo').length, + expectedNegativeStderr: tests.filter((row) => row.diagnosticsClass === 'expected-negative-stderr').length, + unexpectedStderr: tests.filter((row) => row.diagnosticsClass === 'unexpected-stderr').length + }, + suiteCategories: { + hero: tests.filter((row) => row.suiteCategory === 'hero').length, + matrix: tests.filter((row) => row.suiteCategory === 'matrix').length, + meta: tests.filter((row) => row.suiteCategory === 'meta').length, + soak: tests.filter((row) => row.suiteCategory === 'soak').length, + 'heavy-runtime': tests.filter((row) => row.suiteCategory === 'heavy-runtime').length + }, + familyTrends: families, + families, + tests + }; +}; + +export const writeStabilityArtifact = async ({ + artifactPath, + archiveDir, + artifact +}) => { + if (artifactPath) { + await fsPromises.mkdir(path.dirname(artifactPath), { recursive: true }); + await fsPromises.writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + } + if (archiveDir) { + await fsPromises.mkdir(archiveDir, { recursive: true }); + const archivePath = path.join( + archiveDir, + `${artifact.generatedAt.replace(/[:.]/g, '-')}-${artifact.runId}.json` + ); + await fsPromises.writeFile(archivePath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + } +}; + +export const stabilitySlowThresholdsForTests = Object.freeze({ + warnFraction: SLOW_WARN_FRACTION, + criticalFraction: SLOW_CRITICAL_FRACTION, + defaultHistoryLimit: DEFAULT_HISTORY_LIMIT +}); diff --git a/tests/runner/stability-classification.test.js b/tests/runner/stability-classification.test.js new file mode 100644 index 000000000..1ad4afcb7 --- /dev/null +++ b/tests/runner/stability-classification.test.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildStabilityArtifact, stabilitySlowThresholdsForTests } from './run-stability.js'; +import { repoRoot } from '../helpers/root.js'; +import { loadDiagnosticsGovernance } from './diagnostics-governance.js'; + +const ROOT = repoRoot(); +const currentFingerprint = `${process.platform}|${process.arch}|${process.version}|ci|ci-lite|ci`; +const diagnosticsGovernance = (await loadDiagnosticsGovernance({ root: ROOT })).payload; + +const makeHistoryArtifact = ({ fingerprint, rows }) => ({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + runId: `history-${fingerprint}`, + environment: { + fingerprint + }, + tests: rows.map((row) => ({ + ...row, + environmentFingerprint: fingerprint + })) +}); + +const artifact = buildStabilityArtifact({ + runId: 'run-current', + root: ROOT, + laneLabel: 'ci-lite', + retries: 1, + baseEnv: { + CI: '1', + PAIROFCLEATS_SUITE_MODE: 'ci' + }, + history: { + sourceDir: '.testLogs/stability-history/ci-lite', + historyLimit: 12, + artifacts: [ + makeHistoryArtifact({ + fingerprint: currentFingerprint, + rows: [ + { + id: 'services/api/flaky', + outcomeClass: 'failed', + durationMs: 120, + status: 'failed' + }, + { + id: 'services/api/slow', + outcomeClass: 'passed', + durationMs: 9000, + status: 'passed' + } + ] + }), + makeHistoryArtifact({ + fingerprint: 'linux|x64|v22|ci|ci-lite', + rows: [ + { + id: 'services/api/env-sensitive', + outcomeClass: 'passed', + durationMs: 150, + status: 'passed' + } + ] + }) + ] + }, + timeoutResolver: () => 15000, + results: [ + { + id: 'services/api/flaky', + relPath: 'services/api/flaky.test.js', + lane: 'api', + status: 'passed', + durationMs: 100, + timedOut: false + }, + { + id: 'services/api/slow', + relPath: 'services/api/slow.test.js', + lane: 'api', + status: 'passed', + durationMs: 7600, + timedOut: false + }, + { + id: 'services/api/env-sensitive', + relPath: 'services/api/env-sensitive.test.js', + lane: 'api', + status: 'failed', + durationMs: 100, + timedOut: false + }, + { + id: 'cli/error-contract', + relPath: 'cli/error-contract.test.js', + lane: 'ci-lite', + suiteCategory: 'hero', + status: 'passed', + durationMs: 80, + timedOut: false, + stderr: '[INVALID_REQUEST] expected contract' + } + ], + diagnosticsGovernance +}); + +const byId = new Map(artifact.tests.map((row) => [row.id, row])); + +assert.equal(byId.get('services/api/flaky')?.stabilityClass, 'flaky'); +assert.equal(byId.get('services/api/slow')?.stabilityClass, 'slow'); +assert.equal(byId.get('services/api/env-sensitive')?.stabilityClass, 'environment-sensitive'); +assert.equal(byId.get('cli/error-contract')?.diagnosticsClass, 'expected-negative-stderr'); +assert.equal( + byId.get('services/api/slow')?.timeoutBudgetMs, + 15000, + 'expected timeout budget to flow into slow classification' +); +assert.equal( + artifact.summary.unstable, + 3, + 'expected all three rows to classify as unstable in different ways' +); +assert.equal( + artifact.families.find((entry) => entry.id === 'services/api')?.tests, + 3, + 'expected family rollup to count all service API rows' +); +assert.equal(artifact.summary.expectedNegativeStderr, 1); +assert.ok(artifact.suiteCategories.hero >= 1, 'expected hero suite category count to be recorded'); +assert.equal(artifact.policy.retryBySuiteCategory.matrix.maxRetries, 1); +assert.equal(stabilitySlowThresholdsForTests.warnFraction, 0.5); + +console.log('stability classification test passed'); diff --git a/tests/runner/suite-taxonomy-report.js b/tests/runner/suite-taxonomy-report.js new file mode 100644 index 000000000..35a5ef795 --- /dev/null +++ b/tests/runner/suite-taxonomy-report.js @@ -0,0 +1,159 @@ +import path from 'node:path'; +import { discoverTests } from './run-discovery.js'; +import { loadRunRules } from './run-config.js'; +import { loadLaneManifestConfig, loadOrderedLaneManifest } from './lane-manifests.js'; +import { buildSuiteCategorySummary, inferSuiteCategory, TEST_SUITE_CATEGORIES } from './suite-taxonomy.js'; +import { loadConsolidationOwnership } from './consolidation-ownership.js'; +import { writeStableGeneratedJsonReport, writeTextIfChanged } from '../../tools/shared/generated-report.js'; + +const toPosix = (value) => String(value || '').replace(/\\/g, '/'); + +const PERIPHERAL_GROUPS = Object.freeze([ + { key: 'tooling/install', prefix: 'tooling/install/' }, + { key: 'tooling/vscode', prefix: 'tooling/vscode/' }, + { key: 'tooling/sublime', prefix: 'tooling/sublime/' }, + { key: 'tooling/config-inventory', prefix: 'tooling/config-inventory/' } +]); + +const toCategorySummary = (tests) => buildSuiteCategorySummary( + tests.map((test) => ({ suiteCategory: test.suiteCategory })) +); + +const countKnown = (value) => Number.isFinite(Number(value)) ? Number(value) : 0; + +export const buildSuiteTaxonomyReport = ({ + tests = [], + manifests = new Map(), + ownership = { suites: [] } +} = {}) => { + const peripheralGroups = PERIPHERAL_GROUPS.map((group) => { + const matching = tests.filter((test) => test.id.startsWith(group.prefix)); + return { + key: group.key, + totalTests: matching.length, + byCategory: toCategorySummary(matching) + }; + }); + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + summary: { + totalTests: tests.length, + byCategory: toCategorySummary(tests), + peripheralGroups: peripheralGroups.length, + ownershipSuites: ownership.suites.length, + replacementIds: ownership.suites.reduce((sum, entry) => sum + entry.replacementIds.length, 0) + }, + lanes: Array.from(manifests.entries()).map(([lane, manifest]) => ({ + lane, + totalTests: Array.isArray(manifest?.tests) ? manifest.tests.length : 0, + byCategory: manifest?.suiteCategorySummary || Object.fromEntries(TEST_SUITE_CATEGORIES.map((category) => [category, 0])) + })), + peripheralGroups, + ownership: { + suites: ownership.suites.map((entry) => ({ + id: entry.id, + suiteCategory: entry.suiteCategory, + coverageOwner: entry.coverageOwner, + replacementIds: entry.replacementIds, + overlapPolicy: entry.overlapPolicy, + matrixStrategy: entry.matrixStrategy, + processIsolationRequired: entry.processIsolationRequired + })) + } + }; +}; + +const renderMarkdown = ({ report, ownershipPath, root }) => { + const lines = [ + '# Suite Taxonomy Report', + '', + `Generated: ${report.generatedAt}`, + '', + '## Summary', + '', + ...Object.entries(report.summary.byCategory).map(([category, count]) => `- \`${category}\`: ${count}`), + `- ownership suites tracked: ${report.summary.ownershipSuites}`, + `- replacement ids tracked: ${report.summary.replacementIds}`, + '', + '## Lane Category Summary', + '' + ]; + for (const lane of report.lanes) { + lines.push(`- \`${lane.lane}\`: ${lane.totalTests} tests`); + for (const [category, count] of Object.entries(lane.byCategory)) { + if (!countKnown(count)) continue; + lines.push(` ${category}: ${count}`); + } + } + lines.push('', '## Peripheral Tooling Groups', ''); + for (const group of report.peripheralGroups) { + lines.push(`- \`${group.key}\`: ${group.totalTests} tests`); + for (const [category, count] of Object.entries(group.byCategory)) { + if (!countKnown(count)) continue; + lines.push(` ${category}: ${count}`); + } + } + lines.push('', '## Coverage Ownership', '', `- source: \`${toPosix(path.relative(root, ownershipPath))}\``, ''); + for (const entry of report.ownership.suites) { + lines.push(`- \`${entry.id}\` -> ${entry.coverageOwner}`); + lines.push(` replacements: ${entry.replacementIds.join(', ')}`); + lines.push(` overlap policy: ${entry.overlapPolicy}`); + lines.push(` matrix strategy: ${entry.matrixStrategy}`); + lines.push(` process isolation required: ${entry.processIsolationRequired ? 'yes' : 'no'}`); + } + lines.push(''); + return `${lines.join('\n')}\n`; +}; + +export const generateSuiteTaxonomyReport = async ({ + root = process.cwd(), + outputJsonPath = path.join(process.cwd(), 'docs', 'testing', 'suite-taxonomy.json'), + outputMarkdownPath = path.join(process.cwd(), 'docs', 'testing', 'suite-taxonomy.md') +} = {}) => { + const manifestConfig = await loadLaneManifestConfig({ root }); + const manifests = new Map(); + const laneById = new Map(); + for (const lane of manifestConfig.orderedLanes.keys()) { + const manifest = await loadOrderedLaneManifest({ root, lane, config: manifestConfig }); + if (!manifest) continue; + manifests.set(lane, manifest); + for (const entry of Array.isArray(manifest.tests) ? manifest.tests : []) { + const id = String(entry?.id || '').trim(); + if (!id || laneById.has(id)) continue; + laneById.set(id, lane); + } + } + + const runRules = loadRunRules({ root }); + const discovered = await discoverTests({ + testsDir: path.join(root, 'tests'), + excludedDirs: runRules.excludedDirs, + excludedFiles: runRules.excludedFiles + }); + const tests = discovered.map((test) => ({ + ...test, + suiteCategory: inferSuiteCategory({ id: test.id, lane: laneById.get(test.id) || '' }).category + })); + const ownership = await loadConsolidationOwnership({ root }); + const initialReport = buildSuiteTaxonomyReport({ + tests, + manifests, + ownership: ownership.payload + }); + + const report = await writeStableGeneratedJsonReport(outputJsonPath, initialReport); + await writeTextIfChanged( + outputMarkdownPath, + renderMarkdown({ report, ownershipPath: ownership.path, root }), + { encoding: 'utf8' } + ); + + return { + report, + outputJsonPath, + outputMarkdownPath, + ownershipPath: ownership.path + }; +}; diff --git a/tests/runner/suite-taxonomy-report.test.js b/tests/runner/suite-taxonomy-report.test.js new file mode 100644 index 000000000..0da65b295 --- /dev/null +++ b/tests/runner/suite-taxonomy-report.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildSuiteTaxonomyReport } from './suite-taxonomy-report.js'; + +const report = buildSuiteTaxonomyReport({ + tests: [ + { id: 'tooling/install/detect', suiteCategory: 'hero' }, + { id: 'tooling/vscode/workspace-matrix', suiteCategory: 'matrix' }, + { id: 'tooling/config-inventory/audit', suiteCategory: 'meta' }, + { id: 'services/soak/recovery', suiteCategory: 'soak' }, + { id: 'storage/sqlite/heavy-runtime-case', suiteCategory: 'heavy-runtime' } + ], + manifests: new Map([ + ['ci-lite', { tests: [{}, {}], suiteCategorySummary: { hero: 1, matrix: 1, meta: 0, soak: 0, 'heavy-runtime': 0 } }], + ['ci-long', { tests: [{}, {}], suiteCategorySummary: { hero: 0, matrix: 0, meta: 0, soak: 1, 'heavy-runtime': 1 } }] + ]), + ownership: { + suites: [ + { + id: 'lang/fixtures-sample/metadata-matrix', + suiteCategory: 'matrix', + coverageOwner: 'sample metadata matrix', + replacementIds: ['lang/fixtures-sample/python-metadata'], + overlapPolicy: 'parity', + matrixStrategy: 'shared-search-fixture', + processIsolationRequired: false + } + ] + } +}); + +assert.equal(report.summary.totalTests, 5); +assert.equal(report.summary.byCategory.hero, 1); +assert.equal(report.summary.byCategory['heavy-runtime'], 1); +assert.equal(report.peripheralGroups.find((entry) => entry.key === 'tooling/vscode')?.byCategory?.matrix, 1); +assert.equal(report.ownership.suites[0].replacementIds[0], 'lang/fixtures-sample/python-metadata'); + +console.log('suite taxonomy report test passed'); diff --git a/tests/runner/suite-taxonomy.js b/tests/runner/suite-taxonomy.js new file mode 100644 index 000000000..cfcaa2ecd --- /dev/null +++ b/tests/runner/suite-taxonomy.js @@ -0,0 +1,74 @@ +const META_PREFIXES = Object.freeze([ + 'ci/', + 'runner/', + 'tooling/ci/', + 'tooling/config-inventory/', + 'tooling/docs/', + 'tooling/script-coverage/' +]); + +const META_IDS = Object.freeze(new Set([ + 'tooling/editors/harness-coverage' +])); + +const HERO_PREFIXES = Object.freeze([ + 'smoke/', + 'tooling/install/', + 'tooling/vscode/', + 'tooling/sublime/' +]); + +export const TEST_SUITE_CATEGORIES = Object.freeze([ + 'hero', + 'matrix', + 'meta', + 'soak', + 'heavy-runtime' +]); + +const normalizeId = (id) => String(id || '').trim().replace(/\\/g, '/'); + +const testIdHasSegment = (id, segment) => id.split('/').includes(segment); + +export const inferSuiteCategory = ({ id, lane = '', tags = [] } = {}) => { + const normalizedId = normalizeId(id); + const normalizedLane = String(lane || '').trim(); + const normalizedTags = Array.isArray(tags) + ? tags.map((tag) => String(tag || '').trim()).filter(Boolean) + : []; + const baseName = normalizedId.split('/').pop() || ''; + + if (!normalizedId) { + return { category: 'hero', reason: 'default-empty-id' }; + } + if (normalizedId.startsWith('services/soak/')) { + return { category: 'soak', reason: 'services-soak-prefix' }; + } + if (baseName.includes('matrix')) { + return { category: 'matrix', reason: 'matrix-filename' }; + } + if (META_IDS.has(normalizedId) || META_PREFIXES.some((prefix) => normalizedId.startsWith(prefix))) { + return { category: 'meta', reason: 'meta-cohort-prefix' }; + } + if (HERO_PREFIXES.some((prefix) => normalizedId.startsWith(prefix))) { + return { category: 'hero', reason: 'peripheral-tooling-or-smoke-surface' }; + } + if ( + normalizedLane === 'ci-long' + || normalizedTags.includes('long') + || testIdHasSegment(normalizedId, 'heavy-runtime') + ) { + return { category: 'heavy-runtime', reason: 'long-lane-or-tag' }; + } + return { category: 'hero', reason: 'default-standalone-behavior' }; +}; + +export const buildSuiteCategorySummary = (tests) => { + const summary = Object.fromEntries(TEST_SUITE_CATEGORIES.map((category) => [category, 0])); + for (const test of Array.isArray(tests) ? tests : []) { + const category = String(test?.suiteCategory || '').trim(); + if (!summary[category] && summary[category] !== 0) continue; + summary[category] += 1; + } + return summary; +}; diff --git a/tests/runner/suite-taxonomy.test.js b/tests/runner/suite-taxonomy.test.js new file mode 100644 index 000000000..61b7834ea --- /dev/null +++ b/tests/runner/suite-taxonomy.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + TEST_SUITE_CATEGORIES, + buildSuiteCategorySummary, + inferSuiteCategory +} from './suite-taxonomy.js'; + +assert.deepEqual(TEST_SUITE_CATEGORIES, ['hero', 'matrix', 'meta', 'soak', 'heavy-runtime']); + +assert.deepEqual( + inferSuiteCategory({ id: 'services/soak/operational-recovery', lane: 'ci-long' }), + { category: 'soak', reason: 'services-soak-prefix' } +); +assert.deepEqual( + inferSuiteCategory({ id: 'tooling/lsp/dedicated-provider-bootstrap-matrix', lane: 'ci-lite' }), + { category: 'matrix', reason: 'matrix-filename' } +); +assert.deepEqual( + inferSuiteCategory({ id: 'tooling/ci/command-surface-audit', lane: 'ci-lite' }), + { category: 'meta', reason: 'meta-cohort-prefix' } +); +assert.deepEqual( + inferSuiteCategory({ id: 'storage/sqlite/wal-checkpoint', lane: 'ci-long', tags: ['ci-long', 'long'] }), + { category: 'heavy-runtime', reason: 'long-lane-or-tag' } +); +assert.deepEqual( + inferSuiteCategory({ id: 'tooling/vscode/integration-harness', lane: 'ci-lite' }), + { category: 'hero', reason: 'peripheral-tooling-surface' } +); + +const summary = buildSuiteCategorySummary([ + { suiteCategory: 'hero' }, + { suiteCategory: 'hero' }, + { suiteCategory: 'matrix' }, + { suiteCategory: 'meta' }, + { suiteCategory: 'soak' }, + { suiteCategory: 'heavy-runtime' } +]); +assert.deepEqual(summary, { + hero: 2, + matrix: 1, + meta: 1, + soak: 1, + 'heavy-runtime': 1 +}); + +console.log('suite taxonomy test passed'); diff --git a/tests/runner/test-runner.js b/tests/runner/test-runner.js index d50daf859..9f24b6d23 100644 --- a/tests/runner/test-runner.js +++ b/tests/runner/test-runner.js @@ -1,39 +1,43 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; + +import { runNode } from '../helpers/run-node.js'; const root = process.cwd(); const runner = path.join(root, 'tests', 'run.js'); +const smokeTarget = 'runner/harness/skip-target'; -const listResult = spawnSync(process.execPath, [runner, '--list', '--json', '--lane', 'unit'], { - cwd: root, - encoding: 'utf8' +const listResult = runNode([runner, '--list', '--json', '--lane', 'unit'], 'runner unit list JSON', root, process.env, { + stdio: 'pipe', + allowFailure: true }); assert.equal(listResult.status, 0, `expected --list to succeed, got ${listResult.status}`); const payload = JSON.parse(listResult.stdout.trim() || '{}'); assert(Array.isArray(payload.tests), 'expected JSON list to include tests'); const ids = payload.tests.map((test) => test.id); -assert(ids.includes('test-runner'), 'expected test-runner in unit lane list'); +assert(ids.includes(smokeTarget), 'expected runner harness smoke target in unit lane list'); assert(!ids.includes('run'), 'runner entrypoint should be excluded from discovery'); +const selfEntry = payload.tests.find((test) => test.id === smokeTarget); +assert.equal(selfEntry?.suiteCategory, 'meta', 'expected runner smoke test to be classified as meta'); -const matchResult = spawnSync(process.execPath, [runner, '--list', '--match', 'test-runner'], { - cwd: root, - encoding: 'utf8' +const matchResult = runNode([runner, '--list', '--lane', 'unit', '--match', 'skip-target'], 'runner skip-target list', root, process.env, { + stdio: 'pipe', + allowFailure: true }); assert.equal(matchResult.status, 0, `expected --match list to succeed, got ${matchResult.status}`); const lines = matchResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); -assert(lines.includes('test-runner'), 'expected match list to include test-runner'); +assert(lines.includes(smokeTarget), 'expected match list to include runner harness target'); -const badLane = spawnSync(process.execPath, [runner, '--lane', 'nope'], { - cwd: root, - encoding: 'utf8' +const badLane = runNode([runner, '--lane', 'nope'], 'runner bad lane failure', root, process.env, { + stdio: 'pipe', + allowFailure: true }); assert.equal(badLane.status, 2, `expected unknown lane to exit 2, got ${badLane.status}`); -const emptyMatch = spawnSync(process.execPath, [runner, '--list', '--match', 'does-not-exist'], { - cwd: root, - encoding: 'utf8' +const emptyMatch = runNode([runner, '--list', '--match', 'does-not-exist'], 'runner empty match failure', root, process.env, { + stdio: 'pipe', + allowFailure: true }); assert.equal(emptyMatch.status, 2, `expected empty selection to exit 2, got ${emptyMatch.status}`); diff --git a/tests/services/advanced-surface-goldens.test.js b/tests/services/advanced-surface-goldens.test.js new file mode 100644 index 000000000..334a178c3 --- /dev/null +++ b/tests/services/advanced-surface-goldens.test.js @@ -0,0 +1,429 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { URLSearchParams, fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; +import { computeIndexDiff } from '../../src/index/diffs/compute.js'; +import { stableStringify } from '../../src/shared/stable-json.js'; +import { replaceDir } from '../../src/shared/json-stream/atomic.js'; +import { createBaseIndex } from '../indexing/validate/helpers.js'; +import { buildPerRepoArgsFromRequest } from '../../src/retrieval/federation/args.js'; +import { runFederatedSearch } from '../../src/retrieval/federation/coordinator.js'; +import { createError, ERROR_CODES } from '../../src/shared/error-codes.js'; +import { buildSearchParams, buildSearchPayloadFromQuery } from '../../tools/api/router/search.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../tools/shared/dict-utils.js'; +import { startApiServer } from '../helpers/api-server.js'; +import { + createCodeBuildNoEmbeddingsEnv, + runStage2CodeNoSqliteBuild +} from '../helpers/build-index-fixture.js'; +import { writeFederatedWorkspaceConfig } from '../helpers/federated-api.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const root = process.cwd(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const goldenPath = path.join(__dirname, 'golden', 'advanced-surface-goldens.json'); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const tempRoot = resolveTestCachePath(root, 'advanced-surface-goldens'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const markerFile = 'src/phase14-advanced-surface.js'; + +const env = createCodeBuildNoEmbeddingsEnv({ cacheRoot }); + +const writeJson = async (filePath, value) => { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +}; + +const runBuild = () => runStage2CodeNoSqliteBuild({ root, repoRoot, env }); + +const normalizeSearchHit = (hit) => ({ + file: hit?.file || null, + start: Number(hit?.start ?? 0), + end: Number(hit?.end ?? 0), + repoAlias: hit?.repoAlias || null +}); + +const normalizeDiffRef = (value) => value + ? { + ref: value.ref || null, + snapshotId: value.snapshotId || null + } + : null; + +const normalizeDiffId = () => ''; + +const normalizeAsOfResponse = (response) => ({ + status: response.status, + body: { + ok: response.body?.ok === true, + result: { + asOf: { + ref: response.body?.result?.asOf?.ref || null + }, + code: Array.isArray(response.body?.result?.code) + ? response.body.result.code.map(normalizeSearchHit) + : [] + } + } +}); + +const normalizeDiffShowResponse = (response) => ({ + status: response.status, + body: { + ok: response.body?.ok === true, + diff: { + entry: { + id: normalizeDiffId(), + from: normalizeDiffRef(response.body?.diff?.entry?.from || null), + to: normalizeDiffRef(response.body?.diff?.entry?.to || null), + modes: Array.isArray(response.body?.diff?.entry?.modes) + ? response.body.diff.entry.modes.slice() + : [] + }, + summary: response.body?.diff?.summary + ? { + id: normalizeDiffId(), + from: normalizeDiffRef(response.body.diff.summary.from || null), + to: normalizeDiffRef(response.body.diff.summary.to || null), + modes: Array.isArray(response.body.diff.summary.modes) + ? response.body.diff.summary.modes.slice() + : [], + limits: response.body.diff.summary.limits || null, + totals: response.body.diff.summary.totals || null, + truncated: response.body.diff.summary.truncated === true + } + : null, + events: Array.isArray(response.body?.diff?.events) + ? response.body.diff.events.map((event) => ({ + kind: event?.kind || null, + file: event?.file || null, + chunkId: event?.chunkId || null + })) + : [] + } + } +}); + +const normalizeStreamedDiffEvents = (payload) => payload.map((event) => ({ + kind: event?.kind || null, + file: event?.file || null, + chunkId: event?.chunkId || null +})); + +const normalizeFederatedArgs = (args) => args.map((entry) => String(entry)); + +const normalizeFederatedResponse = (response) => ({ + ok: response?.ok === true, + backend: response?.backend || null, + meta: { + workspace: { + name: response?.meta?.workspace?.name || '', + workspaceId: '' + }, + selection: { + selectedRepos: Array.isArray(response?.meta?.selection?.selectedRepos) + ? response.meta.selection.selectedRepos.map((repo) => ({ + alias: repo?.alias || null, + priority: Number(repo?.priority || 0), + enabled: repo?.enabled !== false + })) + : [] + }, + limits: response?.meta?.limits || null + }, + code: Array.isArray(response?.code) ? response.code.map(normalizeSearchHit) : [], + repos: Array.isArray(response?.repos) + ? response.repos.map((entry) => ({ + status: entry?.status || null, + error: entry?.error + ? { + code: entry.error.code || null, + message: entry.error.message || null + } + : null + })) + : [], + warnings: Array.isArray(response?.warnings) ? response.warnings.slice() : [] +}); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); + +await fsPromises.writeFile(path.join(repoRoot, markerFile), 'export const phase14_marker = "phase14alpha";\n', 'utf8'); +runBuild(); + +const userConfig = loadUserConfig(repoRoot); +const snapshotA = 'snap-20260212000000-goldena'; +await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: snapshotA +}); + +await fsPromises.writeFile(path.join(repoRoot, markerFile), 'export const phase14_marker = "phase14beta";\n', 'utf8'); +runBuild(); + +const snapshotB = 'snap-20260212000000-goldenb'; +await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: snapshotB +}); + +const diff = await computeIndexDiff({ + repoRoot, + userConfig, + from: `snap:${snapshotA}`, + to: `snap:${snapshotB}`, + modes: ['code'], + includeRelations: false +}); + +const { serverInfo, requestJson, requestRaw, stop } = await startApiServer({ + repoRoot, + allowedRoots: [], + env +}); + +const asOfQueryString = `q=phase14beta&mode=code&top=50&asOf=${encodeURIComponent(`snap:${snapshotB}`)}`; +const asOfPayloadInfo = buildSearchPayloadFromQuery(new URLSearchParams(asOfQueryString)); +assert.deepEqual(asOfPayloadInfo.errors, [], 'expected as-of search query parsing to succeed'); +const asOfParams = buildSearchParams(repoRoot, asOfPayloadInfo.payload, 'json'); +assert.equal(asOfParams.ok, true, 'expected as-of search params to build'); +const asOfResponse = await requestJson('GET', `/search?${asOfQueryString}`, null, serverInfo); + +const diffShowPath = `/index/diffs/${diff.diffId}?format=jsonl&mode=code&kind=file.modified&max-events=1`; +const diffShowResponse = await requestJson('GET', diffShowPath, null, serverInfo); +const diffEventsPath = `/index/diffs/${diff.diffId}/events?mode=code&kind=file.modified&maxEvents=1`; +const diffEventsResponse = await requestRaw('GET', diffEventsPath, null, serverInfo); +await stop(); + +const diffEventLines = diffEventsResponse.body + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + +const federatedTempRoot = path.join(tempRoot, 'federated'); +const federatedCacheRoot = path.join(federatedTempRoot, 'cache'); +const repoA = path.join(federatedTempRoot, 'repo-a'); +const repoB = path.join(federatedTempRoot, 'repo-b'); +const workspacePath = path.join(federatedTempRoot, '.pairofcleats-workspace.jsonc'); + +const writeFederatedRepo = async (repoPath, alias) => { + await fsPromises.mkdir(repoPath, { recursive: true }); + const gitInit = spawnSync('git', ['init', '-q'], { + cwd: repoPath, + encoding: 'utf8' + }); + if (gitInit.status !== 0) { + throw new Error(`git init failed for ${repoPath}: ${gitInit.stderr || gitInit.stdout || 'unknown error'}`); + } + await fsPromises.writeFile(path.join(repoPath, '.pairofcleats.json'), JSON.stringify({ + cache: { root: federatedCacheRoot } + }, null, 2), 'utf8'); + const repoCache = getRepoCacheRoot(repoPath); + const buildRoot = path.join(repoCache, 'builds', 'test-build'); + await fsPromises.mkdir(path.join(repoCache, 'builds'), { recursive: true }); + await fsPromises.writeFile(path.join(repoCache, 'builds', 'current.json'), JSON.stringify({ + buildId: 'test-build', + buildRoot, + modes: ['code'] + }, null, 2), 'utf8'); + const { indexDir } = await createBaseIndex({ + rootDir: buildRoot, + chunkMeta: [ + { + id: 0, + file: `src/${alias}.js`, + start: 1, + end: 1 + } + ], + tokenPostings: { + vocab: ['risky', alias], + postings: [ + [[0, 1]], + [[0, 1]] + ], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + }, + indexState: { + generatedAt: '2026-02-12T00:10:00.000Z', + mode: 'code', + artifactSurfaceVersion: '0.2.0', + compatibilityKey: 'compat-code' + } + }); + await replaceDir(indexDir, path.join(buildRoot, 'index-code')); + await fsPromises.rm(path.join(buildRoot, '.index-root'), { recursive: true, force: true }); +}; + +await writeFederatedRepo(repoA, 'alpha'); +await writeFederatedRepo(repoB, 'beta'); +await writeFederatedWorkspaceConfig(workspacePath, { + schemaVersion: 1, + cacheRoot: federatedCacheRoot, + repos: [ + { root: repoA, alias: 'alpha', priority: 10, tags: ['team-a'] }, + { root: repoB, alias: 'beta', priority: 5, tags: ['team-b'] } + ] +}); + +const federatedWorkspaceRequest = { + workspacePath, + query: 'workspace-risk', + select: { + tag: ['team-a'] + }, + search: { + mode: 'code', + top: 3 + }, + limits: { + perRepoTop: 4, + concurrency: 1 + } +}; + +const federatedRiskRequest = { + workspacePath, + query: 'risky', + search: { + mode: 'code', + top: 3, + riskTag: 'security', + riskCategory: 'injection', + riskFlow: 'request.body->eval' + }, + limits: { + perRepoTop: 4, + concurrency: 1 + } +}; + +const federatedRiskDegradedRequest = { + ...federatedRiskRequest, + query: 'risky-degraded' +}; + +const federatedWorkspaceArgs = buildPerRepoArgsFromRequest({ + query: federatedWorkspaceRequest.query, + search: federatedWorkspaceRequest.search, + perRepoTop: federatedWorkspaceRequest.limits.perRepoTop +}); +const federatedRiskArgs = buildPerRepoArgsFromRequest({ + query: federatedRiskRequest.query, + search: federatedRiskRequest.search, + perRepoTop: federatedRiskRequest.limits.perRepoTop +}); +const federatedRiskDegradedArgs = buildPerRepoArgsFromRequest({ + query: federatedRiskDegradedRequest.query, + search: federatedRiskDegradedRequest.search, + perRepoTop: federatedRiskDegradedRequest.limits.perRepoTop +}); + +const federatedSearchFn = async (repoRootCanonical) => { + const alias = path.basename(repoRootCanonical) === 'repo-a' ? 'alpha' : 'beta'; + return { + backend: 'memory', + code: [ + { + id: `hit-${alias}`, + file: `src/${alias}.js`, + start: 1, + end: 1, + score: alias === 'alpha' ? 10 : 5 + } + ], + prose: [], + extractedProse: [], + records: [] + }; +}; + +const federatedWorkspaceResponse = await runFederatedSearch(federatedWorkspaceRequest, { + searchFn: federatedSearchFn +}); +const federatedRiskResponse = await runFederatedSearch(federatedRiskRequest, { + searchFn: federatedSearchFn +}); +const federatedRiskDegradedResponse = await runFederatedSearch(federatedRiskDegradedRequest, { + searchFn: async (repoRootCanonical) => { + if (path.basename(repoRootCanonical) === 'repo-a') { + throw createError(ERROR_CODES.NO_INDEX, 'simulated missing index'); + } + return await federatedSearchFn(repoRootCanonical); + } +}); + +const snapshot = { + apiSearchAsOf: { + request: { + queryString: asOfQueryString, + payload: asOfPayloadInfo.payload, + args: asOfParams.args + }, + response: normalizeAsOfResponse(asOfResponse) + }, + apiDiffFiltered: { + request: { + showPath: '/index/diffs/?format=jsonl&mode=code&kind=file.modified&max-events=1', + eventsPath: '/index/diffs//events?mode=code&kind=file.modified&maxEvents=1' + }, + response: normalizeDiffShowResponse(diffShowResponse), + streamedEvents: normalizeStreamedDiffEvents(diffEventLines) + }, + federatedWorkspace: { + request: { + payload: { + query: federatedWorkspaceRequest.query, + select: federatedWorkspaceRequest.select, + search: federatedWorkspaceRequest.search, + limits: federatedWorkspaceRequest.limits + }, + args: normalizeFederatedArgs(federatedWorkspaceArgs) + }, + response: normalizeFederatedResponse(federatedWorkspaceResponse) + }, + federatedRisk: { + request: { + payload: { + query: federatedRiskRequest.query, + search: federatedRiskRequest.search, + limits: federatedRiskRequest.limits + }, + args: normalizeFederatedArgs(federatedRiskArgs) + }, + successResponse: normalizeFederatedResponse(federatedRiskResponse), + degradedRequest: { + payload: { + query: federatedRiskDegradedRequest.query, + search: federatedRiskDegradedRequest.search, + limits: federatedRiskDegradedRequest.limits + }, + args: normalizeFederatedArgs(federatedRiskDegradedArgs) + }, + degradedResponse: normalizeFederatedResponse(federatedRiskDegradedResponse) + } +}; + +if (!fs.existsSync(goldenPath)) { + console.log(stableStringify(snapshot)); + process.exit(0); +} + +const expected = JSON.parse(fs.readFileSync(goldenPath, 'utf8')); +assert.deepEqual(snapshot, expected, 'advanced surface golden drift detected'); + +console.log('advanced surface goldens test passed'); diff --git a/tests/services/api-search-asof.test.js b/tests/services/api-search-asof.test.js index f05cd78fa..69a74d23b 100644 --- a/tests/services/api-search-asof.test.js +++ b/tests/services/api-search-asof.test.js @@ -4,64 +4,108 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; import { computeIndexDiff } from '../../src/index/diffs/compute.js'; -import { loadUserConfig } from '../../tools/shared/dict-utils.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../tools/shared/dict-utils.js'; import { startApiServer } from '../helpers/api-server.js'; +import { + seedCodeSnapshotBuildRoot, + setCurrentCodeSnapshotBuild +} from '../helpers/snapshot-build-fixture.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'api-search-asof-service'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); const repoRoot = path.join(tempRoot, 'repo'); const cacheRoot = path.join(tempRoot, 'cache'); +const markerRelativePath = 'src/phase14-api-asof.js'; + +const apiAsofTestConfig = { + sqlite: { use: false }, + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + }, + tooling: { + lsp: { + enabled: false + } + } +}; await fs.rm(tempRoot, { recursive: true, force: true }); await fs.mkdir(tempRoot, { recursive: true }); -await fs.cp(fixtureRoot, repoRoot, { recursive: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.writeFile( + path.join(repoRoot, 'src', 'base.js'), + 'export function baseApiSearchAsofValue() { return "base"; }\n', + 'utf8' +); const env = applyTestEnv({ cacheRoot, embeddings: 'stub', - testConfig: { - indexing: { - embeddings: { - enabled: false, - mode: 'off', - lancedb: { enabled: false }, - hnsw: { enabled: false } - } - } - }, - extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } + testConfig: apiAsofTestConfig, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } }); -const runBuild = () => { - const result = spawnSync( - process.execPath, - [ - path.join(root, 'build_index.js'), - '--repo', - repoRoot, - '--mode', - 'code', - '--stub-embeddings', - '--no-sqlite', - '--progress', - 'off' - ], +const seedSearchAsofBuildRoot = async ({ + repoCacheRoot, + buildId, + token, + text, + end, + hash, + size +}) => seedCodeSnapshotBuildRoot({ + repoCacheRoot, + buildId, + manifestOverrides: { + compatibilityKey: `api-search-asof:${buildId}` + }, + buildStateOverrides: { + configHash: 'cfg-api-search-asof' + }, + chunkMeta: [ { - cwd: repoRoot, - env, - encoding: 'utf8' + id: 0, + file: markerRelativePath, + start: 0, + end, + text, + tokens: [token] } - ); - if (result.status !== 0) { - throw new Error(`build_index failed: ${result.stderr || result.stdout || 'unknown error'}`); + ], + fileMeta: [ + { + id: 0, + file: markerRelativePath, + ext: '.js', + hash, + size + } + ], + tokenPostings: { + vocab: [token], + postings: [ + [[0, 1]] + ], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 } -}; +}); const requestText = (serverInfo, requestPath, authToken = 'test-token') => new Promise((resolve, reject) => { const req = http.request( @@ -88,12 +132,25 @@ const requestText = (serverInfo, requestPath, authToken = 'test-token') => new P req.end(); }); -const markerPath = path.join(repoRoot, 'src', 'phase14-api-asof.js'); +const markerPath = path.join(repoRoot, markerRelativePath); await fs.mkdir(path.dirname(markerPath), { recursive: true }); -await fs.writeFile(markerPath, 'export const phase14_api_marker = "phase14alpha";\n', 'utf8'); -runBuild(); +const textA = 'export const phase14_api_marker = "phase14alpha";\n'; +await fs.writeFile(markerPath, textA, 'utf8'); const userConfig = loadUserConfig(repoRoot); +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); +await seedSearchAsofBuildRoot({ + repoCacheRoot, + buildId: 'build-alpha', + token: 'phase14alpha', + text: textA, + end: textA.length, + hash: 'sha1-alpha', + size: textA.length +}); +await setCurrentCodeSnapshotBuild({ repoCacheRoot, buildId: 'build-alpha' }); + const snapshotA = 'snap-20260212000000-apiaa'; await createPointerSnapshot({ repoRoot, @@ -102,8 +159,18 @@ await createPointerSnapshot({ snapshotId: snapshotA }); -await fs.writeFile(markerPath, 'export const phase14_api_marker = "phase14beta";\n', 'utf8'); -runBuild(); +const textB = 'export const phase14_api_marker = "phase14beta";\nexport const phase14_api_version = 2;\n'; +await fs.writeFile(markerPath, textB, 'utf8'); +await seedSearchAsofBuildRoot({ + repoCacheRoot, + buildId: 'build-beta', + token: 'phase14beta', + text: textB, + end: textB.length, + hash: 'sha1-beta', + size: textB.length +}); +await setCurrentCodeSnapshotBuild({ repoCacheRoot, buildId: 'build-beta' }); const snapshotB = 'snap-20260212000000-apibb'; await createPointerSnapshot({ @@ -145,7 +212,7 @@ try { const responseB = await requestJson( 'GET', - `/search?q=phase14alpha&mode=code&top=50&asOf=${encodeURIComponent(`snap:${snapshotB}`)}`, + `/search?q=phase14beta&mode=code&top=50&asOf=${encodeURIComponent(`snap:${snapshotB}`)}`, null, serverInfo ); @@ -153,6 +220,19 @@ try { assert.equal(responseB.body?.ok, true); assert.equal(responseB.body?.result?.asOf?.ref, `snap:${snapshotB}`); + const responseBAlpha = await requestJson( + 'GET', + `/search?q=phase14alpha&mode=code&top=50&asOf=${encodeURIComponent(`snap:${snapshotB}`)}`, + null, + serverInfo + ); + assert.equal(responseBAlpha.status, 200); + assert.equal(responseBAlpha.body?.ok, true); + const staleHitB = Array.isArray(responseBAlpha.body?.result?.code) + ? responseBAlpha.body.result.code.find((hit) => String(hit.file || '').includes('phase14-api-asof.js')) + : null; + assert.ok(!staleHitB, 'snapshot B should not match stale alpha marker text'); + const hitB = Array.isArray(responseB.body?.result?.code) ? responseB.body.result.code.find((hit) => String(hit.file || '').includes('phase14-api-asof.js')) : null; diff --git a/tests/services/api/analysis-body-error-classification.test.js b/tests/services/api/analysis-body-error-classification.test.js new file mode 100644 index 000000000..4d69650ee --- /dev/null +++ b/tests/services/api/analysis-body-error-classification.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + analysisErrorRoutes, + createAnalysisErrorResponseCapture +} from './analysis-error-classification-fixture.js'; + +const cases = [ + { + name: 'risk explain malformed json returns 400', + ...analysisErrorRoutes.riskExplain, + expectedStatus: 400, + errorCode: null + }, + { + name: 'context pack unsupported media type returns 415', + ...analysisErrorRoutes.contextPack, + expectedStatus: 415, + errorCode: 'ERR_UNSUPPORTED_MEDIA_TYPE' + }, + { + name: 'risk delta oversized body returns 413', + ...analysisErrorRoutes.riskDelta, + expectedStatus: 413, + errorCode: 'ERR_BODY_TOO_LARGE' + } +]; + +for (const testCase of cases) { + const { capture, response } = createAnalysisErrorResponseCapture(); + let resolveRepoCalled = false; + const err = new Error(`${testCase.name} parse failure`); + if (testCase.errorCode) err.code = testCase.errorCode; + + const handled = await testCase.handler({ + req: {}, + res: response, + corsHeaders: {}, + observability: null, + parseJsonBody: async () => { + throw err; + }, + resolveRepo: async () => { + resolveRepoCalled = true; + return 'unused'; + }, + ...testCase.routeArgs + }); + + assert.equal(handled, true, `expected route to handle ${testCase.name}`); + assert.equal(resolveRepoCalled, false, `expected parse failure to short-circuit repo resolution for ${testCase.name}`); + assert.equal(capture.statusCode, testCase.expectedStatus, `unexpected status for ${testCase.name}`); + + const body = JSON.parse(String(capture.body || '{}')); + assert.equal(body.ok, false, `expected API error envelope for ${testCase.name}`); + assert.equal(body.code, 'INVALID_REQUEST', `expected INVALID_REQUEST code for ${testCase.name}`); +} + +console.log('API analysis body error classification test passed'); diff --git a/tests/services/api/analysis-error-classification-fixture.js b/tests/services/api/analysis-error-classification-fixture.js new file mode 100644 index 000000000..c53d60716 --- /dev/null +++ b/tests/services/api/analysis-error-classification-fixture.js @@ -0,0 +1,39 @@ +import { + handleContextPackRoute, + handleRiskDeltaRoute, + handleRiskExplainRoute +} from '../../../tools/api/router/analysis.js'; +import { + createContextPackValidator, + createRiskDeltaValidator, + createRiskExplainValidator +} from '../../../tools/api/validation.js'; +import { createResponseCapture } from './response-capture.js'; + +const validateContextPackPayload = createContextPackValidator(); +const validateRiskDeltaPayload = createRiskDeltaValidator(); +const validateRiskExplainPayload = createRiskExplainValidator(); + +export const analysisErrorRoutes = { + contextPack: { + handler: handleContextPackRoute, + routeArgs: { + validateContextPackPayload, + ensureWorkspaceAllowlist: async () => null + } + }, + riskDelta: { + handler: handleRiskDeltaRoute, + routeArgs: { + validateRiskDeltaPayload + } + }, + riskExplain: { + handler: handleRiskExplainRoute, + routeArgs: { + validateRiskExplainPayload + } + } +}; + +export const createAnalysisErrorResponseCapture = createResponseCapture; diff --git a/tests/services/api/analysis-repo-resolution-error-classification.test.js b/tests/services/api/analysis-repo-resolution-error-classification.test.js new file mode 100644 index 000000000..0c8cb762a --- /dev/null +++ b/tests/services/api/analysis-repo-resolution-error-classification.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { + analysisErrorRoutes, + createAnalysisErrorResponseCapture +} from './analysis-error-classification-fixture.js'; + +const cases = [ + { + name: 'risk explain invalid repo returns 400', + ...analysisErrorRoutes.riskExplain, + payload: { repoPath: 'bad-repo', chunk: 'chunk:test' }, + errorCode: ERROR_CODES.INVALID_REQUEST, + expectedStatus: 400, + expectedBodyCode: ERROR_CODES.INVALID_REQUEST + }, + { + name: 'risk explain forbidden repo returns 403', + ...analysisErrorRoutes.riskExplain, + payload: { repoPath: 'forbidden-repo', chunk: 'chunk:test' }, + errorCode: ERROR_CODES.FORBIDDEN, + expectedStatus: 403, + expectedBodyCode: ERROR_CODES.FORBIDDEN + }, + { + name: 'context pack invalid repo returns 400', + ...analysisErrorRoutes.contextPack, + payload: { repoPath: 'bad-repo', seed: 'chunk:test', hops: 0 }, + errorCode: ERROR_CODES.INVALID_REQUEST, + expectedStatus: 400, + expectedBodyCode: ERROR_CODES.INVALID_REQUEST + }, + { + name: 'context pack forbidden repo returns 403', + ...analysisErrorRoutes.contextPack, + payload: { repoPath: 'forbidden-repo', seed: 'chunk:test', hops: 0 }, + errorCode: ERROR_CODES.FORBIDDEN, + expectedStatus: 403, + expectedBodyCode: ERROR_CODES.FORBIDDEN + }, + { + name: 'risk delta invalid repo returns 400', + ...analysisErrorRoutes.riskDelta, + payload: { repoPath: 'bad-repo', seed: 'chunk:test', from: 'a', to: 'b' }, + errorCode: ERROR_CODES.INVALID_REQUEST, + expectedStatus: 400, + expectedBodyCode: ERROR_CODES.INVALID_REQUEST + }, + { + name: 'risk delta forbidden repo returns 403', + ...analysisErrorRoutes.riskDelta, + payload: { repoPath: 'forbidden-repo', seed: 'chunk:test', from: 'a', to: 'b' }, + errorCode: ERROR_CODES.FORBIDDEN, + expectedStatus: 403, + expectedBodyCode: ERROR_CODES.FORBIDDEN + } +]; + +for (const testCase of cases) { + const { capture, response } = createAnalysisErrorResponseCapture(); + const repoErr = new Error(`${testCase.name} repo failure`); + repoErr.code = testCase.errorCode; + + const handled = await testCase.handler({ + req: {}, + res: response, + corsHeaders: {}, + observability: null, + parseJsonBody: async () => testCase.payload, + resolveRepo: async () => { + throw repoErr; + }, + ...testCase.routeArgs + }); + + assert.equal(handled, true, `expected route to handle ${testCase.name}`); + assert.equal(capture.statusCode, testCase.expectedStatus, `unexpected status for ${testCase.name}`); + + const body = JSON.parse(String(capture.body || '{}')); + assert.equal(body.ok, false, `expected API error envelope for ${testCase.name}`); + assert.equal(body.code, testCase.expectedBodyCode, `unexpected API code for ${testCase.name}`); +} + +console.log('API analysis repo resolution error classification test passed'); diff --git a/tests/services/api/analysis-validation-compatibility.test.js b/tests/services/api/analysis-validation-compatibility.test.js new file mode 100644 index 000000000..9e508c6ae --- /dev/null +++ b/tests/services/api/analysis-validation-compatibility.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createContextPackValidator, + createRiskDeltaValidator, + createRiskExplainValidator +} from '../../../tools/api/validation.js'; + +const validateContextPackPayload = createContextPackValidator(); +const validateRiskDeltaPayload = createRiskDeltaValidator(); +const validateRiskExplainPayload = createRiskExplainValidator(); + +for (const select of [ + 'repo-a', + ['repo-a', 'repo-b'], + { repos: ['repo-a'], includeDisabled: true } +]) { + const validation = validateContextPackPayload({ + workspacePath: 'C:\\workspace\\.pairofcleats-workspace.jsonc', + seed: 'chunk:test', + hops: 0, + select + }); + assert.deepEqual(validation, { ok: true }, `expected context-pack validator to accept select=${JSON.stringify(select)}`); +} + +const dashedFilters = { + 'flow-id': 'flow-1', + 'source-rule': 'source.rule', + 'sink-rule': 'sink.rule' +}; + +assert.deepEqual( + validateContextPackPayload({ + seed: 'chunk:test', + hops: 0, + filters: dashedFilters + }), + { ok: true }, + 'expected context-pack validator to accept dashed risk filter keys' +); + +assert.deepEqual( + validateRiskExplainPayload({ + chunk: 'chunk:test', + filters: dashedFilters + }), + { ok: true }, + 'expected risk-explain validator to accept dashed risk filter keys' +); + +assert.deepEqual( + validateRiskDeltaPayload({ + seed: 'chunk:test', + from: 'build:a', + to: 'build:b', + filters: dashedFilters + }), + { ok: true }, + 'expected risk-delta validator to accept dashed risk filter keys' +); + +console.log('API analysis validation compatibility test passed'); diff --git a/tests/services/api/api-server-stream.test.js b/tests/services/api/api-server-stream.test.js deleted file mode 100644 index 3d9bb8420..000000000 --- a/tests/services/api/api-server-stream.test.js +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env node -import http from 'node:http'; -import path from 'node:path'; -import readline from 'node:readline'; -import fsPromises from 'node:fs/promises'; -import { spawn, spawnSync } from 'node:child_process'; -import { applyTestEnv, attachSilentLogging } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const cacheRoot = resolveTestCachePath(root, 'api-server-stream'); -const serverPath = path.join(root, 'tools', 'api', 'server.js'); -const authToken = 'test-token'; - -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - syncProcess: false -}); - -const build = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', fixtureRoot], - { env, stdio: 'inherit' } -); -if (build.status !== 0) { - console.error('api-server stream test failed: build_index failed'); - process.exit(build.status ?? 1); -} - -const server = spawn( - process.execPath, - [serverPath, '--port', '0', '--json', '--quiet', '--repo', fixtureRoot, '--auth-token', authToken], - { env, stdio: ['ignore', 'pipe', 'pipe'] } -); -attachSilentLogging(server, 'api-server'); -const startupTimeoutMs = Number.isFinite(Number(process.env.PAIROFCLEATS_TEST_API_STARTUP_TIMEOUT_MS)) - ? Math.max(1000, Math.floor(Number(process.env.PAIROFCLEATS_TEST_API_STARTUP_TIMEOUT_MS))) - : 30000; - -let stderr = ''; -server.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); -}); - -const readStartup = async () => { - const rl = readline.createInterface({ input: server.stdout }); - return await new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - clearTimeout(timeout); - server.off('exit', handleExitBeforeStartup); - server.off('error', handleStartupError); - try { - rl.close(); - } catch { - // ignore close race; readline may already be closed - } - }; - const fail = (err) => { - if (settled) return; - settled = true; - cleanup(); - reject(err); - }; - const succeed = (line) => { - if (settled) return; - settled = true; - cleanup(); - resolve(line); - }; - const handleExitBeforeStartup = (code, signal) => { - fail(new Error(`api-server exited before startup (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)); - }; - const handleStartupError = (err) => { - fail(err instanceof Error ? err : new Error(String(err))); - }; - const timeout = setTimeout(() => { - fail(new Error(`api-server startup timed out after ${startupTimeoutMs}ms`)); - }, startupTimeoutMs); - rl.once('line', succeed); - server.once('exit', handleExitBeforeStartup); - server.once('error', handleStartupError); - }); -}; - -const parseSse = (block) => { - const lines = block.split(/\r?\n/); - let event = 'message'; - let data = ''; - for (const line of lines) { - if (line.startsWith('event:')) { - event = line.replace('event:', '').trim(); - continue; - } - if (line.startsWith('data:')) { - data += line.replace('data:', '').trim(); - } - } - const payload = data ? JSON.parse(data) : null; - return { event, data: payload }; -}; - -const readSse = async (method, requestPath, body) => await new Promise((resolve, reject) => { - const payload = body ? JSON.stringify(body) : null; - const headers = { Authorization: `Bearer ${authToken}` }; - if (payload) { - headers['Content-Type'] = 'application/json'; - headers['Content-Length'] = Buffer.byteLength(payload); - } - const events = []; - let buffer = ''; - const req = http.request( - { - host: serverInfo.host, - port: serverInfo.port, - path: requestPath, - method, - headers - }, - (res) => { - res.on('data', (chunk) => { - buffer += chunk.toString(); - while (true) { - const idx = buffer.indexOf('\n\n'); - if (idx === -1) break; - const block = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 2); - if (!block) continue; - const parsed = parseSse(block); - events.push(parsed); - if (parsed.event === 'done') { - resolve(events); - req.destroy(); - break; - } - } - }); - res.on('end', () => resolve(events)); - } - ); - req.on('error', reject); - if (payload) req.write(payload); - req.end(); -}); - -const abortStream = async (method, requestPath, body) => await new Promise((resolve, reject) => { - const payload = body ? JSON.stringify(body) : null; - const headers = { Authorization: `Bearer ${authToken}` }; - if (payload) { - headers['Content-Type'] = 'application/json'; - headers['Content-Length'] = Buffer.byteLength(payload); - } - const req = http.request( - { - host: serverInfo.host, - port: serverInfo.port, - path: requestPath, - method, - headers - }, - (res) => { - const timeout = setTimeout(() => { - req.destroy(); - resolve(); - }, 1000); - res.once('data', () => { - clearTimeout(timeout); - req.destroy(); - resolve(); - }); - res.on('error', (err) => { - clearTimeout(timeout); - if (err?.code === 'ECONNRESET') return resolve(); - reject(err); - }); - } - ); - req.on('error', (err) => { - if (err?.code === 'ECONNRESET') return resolve(); - reject(err); - }); - if (payload) req.write(payload); - req.end(); -}); - -let serverInfo = null; -try { - const line = await readStartup(); - serverInfo = JSON.parse(line || '{}'); - if (!serverInfo?.port) { - throw new Error('api-server did not report a listening port'); - } - - const statusEvents = await readSse('GET', '/status/stream'); - const statusResult = statusEvents.find((evt) => evt.event === 'result'); - if (!statusResult?.data?.status?.repo?.root) { - throw new Error('status stream missing repo payload'); - } - const statusBody = JSON.stringify(statusResult.data || {}); - if (statusBody.includes(fixtureRoot) || statusBody.includes(cacheRoot)) { - throw new Error('status stream leaked absolute paths'); - } - - const searchEvents = await readSse('POST', '/search/stream', { query: 'return', mode: 'code' }); - const searchResult = searchEvents.find((evt) => evt.event === 'result'); - const hits = searchResult?.data?.result?.code || []; - if (!hits.length) { - throw new Error('search stream returned no results'); - } - - await abortStream('POST', '/search/stream', { query: 'return', mode: 'code' }); - const followUp = await readSse('GET', '/status/stream'); - const followResult = followUp.find((evt) => evt.event === 'result'); - if (!followResult?.data?.status?.repo?.root) { - throw new Error('stream abort should not break subsequent requests'); - } - const followBody = JSON.stringify(followResult.data || {}); - if (followBody.includes(fixtureRoot) || followBody.includes(cacheRoot)) { - throw new Error('follow-up status stream leaked absolute paths'); - } -} catch (err) { - console.error(err?.message || err); - if (stderr.trim()) { - console.error(stderr.trim()); - } - server.kill('SIGKILL'); - process.exit(1); -} - -await new Promise((resolve) => { - const timeout = setTimeout(() => { - server.kill('SIGKILL'); - resolve(); - }, 5000); - server.once('exit', () => { - clearTimeout(timeout); - resolve(); - }); - server.kill('SIGTERM'); -}); - -console.log('api-server stream tests passed'); - diff --git a/tests/services/api/capabilities.test.js b/tests/services/api/capabilities.test.js new file mode 100644 index 000000000..ae68f927a --- /dev/null +++ b/tests/services/api/capabilities.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { MCP_SCHEMA_VERSION } from '../../../src/integrations/mcp/defs.js'; +import { getCapabilities } from '../../../src/shared/capabilities.js'; +import { getApiWorkflowCapabilities, getRuntimeCapabilityManifest } from '../../../src/shared/runtime-capability-manifest.js'; +import { getToolVersion } from '../../../tools/shared/dict-utils.js'; +import { buildApiTrustBoundaryStatusView, evaluateApiTrustBoundary } from '../../../tools/api/trust-boundary.js'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; + +const cohort = await prepareFixtureApiServerCohort({ + cacheName: 'api-capabilities' +}); +const { fixtureRoot } = cohort; + +const expectedToolVersion = getToolVersion() || '0.0.0'; +const expectedRuntimeCapabilities = getCapabilities({ refresh: true }); +const expectedManifest = getRuntimeCapabilityManifest({ runtimeCapabilities: expectedRuntimeCapabilities }); +const expectedTrustBoundary = evaluateApiTrustBoundary({ + host: '127.0.0.1', + defaultRepo: fixtureRoot, + allowedRepoRoots: [], + allowUnauthenticated: false, + authToken: 'test-token', + corsAllowAny: false +}); +const expectedTrustBoundaryView = buildApiTrustBoundaryStatusView(expectedTrustBoundary); + +const { serverInfo, requestJson, stop } = await cohort.start({ + allowedRoots: [] +}); + +try { + const unauthorized = await requestJson('GET', '/capabilities', null, serverInfo, { auth: false }); + assert.equal(unauthorized.status, 401, 'api-server should reject missing auth for /capabilities'); + assert.equal(unauthorized.body?.code, 'UNAUTHORIZED', 'api-server should return UNAUTHORIZED for /capabilities'); + + const capabilities = await requestJson('GET', '/capabilities', null, serverInfo); + assert.equal(capabilities.status, 200, 'api-server /capabilities should return 200'); + assert.equal(capabilities.body?.ok, true, 'api-server /capabilities should return ok=true'); + assert.equal(capabilities.body?.schemaVersion, MCP_SCHEMA_VERSION, 'api-server /capabilities schemaVersion mismatch'); + assert.equal(capabilities.body?.toolVersion, expectedToolVersion, 'api-server /capabilities toolVersion mismatch'); + assert.deepEqual(capabilities.body?.serverInfo, { + name: 'PairOfCleats', + version: expectedToolVersion + }, 'api-server /capabilities serverInfo mismatch'); + assert.deepEqual( + capabilities.body?.capabilities, + getApiWorkflowCapabilities({ runtimeCapabilities: expectedRuntimeCapabilities }), + 'api-server /capabilities workflow capability mask mismatch' + ); + assert.deepEqual( + capabilities.body?.runtimeCapabilities, + expectedRuntimeCapabilities, + 'api-server /capabilities runtime capability payload mismatch' + ); + assert.deepEqual( + capabilities.body?.runtimeManifest, + expectedManifest, + 'api-server /capabilities runtime manifest mismatch' + ); + assert.deepEqual( + capabilities.body?.trustBoundary, + expectedTrustBoundaryView, + 'api-server /capabilities trust boundary mismatch' + ); +} catch (err) { + console.error(err?.message || err); + process.exit(1); +} finally { + await stop(); +} + +console.log('API capabilities ok.'); diff --git a/tests/services/api/context-pack-default-repo-resolution.test.js b/tests/services/api/context-pack-default-repo-resolution.test.js new file mode 100644 index 000000000..6236cc1a0 --- /dev/null +++ b/tests/services/api/context-pack-default-repo-resolution.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { handleContextPackRoute } from '../../../tools/api/router/analysis.js'; +import { + createContextPackRouteFixture, + readCapturedJson +} from './context-pack-route-fixture.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-api-context-pack-default-repo-')); +const { + capture, + response, + resolveRepoCalls, + parseJsonBody, + validateContextPackPayload +} = createContextPackRouteFixture(); + +const handled = await handleContextPackRoute({ + req: {}, + res: response, + corsHeaders: {}, + observability: null, + parseJsonBody, + resolveRepo: async (repo) => { + resolveRepoCalls.push(repo); + return tempRoot; + }, + validateContextPackPayload, + ensureWorkspaceAllowlist: async () => null +}); + +assert.equal(handled, true, 'expected context-pack route to handle request'); +assert.deepEqual( + resolveRepoCalls, + [''], + 'single-repo context-pack requests should resolve the default repo when no repoPath/workspacePath is provided' +); +assert.equal(capture.statusCode, 404, 'expected missing index at resolved default repo to surface as 404'); + +const body = readCapturedJson(capture); +assert.equal(body.ok, false, 'expected API error envelope'); +assert.equal(body.code, 'NO_INDEX'); +assert.match(String(body.message || ''), /Code index not found/i); + +console.log('API context-pack default repo resolution test passed'); diff --git a/tests/services/api/context-pack-route-fixture.js b/tests/services/api/context-pack-route-fixture.js new file mode 100644 index 000000000..6f9fed9e6 --- /dev/null +++ b/tests/services/api/context-pack-route-fixture.js @@ -0,0 +1,27 @@ +import { createContextPackValidator } from '../../../tools/api/validation.js'; +import { createResponseCapture } from './response-capture.js'; + +export const createMinimalContextPackPayload = (overrides = {}) => ({ + seed: 'chunk:ck64:v1:test:src/file.js:0000000000000001', + hops: 0, + includeGraph: false, + includeImports: false, + includeUsages: false, + includeCallersCallees: false, + ...overrides +}); + +export const createContextPackRouteFixture = (payload = createMinimalContextPackPayload()) => { + const { capture, response } = createResponseCapture(); + const resolveRepoCalls = []; + return { + capture, + response, + payload, + resolveRepoCalls, + parseJsonBody: async () => payload, + validateContextPackPayload: createContextPackValidator() + }; +}; + +export const readCapturedJson = (capture) => JSON.parse(String(capture?.body || '{}')); diff --git a/tests/services/api/context-pack-workspace-allowlist.test.js b/tests/services/api/context-pack-workspace-allowlist.test.js new file mode 100644 index 000000000..68f760117 --- /dev/null +++ b/tests/services/api/context-pack-workspace-allowlist.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { writeFederatedWorkspaceConfig, startFederatedApiServer } from '../../helpers/federated-api.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'api-context-pack-workspace-allowlist'); +const allowedRoot = path.join(tempRoot, 'allowed-root'); +const blockedRoot = path.join(tempRoot, 'blocked-root'); +const repoRoot = path.join(allowedRoot, 'repo'); +const workspacePath = path.join(blockedRoot, '.pairofcleats-workspace.jsonc'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.mkdir(blockedRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'src', 'alpha.js'), 'export const alpha = 1;\n', 'utf8'); +await writeFederatedWorkspaceConfig(workspacePath, { + schemaVersion: 1, + repos: [ + { root: repoRoot, alias: 'alpha', priority: 1 } + ] +}); + +const { serverInfo, requestJson, stop } = await startFederatedApiServer({ + repoRoot, + allowedRoots: [allowedRoot], + envOverrides: process.env +}); + +try { + const response = await requestJson('POST', '/analysis/context-pack', { + repoPath: repoRoot, + workspacePath, + seed: 'chunk:chunk-risk', + hops: 0, + includeRisk: true, + includeGraph: false, + includeImports: false, + includeUsages: false, + includeCallersCallees: false + }, serverInfo); + + assert.equal(response.status, 403, 'expected workspace path outside allowlist to be forbidden'); + assert.equal(response.body?.ok, false, 'expected API error envelope'); + assert.equal(response.body?.code, 'FORBIDDEN'); + assert.match(String(response.body?.message || ''), /not permitted/i); +} finally { + await stop(); +} + +console.log('API context-pack workspace allowlist test passed'); diff --git a/tests/services/api/context-pack-workspace-without-repo.test.js b/tests/services/api/context-pack-workspace-without-repo.test.js new file mode 100644 index 000000000..87fd8b9c2 --- /dev/null +++ b/tests/services/api/context-pack-workspace-without-repo.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { handleContextPackRoute } from '../../../tools/api/router/analysis.js'; +import { + createContextPackRouteFixture, + createMinimalContextPackPayload, + readCapturedJson +} from './context-pack-route-fixture.js'; + +const payload = createMinimalContextPackPayload({ + workspacePath: 'C:\\workspace\\.pairofcleats-workspace.jsonc', + workspaceId: 'workspace-test' +}); +const { + capture, + response, + resolveRepoCalls, + parseJsonBody, + validateContextPackPayload +} = createContextPackRouteFixture(payload); + +const handled = await handleContextPackRoute({ + req: {}, + res: response, + corsHeaders: {}, + observability: null, + parseJsonBody, + resolveRepo: async (repo) => { + resolveRepoCalls.push(repo); + return `RESOLVED:${repo}`; + }, + validateContextPackPayload, + ensureWorkspaceAllowlist: async () => ({ + repoSetId: 'workspace-test', + workspacePath: payload.workspacePath, + repos: [] + }) +}); + +assert.equal(handled, true, 'expected context-pack route to handle request'); +assert.deepEqual( + resolveRepoCalls, + [], + 'workspace-only context-pack requests should not resolve an implicit repo' +); +assert.equal(capture.statusCode, 400, 'expected empty trusted workspace config to fail as invalid request'); + +const body = readCapturedJson(capture); +assert.equal(body.ok, false, 'expected API error envelope'); +assert.equal(body.code, 'INVALID_REQUEST'); +assert.match(String(body.message || ''), /zero repositories/i); + +console.log('API context-pack workspace-without-repo test passed'); diff --git a/tests/services/api/core-api.test.js b/tests/services/api/core.test.js similarity index 100% rename from tests/services/api/core-api.test.js rename to tests/services/api/core.test.js diff --git a/tests/services/api/cors-allow.test.js b/tests/services/api/cors-allow.test.js deleted file mode 100644 index 959dea119..000000000 --- a/tests/services/api/cors-allow.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheName = 'api-cors-allow'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); - -const origin = 'https://example.com'; -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [], - env, - corsAllowedOrigins: ['example.com'] -}); - -try { - const allowed = await requestJson('GET', '/health', null, serverInfo, { - headers: { Origin: origin } - }); - if (allowed.status !== 200) { - throw new Error('expected allowed origin to succeed'); - } - const allowHeader = allowed.headers?.['access-control-allow-origin']; - if (allowHeader !== origin) { - throw new Error('expected access-control-allow-origin header to match origin'); - } -} catch (err) { - console.error(err?.message || err); - process.exit(1); -} finally { - await stop(); -} - -console.log('API CORS allow test passed.'); diff --git a/tests/services/api/federated-search-cache-root-allowlist.test.js b/tests/services/api/federated-search-cache-root-allowlist.test.js deleted file mode 100644 index 1a23303d5..000000000 --- a/tests/services/api/federated-search-cache-root-allowlist.test.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-cache-allowlist-'); -const allowedRoot = path.join(tempRoot, 'allowed'); -const blockedRoot = path.join(tempRoot, 'blocked'); -const repoRoot = path.join(allowedRoot, 'repo'); -const workspacePath = path.join(allowedRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.mkdir(blockedRoot, { recursive: true }); - -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: '../blocked/cache', - repos: [ - { root: './repo', alias: 'sample' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [allowedRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'cache-root-allowlist' - }, - serverInfo - ); - assert.equal(response.status, 403); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'FORBIDDEN'); -} finally { - await stop(); -} - -console.log('API federated cache-root allowlist test passed'); diff --git a/tests/services/api/federated-search-cache-root-symlink-escape.test.js b/tests/services/api/federated-search-cache-root-symlink-escape.test.js deleted file mode 100644 index ad9e7e1e4..000000000 --- a/tests/services/api/federated-search-cache-root-symlink-escape.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-cache-symlink-'); -const allowedRoot = path.join(tempRoot, 'allowed'); -const blockedRoot = path.join(tempRoot, 'blocked'); -const repoRoot = path.join(allowedRoot, 'repo'); -const workspacePath = path.join(allowedRoot, '.pairofcleats-workspace.jsonc'); -const cacheLinkPath = path.join(allowedRoot, 'cache-link'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.mkdir(blockedRoot, { recursive: true }); -await fs.symlink(blockedRoot, cacheLinkPath, process.platform === 'win32' ? 'junction' : 'dir'); - -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache-link/federated-cache', - repos: [ - { root: './repo', alias: 'sample' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [allowedRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'cache-root-symlink-escape' - }, - serverInfo - ); - assert.equal(response.status, 403); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'FORBIDDEN'); -} finally { - await stop(); -} - -console.log('API federated cache-root symlink escape test passed'); diff --git a/tests/services/api/federated-search-client-errors.test.js b/tests/services/api/federated-search-client-errors.test.js deleted file mode 100644 index dabea6c1a..000000000 --- a/tests/services/api/federated-search-client-errors.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-client-errors-'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache', - repos: [ - { root: './repo', alias: 'sample' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [tempRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'cohort-client-error', - cohort: ['missing-cohort'], - search: { - mode: 'code', - top: 5 - } - }, - serverInfo - ); - assert.equal(response.status, 400, 'invalid federated cohort selector should be a client error'); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'INVALID_REQUEST'); -} finally { - await stop(); -} - -console.log('API federated client error status test passed'); diff --git a/tests/services/api/federated-search-per-repo-top-zero.test.js b/tests/services/api/federated-search-per-repo-top-zero.test.js deleted file mode 100644 index deea4a098..000000000 --- a/tests/services/api/federated-search-per-repo-top-zero.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-top-zero-'); -const allowedRoot = path.join(tempRoot, 'allowed'); -const repoRoot = path.join(allowedRoot, 'repo'); -const workspacePath = path.join(allowedRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache', - repos: [ - { root: './repo', alias: 'sample' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [allowedRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'per-repo-top-zero', - limits: { - perRepoTop: 0, - concurrency: 1 - } - }, - serverInfo - ); - assert.notEqual( - response.status, - 400, - 'request should pass API schema validation when limits.perRepoTop is zero' - ); - assert.notEqual(response.body?.code, 'INVALID_REQUEST'); - if (response.status === 200) { - assert.equal(response.body?.ok, true); - assert.equal(response.body?.backend, 'federated'); - } -} finally { - await stop(); -} - -console.log('API federated per-repo top zero validation test passed'); diff --git a/tests/services/api/federated-search-redacts-paths.test.js b/tests/services/api/federated-search-redacts-paths.test.js deleted file mode 100644 index 12a95b69a..000000000 --- a/tests/services/api/federated-search-redacts-paths.test.js +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-redaction-'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); - -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache', - repos: [ - { root: './repo', alias: 'sample' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [tempRoot], - envOverrides: { - PAIROFCLEATS_CACHE_ROOT: cacheRoot - } -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'greet', - select: { - tags: ['does-not-exist'] - }, - search: { - mode: 'code', - top: 5 - } - }, - serverInfo - ); - assert.equal(response.status, 200); - assert.equal(response.body?.ok, true); - assert.deepEqual(response.body?.code || [], [], 'empty selection should return zero hits'); - - const serialized = JSON.stringify(response.body); - assert.equal(serialized.includes(repoRoot), false, 'response should not expose absolute repo root paths'); - assert.equal(serialized.includes(workspacePath), false, 'response should not expose absolute workspace path'); - assert.equal(serialized.includes(cacheRoot), false, 'response should not expose absolute cache root'); -} finally { - await stop(); -} - -console.log('API federated search redacts paths test passed'); diff --git a/tests/services/api/federated-search-repo-root-directory-validation.test.js b/tests/services/api/federated-search-repo-root-directory-validation.test.js deleted file mode 100644 index 6099aae76..000000000 --- a/tests/services/api/federated-search-repo-root-directory-validation.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-repo-root-directory-'); -const allowedRoot = path.join(tempRoot, 'allowed'); -const defaultRepo = path.join(allowedRoot, 'repo-default'); -const notDirectory = path.join(allowedRoot, 'repo-root.txt'); -const workspacePath = path.join(allowedRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(defaultRepo, { recursive: true }); -await fs.writeFile(notDirectory, 'not a directory', 'utf8'); - -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache', - repos: [ - { root: './repo-root.txt', alias: 'bad-root' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot: defaultRepo, - allowedRoots: [allowedRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'directory-validation' - }, - serverInfo - ); - assert.equal(response.status, 400); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'INVALID_REQUEST'); - assert.match(String(response.body?.message || ''), /must be a directory/i); -} finally { - await stop(); -} - -console.log('API federated repo-root directory validation test passed'); diff --git a/tests/services/api/federated-search-validation-matrix.test.js b/tests/services/api/federated-search-validation-matrix.test.js new file mode 100644 index 000000000..8b6b92e48 --- /dev/null +++ b/tests/services/api/federated-search-validation-matrix.test.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + createFederatedTempRoot, + startFederatedApiServer, + writeFederatedWorkspaceConfig +} from '../../helpers/federated-api.js'; + +applyTestEnv(); + +const startFederatedValidationServer = async (options) => { + const attempts = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await startFederatedApiServer(options); + } catch (error) { + const message = String(error?.message || error || ''); + attempts.push(message); + const shouldRetry = attempt === 0 && /api-server exited before startup/i.test(message); + if (!shouldRetry) { + if (attempts.length > 1) { + error.message = `${message}\n\nstartup attempts:\n${attempts.join('\n---\n')}`; + } + throw error; + } + } + } + throw new Error(`api-server startup failed after retry:\n${attempts.join('\n---\n')}`); +}; + +const createValidationFixture = async () => { + const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-validation-'); + const allowedRoot = path.join(tempRoot, 'allowed'); + const blockedRoot = path.join(tempRoot, 'blocked'); + const defaultRepo = path.join(allowedRoot, 'repo-default'); + const cacheRoot = path.join(allowedRoot, 'cache'); + + await fs.mkdir(defaultRepo, { recursive: true }); + await fs.mkdir(blockedRoot, { recursive: true }); + + const blockedRepo = path.join(blockedRoot, 'repo-blocked'); + await fs.mkdir(blockedRepo, { recursive: true }); + + const workspaceOutsideAllowlist = path.join(allowedRoot, '.workspace-outside-allowlist.jsonc'); + await writeFederatedWorkspaceConfig(workspaceOutsideAllowlist, { + schemaVersion: 1, + cacheRoot: './cache', + repos: [ + { root: './repo-default', alias: 'allowed' }, + { root: '../blocked/repo-blocked', alias: 'blocked' } + ] + }); + + const notDirectory = path.join(allowedRoot, 'repo-root.txt'); + await fs.writeFile(notDirectory, 'not a directory', 'utf8'); + const workspaceRepoRootNotDirectory = path.join(allowedRoot, '.workspace-repo-root-directory.jsonc'); + await writeFederatedWorkspaceConfig(workspaceRepoRootNotDirectory, { + schemaVersion: 1, + cacheRoot: './cache', + repos: [ + { root: './repo-root.txt', alias: 'bad-root' } + ] + }); + + const workspaceCacheOutsideAllowlist = path.join(allowedRoot, '.workspace-cache-outside-allowlist.jsonc'); + await writeFederatedWorkspaceConfig(workspaceCacheOutsideAllowlist, { + schemaVersion: 1, + cacheRoot: '../blocked/cache', + repos: [ + { root: './repo-default', alias: 'sample' } + ] + }); + + const cacheLinkPath = path.join(allowedRoot, 'cache-link'); + await fs.symlink(blockedRoot, cacheLinkPath, process.platform === 'win32' ? 'junction' : 'dir'); + const workspaceCacheSymlinkEscape = path.join(allowedRoot, '.workspace-cache-symlink-escape.jsonc'); + await writeFederatedWorkspaceConfig(workspaceCacheSymlinkEscape, { + schemaVersion: 1, + cacheRoot: './cache-link/federated-cache', + repos: [ + { root: './repo-default', alias: 'sample' } + ] + }); + + const workspaceDefault = path.join(allowedRoot, '.workspace-default.jsonc'); + await writeFederatedWorkspaceConfig(workspaceDefault, { + schemaVersion: 1, + cacheRoot: './cache', + repos: [ + { root: './repo-default', alias: 'sample' } + ] + }); + + return { + tempRoot, + allowedRoot, + blockedRoot, + defaultRepo, + blockedRepo, + cacheRoot, + workspaceOutsideAllowlist, + workspaceRepoRootNotDirectory, + workspaceCacheOutsideAllowlist, + workspaceCacheSymlinkEscape, + workspaceDefault + }; +}; + +const fixture = await createValidationFixture(); + +const cases = [ + { + name: 'workspaceId requests are rejected until workspacePath support exists', + body: { + workspaceId: 'ws1-demo', + query: 'sample' + }, + assertResponse(response) { + assert.equal(response.status, 400); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'INVALID_REQUEST'); + const errors = Array.isArray(response.body?.errors) ? response.body.errors : []; + assert.ok(errors.some((entry) => String(entry).includes('workspacePath'))); + } + }, + { + name: 'workspace repos outside the allowlist return forbidden', + body: { + workspacePath: fixture.workspaceOutsideAllowlist, + query: 'allowlist' + }, + assertResponse(response) { + assert.equal(response.status, 403); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'FORBIDDEN'); + } + }, + { + name: 'workspace repo roots must be directories', + body: { + workspacePath: fixture.workspaceRepoRootNotDirectory, + query: 'directory-validation' + }, + assertResponse(response) { + assert.equal(response.status, 400); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'INVALID_REQUEST'); + assert.match(String(response.body?.message || ''), /must be a directory/i); + } + }, + { + name: 'workspace cache roots outside the allowlist return forbidden', + body: { + workspacePath: fixture.workspaceCacheOutsideAllowlist, + query: 'cache-root-allowlist' + }, + assertResponse(response) { + assert.equal(response.status, 403); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'FORBIDDEN'); + } + }, + { + name: 'workspace cache-root symlink escapes return forbidden', + body: { + workspacePath: fixture.workspaceCacheSymlinkEscape, + query: 'cache-root-symlink-escape' + }, + assertResponse(response) { + assert.equal(response.status, 403); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'FORBIDDEN'); + } + }, + { + name: 'invalid cohort selectors are returned as client errors', + body: { + workspacePath: fixture.workspaceDefault, + query: 'cohort-client-error', + cohort: ['missing-cohort'], + search: { + mode: 'code', + top: 5 + } + }, + assertResponse(response) { + assert.equal(response.status, 400); + assert.equal(response.body?.ok, false); + assert.equal(response.body?.code, 'INVALID_REQUEST'); + } + }, + { + name: 'empty federated selections redact absolute paths from responses', + body: { + workspacePath: fixture.workspaceDefault, + query: 'greet', + select: { + tags: ['does-not-exist'] + }, + search: { + mode: 'code', + top: 5 + } + }, + assertResponse(response) { + assert.equal(response.status, 200); + assert.equal(response.body?.ok, true); + assert.deepEqual(response.body?.code || [], []); + const serialized = JSON.stringify(response.body); + assert.equal(serialized.includes(fixture.defaultRepo), false); + assert.equal(serialized.includes(fixture.workspaceDefault), false); + assert.equal(serialized.includes(fixture.cacheRoot), false); + } + }, + { + name: 'limits.perRepoTop accepts zero without becoming an invalid request', + body: { + workspacePath: fixture.workspaceDefault, + query: 'per-repo-top-zero', + limits: { + perRepoTop: 0, + concurrency: 1 + } + }, + assertResponse(response) { + assert.notEqual(response.status, 400); + assert.notEqual(response.body?.code, 'INVALID_REQUEST'); + if (response.status === 200) { + assert.equal(response.body?.ok, true); + assert.equal(response.body?.backend, 'federated'); + } + } + } +]; + +const { serverInfo, requestJson, stop } = await startFederatedValidationServer({ + repoRoot: fixture.defaultRepo, + allowedRoots: [fixture.allowedRoot], + envOverrides: { + PAIROFCLEATS_CACHE_ROOT: fixture.cacheRoot + } +}); + +try { + for (const entry of cases) { + const response = await requestJson( + 'POST', + '/search/federated', + entry.body, + serverInfo + ); + entry.assertResponse(response); + } +} finally { + await stop(); +} + +console.log('API federated search validation matrix test passed'); diff --git a/tests/services/api/federated-search-workspace-allowlist.test.js b/tests/services/api/federated-search-workspace-allowlist.test.js deleted file mode 100644 index 22b169464..000000000 --- a/tests/services/api/federated-search-workspace-allowlist.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer, - writeFederatedWorkspaceConfig -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-allowlist-'); -const allowedRoot = path.join(tempRoot, 'allowed'); -const blockedRoot = path.join(tempRoot, 'blocked'); -const defaultRepo = path.join(allowedRoot, 'repo-default'); -const blockedRepo = path.join(blockedRoot, 'repo-blocked'); -const workspacePath = path.join(allowedRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(defaultRepo, { recursive: true }); -await fs.mkdir(blockedRepo, { recursive: true }); - -await writeFederatedWorkspaceConfig(workspacePath, { - schemaVersion: 1, - cacheRoot: './cache', - repos: [ - { root: './repo-default', alias: 'allowed' }, - { root: '../blocked/repo-blocked', alias: 'blocked' } - ] -}); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot: defaultRepo, - allowedRoots: [allowedRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspacePath, - query: 'allowlist' - }, - serverInfo - ); - assert.equal(response.status, 403); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'FORBIDDEN'); -} finally { - await stop(); -} - -console.log('API federated search workspace allowlist test passed'); diff --git a/tests/services/api/federated-search-workspaceid-validation.test.js b/tests/services/api/federated-search-workspaceid-validation.test.js deleted file mode 100644 index ab280ed98..000000000 --- a/tests/services/api/federated-search-workspaceid-validation.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createFederatedTempRoot, - startFederatedApiServer -} from '../../helpers/federated-api.js'; - -applyTestEnv(); - -const tempRoot = await createFederatedTempRoot('pairofcleats-api-fed-wsid-'); -const repoRoot = path.join(tempRoot, 'repo'); -await fs.mkdir(repoRoot, { recursive: true }); - -const { serverInfo, requestJson, stop } = await startFederatedApiServer({ - repoRoot, - allowedRoots: [tempRoot] -}); - -try { - const response = await requestJson( - 'POST', - '/search/federated', - { - workspaceId: 'ws1-demo', - query: 'sample' - }, - serverInfo - ); - assert.equal(response.status, 400); - assert.equal(response.body?.ok, false); - assert.equal(response.body?.code, 'INVALID_REQUEST'); - const errors = Array.isArray(response.body?.errors) ? response.body.errors : []; - assert.ok(errors.some((entry) => String(entry).includes('workspacePath')), 'expected workspacePath validation error'); -} finally { - await stop(); -} - -console.log('API federated workspaceId validation test passed'); diff --git a/tests/services/api/fixture-api-server-cohort.test.js b/tests/services/api/fixture-api-server-cohort.test.js new file mode 100644 index 000000000..e8c1b648a --- /dev/null +++ b/tests/services/api/fixture-api-server-cohort.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const sharedOne = await prepareFixtureApiServerCohort({ + fixtureName: 'call-sites-determinism', + cacheName: 'api-cohort-contract', + fixtureOptions: { + requiredModes: ['code'] + } +}); +const sharedTwo = await prepareFixtureApiServerCohort({ + fixtureName: 'call-sites-determinism', + cacheName: 'api-cohort-contract', + resetCache: false, + fixtureOptions: { + requiredModes: ['code'] + } +}); + +assert.equal(sharedOne.cohort.cacheScope, 'shared'); +assert.equal(sharedOne.cacheRoot, sharedTwo.cacheRoot, 'expected shared cohort cache reuse'); +assert.equal(sharedOne.fixtureRoot, sharedTwo.fixtureRoot, 'expected shared cohort fixture reuse'); + +const started = await sharedTwo.start(); +try { + const health = await started.requestJson('GET', '/health', null, started.serverInfo); + assert.equal(health.status, 200, 'expected cohort-started API server to respond'); + assert.equal(health.body?.ok, true, 'expected healthy cohort response'); +} finally { + await started.stop(); +} + +let isolatedOne = null; +let isolatedTwo = null; +await withTemporaryEnv({ PAIROFCLEATS_TEST_CACHE_SUFFIX: 'api-cohort-one' }, async () => { + isolatedOne = await prepareFixtureApiServerCohort({ + fixtureName: 'call-sites-determinism', + cacheName: 'api-cohort-isolated', + cacheScope: 'isolated', + resetCache: false, + indexFixture: false, + fixtureOptions: { + requiredModes: ['code'] + } + }); +}); +await withTemporaryEnv({ PAIROFCLEATS_TEST_CACHE_SUFFIX: 'api-cohort-two' }, async () => { + isolatedTwo = await prepareFixtureApiServerCohort({ + fixtureName: 'call-sites-determinism', + cacheName: 'api-cohort-isolated', + cacheScope: 'isolated', + resetCache: false, + indexFixture: false, + fixtureOptions: { + requiredModes: ['code'] + } + }); +}); + +assert.equal(isolatedOne?.cohort?.cacheScope, 'isolated'); +assert.equal(isolatedTwo?.cohort?.cacheScope, 'isolated'); +assert.notEqual( + isolatedOne?.cacheRoot, + isolatedTwo?.cacheRoot, + 'expected isolated cohort cache roots to stay distinct' +); + +console.log('fixture api server cohort test passed'); diff --git a/tests/services/api/health-and-status.test.js b/tests/services/api/health-and-status.test.js deleted file mode 100644 index df8b42175..000000000 --- a/tests/services/api/health-and-status.test.js +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheName = 'api-health-status'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); - -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [], - env -}); - -try { - const unauthorized = await requestJson('GET', '/health', null, serverInfo, { auth: false }); - if (unauthorized.status !== 401 || unauthorized.body?.code !== 'UNAUTHORIZED') { - throw new Error('api-server should reject missing auth'); - } - - const corsBlocked = await requestJson('GET', '/health', null, serverInfo, { - headers: { Origin: 'https://example.com' } - }); - if (corsBlocked.status !== 403 || corsBlocked.body?.code !== 'FORBIDDEN') { - throw new Error('api-server should reject disallowed CORS origins'); - } - - const preflightBlocked = await requestJson('OPTIONS', '/health', null, serverInfo, { - headers: { - Origin: 'https://example.com', - 'Access-Control-Request-Method': 'GET' - } - }); - if (preflightBlocked.status !== 403 || preflightBlocked.body?.code !== 'FORBIDDEN') { - throw new Error('api-server should reject disallowed CORS preflight'); - } - - const health = await requestJson('GET', '/health', null, serverInfo); - if (!health.body?.ok || typeof health.body.uptimeMs !== 'number') { - throw new Error('api-server /health response invalid'); - } - - const status = await requestJson('GET', '/status', null, serverInfo); - if (!status.body?.ok || !status.body.status?.repo?.root) { - throw new Error('api-server /status response missing repo info'); - } - const statusBody = JSON.stringify(status.body); - if (statusBody.includes(fixtureRoot) || statusBody.includes(cacheRoot)) { - throw new Error('api-server /status response leaked absolute paths'); - } -} catch (err) { - console.error(err?.message || err); - process.exit(1); -} finally { - await stop(); -} - -console.log('API health/status ok.'); diff --git a/tests/services/api/index-route-client-error-classification.test.js b/tests/services/api/index-route-client-error-classification.test.js new file mode 100644 index 000000000..a600679ac --- /dev/null +++ b/tests/services/api/index-route-client-error-classification.test.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { handleIndexDiffsRoute } from '../../../tools/api/router/index-diffs.js'; +import { handleIndexSnapshotsRoute } from '../../../tools/api/router/index-snapshots.js'; +import { invokeRouteWithMockResponse } from './response-capture.js'; + +const invalidRepoError = new Error('bad repo'); +invalidRepoError.code = ERROR_CODES.INVALID_REQUEST; +const forbiddenRepoError = new Error('forbidden repo'); +forbiddenRepoError.code = ERROR_CODES.FORBIDDEN; +const oversizedBodyError = new Error('body too large'); +oversizedBodyError.code = 'ERR_BODY_TOO_LARGE'; + +{ + const { handled, response } = await invokeRouteWithMockResponse(handleIndexDiffsRoute, { + requestUrl: new URL('http://127.0.0.1/index/diffs?repo=bad'), + pathname: '/index/diffs', + resolveRepo: async () => { + throw invalidRepoError; + } + }); + assert.equal(handled, true, 'diff route should claim invalid repo requests'); + assert.equal(response.statusCode, 400, 'diff route should map invalid repo to 400'); + assert.equal(response.json?.code, ERROR_CODES.INVALID_REQUEST); +} + +{ + const { handled, response } = await invokeRouteWithMockResponse(handleIndexSnapshotsRoute, { + requestUrl: new URL('http://127.0.0.1/index/snapshots?repo=forbidden'), + pathname: '/index/snapshots', + resolveRepo: async () => { + throw forbiddenRepoError; + }, + parseJsonBody: async () => null + }); + assert.equal(handled, true, 'snapshot route should claim forbidden repo requests'); + assert.equal(response.statusCode, 403, 'snapshot route should map forbidden repo to 403'); + assert.equal(response.json?.code, ERROR_CODES.FORBIDDEN); +} + +{ + const { handled, response } = await invokeRouteWithMockResponse(handleIndexSnapshotsRoute, { + method: 'POST', + requestUrl: new URL('http://127.0.0.1/index/snapshots'), + pathname: '/index/snapshots', + resolveRepo: async () => process.cwd(), + parseJsonBody: async () => { + throw oversizedBodyError; + } + }); + assert.equal(handled, true, 'snapshot route should claim oversized body requests'); + assert.equal(response.statusCode, 413, 'snapshot route should map oversized body to 413'); + assert.equal(response.json?.code, ERROR_CODES.INVALID_REQUEST); +} + +console.log('Index route client error classification test passed'); diff --git a/tests/services/api/index-route-decode-validation.test.js b/tests/services/api/index-route-decode-validation.test.js index 1b8b3d116..90dc769de 100644 --- a/tests/services/api/index-route-decode-validation.test.js +++ b/tests/services/api/index-route-decode-validation.test.js @@ -1,59 +1,27 @@ #!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; import { handleIndexDiffsRoute } from '../../../tools/api/router/index-diffs.js'; import { handleIndexSnapshotsRoute } from '../../../tools/api/router/index-snapshots.js'; import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { invokeRouteWithMockResponse } from './response-capture.js'; applyTestEnv(); -const createMockResponse = () => { - const state = { - statusCode: 0, - headers: {}, - body: '' - }; - return { - res: { - writeHead(statusCode, headers) { - state.statusCode = Number(statusCode) || 0; - state.headers = headers || {}; - }, - end(chunk = '') { - state.body += String(chunk || ''); - } - }, - get statusCode() { - return state.statusCode; - }, - get json() { - return state.body ? JSON.parse(state.body) : null; - } - }; -}; - const repoPath = process.cwd(); -const diffResponse = createMockResponse(); -const diffHandled = await handleIndexDiffsRoute({ - req: { method: 'GET' }, - res: diffResponse.res, +const { handled: diffHandled, response: diffResponse } = await invokeRouteWithMockResponse(handleIndexDiffsRoute, { requestUrl: new URL(`http://127.0.0.1/index/diffs/%E0%A4%A/events?repo=${encodeURIComponent(repoPath)}`), pathname: '/index/diffs/%E0%A4%A/events', - corsHeaders: {}, resolveRepo: async () => repoPath }); assert.equal(diffHandled, true, 'diff route should claim malformed diff-id requests'); assert.equal(diffResponse.statusCode, 400, 'diff route should map malformed URI escapes to 400'); assert.equal(diffResponse.json?.code, ERROR_CODES.INVALID_REQUEST, 'diff route should return INVALID_REQUEST'); -const snapshotResponse = createMockResponse(); -const snapshotHandled = await handleIndexSnapshotsRoute({ - req: { method: 'GET' }, - res: snapshotResponse.res, +const { handled: snapshotHandled, response: snapshotResponse } = await invokeRouteWithMockResponse(handleIndexSnapshotsRoute, { requestUrl: new URL(`http://127.0.0.1/index/snapshots/%E0%A4%A?repo=${encodeURIComponent(repoPath)}`), pathname: '/index/snapshots/%E0%A4%A', - corsHeaders: {}, resolveRepo: async () => repoPath, parseJsonBody: async () => null }); diff --git a/tests/services/api/no-index.test.js b/tests/services/api/no-index.test.js deleted file mode 100644 index 75b0b99e6..000000000 --- a/tests/services/api/no-index.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-api-no-index-')); -const defaultRepo = path.join(cacheRoot, 'default'); -const emptyRepo = path.join(cacheRoot, 'empty'); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(defaultRepo, { recursive: true }); -await fsPromises.mkdir(emptyRepo, { recursive: true }); -await fsPromises.writeFile(path.join(defaultRepo, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const env = applyTestEnv({ - cacheRoot, - embeddings: '0' -}); - -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: defaultRepo, - allowedRoots: [emptyRepo], - env -}); - -try { - const noIndex = await requestJson('POST', '/search', { - repoPath: emptyRepo, - query: 'return' - }, serverInfo); - assert.equal(noIndex.status, 409); - assert.equal(noIndex.body?.code, 'NO_INDEX'); -} finally { - await stop(); - await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -} - -console.log('API no-index response ok.'); diff --git a/tests/services/api/observability-correlation.test.js b/tests/services/api/observability-correlation.test.js new file mode 100644 index 000000000..9f3f8953a --- /dev/null +++ b/tests/services/api/observability-correlation.test.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; + +const cohort = await prepareFixtureApiServerCohort({ + cacheName: 'api-observability-correlation', + fixtureOptions: { + requiredModes: ['code'] + } +}); + +const { serverInfo, requestJson, stop } = await cohort.start(); + +try { + const headers = { + 'X-Correlation-Id': 'api-correlation-test', + 'X-Request-Id': 'api-request-test' + }; + const searchResponse = await requestJson( + 'POST', + '/search', + { query: 'return', mode: 'code', top: 3 }, + serverInfo, + { headers } + ); + assert.equal(searchResponse.status, 200); + assert.equal(searchResponse.headers['x-correlation-id'], 'api-correlation-test'); + assert.equal(searchResponse.headers['x-request-id'], 'api-request-test'); + assert.equal(searchResponse.body?.observability?.correlation?.correlationId, 'api-correlation-test'); + assert.equal(searchResponse.body?.result?.observability?.correlation?.correlationId, 'api-correlation-test'); +} finally { + await stop(); +} + +console.log('API observability correlation test passed'); diff --git a/tests/services/api/repo-authorization.test.js b/tests/services/api/repo-authorization.test.js index 17d236174..4f753bb81 100644 --- a/tests/services/api/repo-authorization.test.js +++ b/tests/services/api/repo-authorization.test.js @@ -1,30 +1,21 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; -const cacheName = 'api-repo-auth'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -const emptyRepo = path.join(cacheRoot, 'empty'); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(emptyRepo, { recursive: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' +const cohort = await prepareFixtureApiServerCohort({ + cacheName: 'api-repo-auth' }); +const emptyRepo = path.join(cohort.cacheRoot, 'empty'); +await fsPromises.mkdir(emptyRepo, { recursive: true }); -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [emptyRepo], - env +const { serverInfo, requestJson, stop } = await cohort.start({ + allowedRoots: [emptyRepo] }); try { const forbidden = await requestJson('POST', '/search', { - repoPath: cacheRoot, + repoPath: cohort.cacheRoot, query: 'return' }, serverInfo); if (forbidden.status !== 403 || forbidden.body?.code !== 'FORBIDDEN') { diff --git a/tests/services/api/request-helpers.test.js b/tests/services/api/request-helpers.test.js new file mode 100644 index 000000000..96df7b26c --- /dev/null +++ b/tests/services/api/request-helpers.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { + classifyBodyParseError, + classifyRepoResolveError, + classifyWorkspaceRequestError, + decodeRoutePathSegment, + parseJsonBodyOrSendError, + resolveRepoOrSendError +} from '../../../tools/api/router/request-helpers.js'; +import { createResponseCapture } from './response-capture.js'; + +const oversized = new Error('too large'); +oversized.code = 'ERR_BODY_TOO_LARGE'; +assert.deepEqual( + classifyBodyParseError(oversized), + { status: 413, code: ERROR_CODES.INVALID_REQUEST, message: 'too large' }, + 'expected body-too-large to map to 413 INVALID_REQUEST' +); + +const unsupported = new Error('bad type'); +unsupported.code = 'ERR_UNSUPPORTED_MEDIA_TYPE'; +assert.deepEqual( + classifyBodyParseError(unsupported), + { status: 415, code: ERROR_CODES.INVALID_REQUEST, message: 'bad type' }, + 'expected unsupported media type to map to 415 INVALID_REQUEST' +); + +const forbiddenRepo = new Error('repo forbidden'); +forbiddenRepo.code = ERROR_CODES.FORBIDDEN; +assert.deepEqual( + classifyRepoResolveError(forbiddenRepo), + { status: 403, code: ERROR_CODES.FORBIDDEN, message: 'repo forbidden' }, + 'expected forbidden repo resolution to map to 403 FORBIDDEN' +); + +const invalidWorkspace = new Error('Workspace path not permitted by server configuration.'); +assert.deepEqual( + classifyWorkspaceRequestError(invalidWorkspace), + { + status: 403, + code: ERROR_CODES.FORBIDDEN, + message: 'Workspace path not permitted by server configuration.' + }, + 'expected workspace allowlist violations to map to 403 FORBIDDEN' +); + +assert.equal( + decodeRoutePathSegment('snapshot%201', 'snapshot id'), + 'snapshot 1', + 'expected route path segment helper to decode valid URI segments' +); +assert.throws( + () => decodeRoutePathSegment('%E0%A4%A', 'diff id'), + (err) => err?.code === ERROR_CODES.INVALID_REQUEST + && err?.message === 'Invalid diff id: malformed URI encoding.', + 'expected malformed route path segment encoding to map to INVALID_REQUEST' +); + +{ + const { capture, response } = createResponseCapture(); + const result = await parseJsonBodyOrSendError( + {}, + response, + async () => { + throw unsupported; + }, + {} + ); + assert.equal(result.ok, false, 'expected parse helper to stop on parse error'); + assert.equal(capture.statusCode, 415, 'expected parse helper to emit 415'); + assert.equal(JSON.parse(String(capture.body || '{}')).code, ERROR_CODES.INVALID_REQUEST); +} + +{ + const { capture, response } = createResponseCapture(); + const result = await resolveRepoOrSendError( + response, + async () => { + throw forbiddenRepo; + }, + 'repo', + {} + ); + assert.equal(result.ok, false, 'expected repo helper to stop on repo resolution error'); + assert.equal(capture.statusCode, 403, 'expected repo helper to emit 403'); + assert.equal(JSON.parse(String(capture.body || '{}')).code, ERROR_CODES.FORBIDDEN); +} + +console.log('API request helpers test passed'); diff --git a/tests/services/api/response-capture.js b/tests/services/api/response-capture.js new file mode 100644 index 000000000..d80a9877f --- /dev/null +++ b/tests/services/api/response-capture.js @@ -0,0 +1,64 @@ +export const createResponseCapture = () => { + const capture = { + statusCode: null, + headers: null, + body: null + }; + return { + capture, + response: { + writeHead(statusCode, headers) { + capture.statusCode = statusCode; + capture.headers = headers; + }, + end(body) { + capture.body = body; + } + } + }; +}; + +export const createMockResponse = () => { + const state = { + statusCode: 0, + headers: {}, + body: '' + }; + return { + res: { + writeHead(statusCode, headers) { + state.statusCode = Number(statusCode) || 0; + state.headers = headers || {}; + }, + end(chunk = '') { + state.body += String(chunk || ''); + } + }, + get statusCode() { + return state.statusCode; + }, + get json() { + return state.body ? JSON.parse(state.body) : null; + } + }; +}; + +export const invokeRouteWithMockResponse = async (routeHandler, { + method = 'GET', + requestUrl, + pathname, + corsHeaders = {}, + req = {}, + ...routeOptions +}) => { + const response = createMockResponse(); + const handled = await routeHandler({ + req: { method, ...req }, + res: response.res, + requestUrl, + pathname, + corsHeaders, + ...routeOptions + }); + return { handled, response }; +}; diff --git a/tests/services/api/risk-pack-metrics.test.js b/tests/services/api/risk-pack-metrics.test.js new file mode 100644 index 000000000..79767da95 --- /dev/null +++ b/tests/services/api/risk-pack-metrics.test.js @@ -0,0 +1,337 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { assembleCompositeContextPack } from '../../../src/context-pack/assemble.js'; +import { getMetricsRegistry } from '../../../src/shared/metrics/core.js'; +import { createApiRouter } from '../../../tools/api/router.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'api-risk-pack-metrics'); +const repoRoot = path.join(tempRoot, 'repo'); +const indexDir = path.join(repoRoot, '.pairofcleats', 'index-code'); +const repoFile = path.join(repoRoot, 'src', 'file.js'); +const repoSource = 'export function risky(input) {\n return query(input);\n}\n'; +const queryExcerpt = 'query(input)'; +const queryOffset = repoSource.indexOf(queryExcerpt); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.mkdir(indexDir, { recursive: true }); +await fs.writeFile(repoFile, repoSource, 'utf8'); + +const writeJsonl = async (filePath, rows) => { + const content = rows.map((row) => JSON.stringify(row)).join('\n'); + await fs.writeFile(filePath, content ? `${content}\n` : '', 'utf8'); +}; + +const buildFlowId = (digit) => `sha1:${String(digit).repeat(40)}`; + +const summaryRow = { + schemaVersion: 1, + chunkUid: 'chunk-risk', + file: 'src/file.js', + languageId: 'javascript', + symbol: { + name: 'risky', + kind: 'FunctionDeclaration', + signature: 'risky(input)' + }, + signals: { + sources: [{ + ruleId: 'source.req.body', + ruleName: 'req.body', + ruleType: 'source', + category: 'input', + severity: 'low', + confidence: 0.6, + tags: ['input'], + evidence: [] + }], + sinks: [{ + ruleId: 'sink.sql.query', + ruleName: 'sql.query', + ruleType: 'sink', + category: 'injection', + severity: 'high', + confidence: 0.9, + tags: ['sql'], + evidence: [] + }], + sanitizers: [], + localFlows: [] + }, + totals: { + sources: 1, + sinks: 1, + sanitizers: 0, + localFlows: 0 + }, + truncated: { + sources: false, + sinks: false, + sanitizers: false, + localFlows: false, + evidence: false + } +}; + +const baseStats = { + schemaVersion: 1, + generatedAt: '2026-03-19T00:00:00.000Z', + mode: 'code', + status: 'ok', + reason: null, + effectiveConfig: { + enabled: true, + summaryOnly: false, + emitArtifacts: 'jsonl' + }, + counts: { + flowsEmitted: 6, + uniqueCallSitesReferenced: 12 + }, + capsHit: [], + provenance: { + indexSignature: 'sig-risk-pack-metrics', + indexCompatKey: 'compat-risk-pack-metrics', + effectiveConfigFingerprint: 'sha1:config-risk-pack-metrics' + }, + artifacts: { + stats: { + name: 'risk_interprocedural_stats', + format: 'json', + sharded: false, + entrypoint: 'risk_interprocedural_stats.json', + totalEntries: 1 + }, + riskSummaries: { + name: 'risk_summaries', + format: 'jsonl', + sharded: false, + entrypoint: 'risk_summaries.jsonl', + totalEntries: 1 + }, + riskFlows: { + name: 'risk_flows', + format: 'jsonl', + sharded: false, + entrypoint: 'risk_flows.jsonl', + totalEntries: 6 + }, + callSites: { + name: 'call_sites', + format: 'jsonl', + sharded: false, + entrypoint: 'call_sites.jsonl', + totalEntries: 12 + } + } +}; + +const buildWatchStep = (index) => ({ + taintIn: ['req.body'], + taintOut: ['input'], + propagatedArgIndices: [0], + boundParams: ['input'], + calleeNormalized: `query${index}`, + sanitizerPolicy: 'terminate', + sanitizerBarrierApplied: false, + sanitizerBarriersBefore: 0, + sanitizerBarriersAfter: 0, + confidenceBefore: 0.6, + confidenceAfter: 0.51, + confidenceDelta: -0.09 +}); + +const buildFlow = ({ + digit, + confidence, + stepCount = 1, + firstStepCallSites = ['cs-1'] +}) => ({ + schemaVersion: 1, + flowId: buildFlowId(digit), + source: { + chunkUid: 'chunk-risk', + ruleId: 'source.req.body', + ruleName: 'req.body', + ruleType: 'source', + category: 'input', + severity: 'low', + confidence: 0.6, + tags: ['input'] + }, + sink: { + chunkUid: `chunk-risk-sink-${digit}`, + ruleId: 'sink.sql.query', + ruleName: 'sql.query', + ruleType: 'sink', + category: 'injection', + severity: 'high', + confidence: 0.9, + tags: ['sql'] + }, + path: { + chunkUids: ['chunk-risk', ...Array.from({ length: stepCount }, (_, index) => `chunk-step-${digit}-${index + 1}`)], + callSiteIdsByStep: [ + firstStepCallSites, + ...Array.from({ length: Math.max(0, stepCount - 1) }, (_, index) => [`cs-${index + 2}`]) + ], + watchByStep: Array.from({ length: stepCount }, (_, index) => buildWatchStep(index + 1)) + }, + confidence, + notes: { + strictness: 'conservative', + sanitizerPolicy: 'terminate', + hopCount: stepCount, + sanitizerBarriersHit: 0, + capsHit: [] + } +}); + +const callSiteRows = Array.from({ length: 12 }, (_, index) => ({ + callSiteId: `cs-${index + 1}`, + callerChunkUid: 'chunk-risk', + file: 'src/file.js', + languageId: 'javascript', + start: queryOffset, + end: queryOffset + queryExcerpt.length, + startLine: 2, + startCol: 10, + endLine: 2, + endCol: 22, + calleeRaw: 'query', + calleeNormalized: 'query', + args: ['input'] +})); + +const flows = [ + buildFlow({ digit: 1, confidence: 0.99, stepCount: 9, firstStepCallSites: ['cs-1', 'cs-2', 'cs-3', 'cs-4'] }), + buildFlow({ digit: 2, confidence: 0.98 }), + buildFlow({ digit: 3, confidence: 0.97 }), + buildFlow({ digit: 4, confidence: 0.96 }), + buildFlow({ digit: 5, confidence: 0.95 }), + buildFlow({ digit: 6, confidence: 0.94 }) +]; + +await writeJsonObjectFile(path.join(indexDir, 'risk_interprocedural_stats.json'), { fields: baseStats }); +await writeJsonl(path.join(indexDir, 'risk_summaries.jsonl'), [summaryRow]); +await writeJsonl(path.join(indexDir, 'risk_flows.jsonl'), flows); +await writeJsonl(path.join(indexDir, 'call_sites.jsonl'), callSiteRows); +await writeJsonObjectFile(path.join(indexDir, 'meta.json'), { + fields: { + version: 1, + createdAt: '2026-03-19T00:00:00.000Z' + } +}); +await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); +await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { + fields: { + version: 2, + artifactSurfaceVersion: 'test', + compatibilityKey: 'compat-risk-pack-metrics', + generatedAt: '2026-03-19T00:00:00.000Z', + mode: 'code', + stage: 'risk-pack-metrics-test', + pieces: [ + { name: 'risk_interprocedural_stats', path: 'risk_interprocedural_stats.json', format: 'json' }, + { name: 'risk_summaries', path: 'risk_summaries.jsonl', format: 'jsonl' }, + { name: 'risk_flows', path: 'risk_flows.jsonl', format: 'jsonl' }, + { name: 'call_sites', path: 'call_sites.jsonl', format: 'jsonl' } + ] + } +}); + +const pack = assembleCompositeContextPack({ + seed: { type: 'chunk', chunkUid: 'chunk-risk' }, + chunkMeta: [{ + id: 0, + file: 'src/file.js', + chunkUid: 'chunk-risk', + start: 0, + end: repoSource.length, + startLine: 1, + endLine: 3 + }], + repoRoot, + indexDir, + includeGraph: false, + includeTypes: false, + includeRisk: true, + includeImports: false, + includeUsages: false, + includeCallersCallees: false, + indexCompatKey: 'compat-risk-pack-metrics', + indexSignature: 'sig-risk-pack-metrics', + now: () => '2026-03-19T00:00:00.000Z' +}); + +assert.equal(pack.risk?.analysisStatus?.code, 'capped', 'expected synthetic pack to exercise capped metrics'); + +const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: repoRoot, + defaultOutput: 'json', + metricsRegistry: getMetricsRegistry() +}); +const server = http.createServer((req, res) => router.handleRequest(req, res)); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +const port = typeof address === 'object' && address ? address.port : 0; + +const metricsBody = await new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port, + path: '/metrics', + method: 'GET' + }, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk.toString(); + }); + res.on('end', () => resolve(body)); + }); + req.on('error', reject); + req.end(); +}); + +try { + assert.match( + metricsBody, + /pairofcleats_risk_pack_caps_hit_total\{status="capped",cap="max_flows"\} 1\b/, + 'expected max_flows cap-hit metric' + ); + assert.match( + metricsBody, + /pairofcleats_risk_pack_caps_hit_total\{status="capped",cap="max_steps_per_flow"\} 1\b/, + 'expected max_steps_per_flow cap-hit metric' + ); + assert.match( + metricsBody, + /pairofcleats_risk_pack_dropped_flows_total\{status="capped",flow_kind="full"\} 1\b/, + 'expected dropped full-flow metric' + ); + assert.match( + metricsBody, + /pairofcleats_risk_pack_truncation_total\{status="capped",cap="max_flows",scope="risk"\} 1\b/, + 'expected max_flows truncation metric' + ); + assert.match( + metricsBody, + /pairofcleats_risk_pack_truncation_total\{status="capped",cap="max_call_sites_per_step",scope="risk"\} 1\b/, + 'expected call-site truncation metric' + ); +} finally { + server.close(); + if (typeof router.close === 'function') router.close(); +} + +console.log('API risk pack metrics test passed'); diff --git a/tests/services/api/router-contract-matrix.test.js b/tests/services/api/router-contract-matrix.test.js new file mode 100644 index 000000000..fd27b7a58 --- /dev/null +++ b/tests/services/api/router-contract-matrix.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; + +import { createApiRouter } from '../../../tools/api/router.js'; +import { createSseResponder } from '../../../tools/api/sse.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const withTimeout = (promise, ms, label) => Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)) +]); + +const runMissingRouteCase = async () => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'api-router'); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + + const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: tempRoot, + defaultOutput: 'json', + metricsRegistry: null + }); + + const server = http.createServer((req, res) => router.handleRequest(req, res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/missing`); + const payload = await response.json(); + + assert.equal(payload.ok, false); + assert.ok(payload.code); + assert.ok(payload.namespaceCode); + assert.ok(typeof payload.hint === 'string' && payload.hint.length > 0); + } finally { + server.close(); + if (typeof router.close === 'function') router.close(); + } +}; + +const runSseBackpressureCase = async () => { + const req = new EventEmitter(); + const res = new EventEmitter(); + res.headersSent = false; + res.writableEnded = false; + res.destroyed = false; + res.writeHead = () => { + res.headersSent = true; + }; + res.write = () => false; + res.end = () => { + res.writableEnded = true; + res.emit('finish'); + }; + + const sse = createSseResponder(req, res); + + const headersPromise = withTimeout(sse.sendHeaders(), 200, 'sendHeaders'); + setTimeout(() => res.emit('close'), 10); + const headersOk = await headersPromise; + assert.equal(headersOk, false); + assert.equal(sse.isClosed(), true); + + const eventResult = await withTimeout(sse.sendEvent('progress', { ok: true }), 200, 'sendEvent'); + assert.equal(eventResult, false); +}; + +await runMissingRouteCase(); +await runSseBackpressureCase(); + +console.log('api router contract matrix test passed'); diff --git a/tests/services/api/router-smoke.test.js b/tests/services/api/router-smoke.test.js deleted file mode 100644 index fb48f58b7..000000000 --- a/tests/services/api/router-smoke.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import http from 'node:http'; -import path from 'node:path'; -import { createApiRouter } from '../../../tools/api/router.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'api-router'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const router = createApiRouter({ - host: '127.0.0.1', - defaultRepo: tempRoot, - defaultOutput: 'json', - metricsRegistry: null -}); - -const server = http.createServer((req, res) => router.handleRequest(req, res)); -await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); -const { port } = server.address(); - -const response = await fetch(`http://127.0.0.1:${port}/missing`); -const payload = await response.json(); - -assert.equal(payload.ok, false, 'expected error payload'); -assert.ok(payload.code, 'expected error code in payload'); -assert.ok(payload.namespaceCode, 'expected namespaced error code in payload'); -assert.ok(typeof payload.hint === 'string' && payload.hint.length > 0, 'expected error hint in payload'); - -server.close(); -if (typeof router.close === 'function') router.close(); - -console.log('api router smoke test passed'); diff --git a/tests/services/api/search-build-identity-observability.test.js b/tests/services/api/search-build-identity-observability.test.js new file mode 100644 index 000000000..a177718c0 --- /dev/null +++ b/tests/services/api/search-build-identity-observability.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { readCurrentBuildGeneration } from '../../../src/shared/indexing/build-pointer.js'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; + +const cohort = await prepareFixtureApiServerCohort({ + cacheName: 'api-search-build-identity', + fixtureOptions: { + requiredModes: ['code'] + } +}); +const { fixtureRoot, userConfig } = cohort; + +const repoCacheRoot = getRepoCacheRoot(fixtureRoot, userConfig); +const currentInfo = readCurrentBuildGeneration({ + currentJsonPath: path.join(repoCacheRoot, 'builds', 'current.json'), + repoCacheRoot, + buildsRoot: path.join(repoCacheRoot, 'builds') +}); + +const { serverInfo, requestJson, stop } = await cohort.start(); + +try { + const response = await requestJson('POST', '/search', { query: 'return', mode: 'code', top: 3 }, serverInfo); + assert.equal(response.status, 200); + assert.equal(response.body?.ok, true); + assert.equal(response.body?.result?.observability?.context?.buildId, currentInfo.buildId); + assert.equal( + response.body?.result?.observability?.context?.activeBuildRoot, + currentInfo.activeRoot, + 'expected API search result observability to expose the active generation root' + ); + assert.equal( + response.body?.result?.observability?.context?.buildGenerationKey, + currentInfo.generationKey, + 'expected API search result observability to expose the active generation key' + ); + assert.equal( + response.body?.result?.retrieval?.freshness?.activeGeneration?.buildId, + currentInfo.buildId, + 'expected API retrieval metadata to expose the active build id' + ); + assert.equal( + response.body?.result?.retrieval?.freshness?.activeGeneration?.activeBuildRoot, + currentInfo.activeRoot, + 'expected API retrieval metadata to expose the active build root' + ); + assert.equal( + response.body?.result?.retrieval?.freshness?.activeGeneration?.buildGenerationKey, + currentInfo.generationKey, + 'expected API retrieval metadata to expose the active generation key' + ); +} finally { + await stop(); +} + +console.log('API search build identity observability test passed'); diff --git a/tests/services/api/search-contract-matrix.test.js b/tests/services/api/search-contract-matrix.test.js new file mode 100644 index 000000000..533bf7558 --- /dev/null +++ b/tests/services/api/search-contract-matrix.test.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { startApiServer } from '../../helpers/api-server.js'; +import { + prepareSharedSearchContractFixture, + SHARED_SEARCH_CONTRACT_CASES +} from '../../helpers/search-contract-cases.js'; + +const fixture = await prepareSharedSearchContractFixture({ + cacheName: 'api-search-contract-matrix' +}); +const emptyRepo = path.join(fixture.workspaceRoot, 'empty-repo'); +await fsPromises.mkdir(emptyRepo, { recursive: true }); + +const runSearchAndHealthCase = async () => { + const { serverInfo, requestJson, requestRaw, stop } = await startApiServer({ + repoRoot: fixture.repoRoot, + env: fixture.env, + allowedRoots: [emptyRepo], + maxBodyBytes: 512, + corsAllowedOrigins: ['example.com'] + }); + try { + for (const entry of SHARED_SEARCH_CONTRACT_CASES) { + const response = await requestJson('POST', '/search', { + query: entry.query, + mode: entry.mode, + top: entry.top + }, serverInfo); + if (response.status !== 200 || response.body?.ok !== true) { + throw new Error(`api ${entry.id} expected ok response`); + } + entry.assertPayload(response.body?.result || {}, { source: 'api' }); + } + + const getWithMetaJsonAlias = await requestJson( + 'GET', + `/search?q=return&mode=code&meta-json=${encodeURIComponent(JSON.stringify({ source: 'query-meta-alias' }))}`, + null, + serverInfo + ); + if (getWithMetaJsonAlias.status !== 200 || getWithMetaJsonAlias.body?.ok !== true) { + throw new Error('api search contract matrix should accept meta-json query param alias'); + } + + const invalid = await requestJson('POST', '/search', {}, serverInfo); + if (invalid.status !== 400 || invalid.body?.ok !== false || invalid.body?.code !== 'INVALID_REQUEST') { + throw new Error('api search contract matrix should reject missing query'); + } + + const missingContentType = await requestRaw( + 'POST', + '/search', + JSON.stringify({ query: 'return' }), + serverInfo, + { headers: {} } + ); + if (missingContentType.status !== 415 || missingContentType.json?.code !== 'INVALID_REQUEST') { + throw new Error('api search contract matrix should reject missing content-type'); + } + + const oversizedPayload = { query: 'return', extra: 'x'.repeat(600) }; + const tooLarge = await requestRaw( + 'POST', + '/search', + JSON.stringify(oversizedPayload), + serverInfo, + { headers: { 'Content-Type': 'application/json' } } + ); + if (tooLarge.status !== 413 || tooLarge.json?.code !== 'INVALID_REQUEST') { + throw new Error('api search contract matrix should enforce body size limits'); + } + + const unknownField = await requestJson('POST', '/search', { + query: 'return', + extraField: true + }, serverInfo); + if (unknownField.status !== 400 || unknownField.body?.code !== 'INVALID_REQUEST') { + throw new Error('api search contract matrix should reject unknown fields'); + } + + const noIndex = await requestJson('POST', '/search', { + repoPath: emptyRepo, + query: 'return' + }, serverInfo); + if (noIndex.status !== 409 || noIndex.body?.code !== 'NO_INDEX') { + throw new Error('api search contract matrix should return NO_INDEX for allowed roots without an index'); + } + + const unauthorized = await requestJson('GET', '/health', null, serverInfo, { auth: false }); + if (unauthorized.status !== 401 || unauthorized.body?.code !== 'UNAUTHORIZED') { + throw new Error('api search contract matrix should reject missing auth'); + } + + const corsBlocked = await requestJson('GET', '/health', null, serverInfo, { + headers: { Origin: 'https://blocked.example' } + }); + if (corsBlocked.status !== 403 || corsBlocked.body?.code !== 'FORBIDDEN') { + throw new Error('api search contract matrix should reject disallowed CORS origins'); + } + + const preflightBlocked = await requestJson('OPTIONS', '/health', null, serverInfo, { + headers: { + Origin: 'https://blocked.example', + 'Access-Control-Request-Method': 'GET' + } + }); + if (preflightBlocked.status !== 403 || preflightBlocked.body?.code !== 'FORBIDDEN') { + throw new Error('api search contract matrix should reject disallowed CORS preflight'); + } + + const origin = 'https://example.com'; + const allowed = await requestJson('GET', '/health', null, serverInfo, { + headers: { Origin: origin } + }); + if (allowed.status !== 200) { + throw new Error('api search contract matrix expected allowed origin to succeed'); + } + const allowHeader = allowed.headers?.['access-control-allow-origin']; + if (allowHeader !== origin) { + throw new Error('api search contract matrix expected access-control-allow-origin header to match origin'); + } + + const health = await requestJson('GET', '/health', null, serverInfo); + if (!health.body?.ok || typeof health.body.uptimeMs !== 'number') { + throw new Error('api search contract matrix /health response invalid'); + } + + const status = await requestJson('GET', '/status', null, serverInfo); + if (!status.body?.ok || !status.body.status?.repo?.root) { + throw new Error('api search contract matrix /status response missing repo info'); + } + if (status.body?.status?.durability?.runtime?.degradedDurability !== false) { + throw new Error('api search contract matrix /status response missing clean durability payload'); + } + if (status.body?.trustBoundary?.effectiveBoundary?.summary !== serverInfo?.trustBoundary?.effectiveBoundary?.summary) { + throw new Error('api search contract matrix /status response missing effective trust boundary summary'); + } + if (typeof status.body?.trustBoundary?.repos?.allowedRepoRootCount !== 'number') { + throw new Error('api search contract matrix /status response missing trust boundary counts'); + } + const statusBody = JSON.stringify(status.body); + if (statusBody.includes(fixture.repoRoot) || statusBody.includes(fixture.cacheRoot)) { + throw new Error('api search contract matrix /status response leaked absolute paths'); + } + } finally { + await stop(); + } +}; + +await runSearchAndHealthCase(); + +console.log('API search contract matrix test passed'); diff --git a/tests/services/api/search-happy-path.test.js b/tests/services/api/search-happy-path.test.js deleted file mode 100644 index 4beebbdb4..000000000 --- a/tests/services/api/search-happy-path.test.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheName = 'api-search-happy'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); - -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [], - env -}); - -try { - const search = await requestJson('POST', '/search', { query: 'return', mode: 'code', top: 3 }, serverInfo); - const hits = search.body?.result?.code || []; - if (!search.body?.ok || !hits.length) { - throw new Error('api-server /search returned no results'); - } - if (hits[0]?.tokens !== undefined) { - throw new Error('api-server /search should default to compact JSON output'); - } - - const getWithMeta = await requestJson( - 'GET', - `/search?q=return&mode=code&meta=kind=function&meta=lang=js&metaJson=${encodeURIComponent(JSON.stringify({ source: 'query-meta' }))}`, - null, - serverInfo - ); - if (getWithMeta.status !== 200 || getWithMeta.body?.ok !== true) { - throw new Error('api-server GET /search should accept meta/metaJson query params'); - } - - const getWithMetaJsonAlias = await requestJson( - 'GET', - `/search?q=return&mode=code&meta-json=${encodeURIComponent(JSON.stringify({ source: 'query-meta-alias' }))}`, - null, - serverInfo - ); - if (getWithMetaJsonAlias.status !== 200 || getWithMetaJsonAlias.body?.ok !== true) { - throw new Error('api-server GET /search should accept meta-json query param alias'); - } -} catch (err) { - console.error(err?.message || err); - process.exit(1); -} finally { - await stop(); -} - -console.log('API search happy path ok.'); diff --git a/tests/services/api/search-stream-abort.test.js b/tests/services/api/search-stream-abort.test.js deleted file mode 100644 index 48f6eaed4..000000000 --- a/tests/services/api/search-stream-abort.test.js +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import http from 'node:http'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheName = 'api-search-stream-abort'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); - -const { serverInfo, requestJson, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [], - env -}); - -const authHeader = { Authorization: 'Bearer test-token' }; - -const abortStream = () => new Promise((resolve, reject) => { - const payload = JSON.stringify({ query: 'return', repoPath: fixtureRoot }); - const req = http.request( - { - host: serverInfo.host, - port: serverInfo.port, - path: '/search/stream', - method: 'POST', - headers: { - ...authHeader, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) - } - }, - (res) => { - res.once('data', () => { - res.destroy(); - resolve(); - }); - } - ); - req.on('error', reject); - req.write(payload); - req.end(); -}); - -try { - await abortStream(); - await new Promise((resolve) => setTimeout(resolve, 50)); - const health = await requestJson('GET', '/health', null, serverInfo); - if (!health.body?.ok) { - throw new Error('api-server should remain healthy after stream abort'); - } -} catch (err) { - console.error(err?.message || err); - process.exit(1); -} finally { - await stop(); -} - -console.log('API search stream abort test passed.'); diff --git a/tests/services/api/search-validation.test.js b/tests/services/api/search-validation.test.js deleted file mode 100644 index a9785086f..000000000 --- a/tests/services/api/search-validation.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; -import { startApiServer } from '../../helpers/api-server.js'; - -const cacheName = 'api-search-validation'; -const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot, env } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); - -const { serverInfo, requestJson, requestRaw, stop } = await startApiServer({ - repoRoot: fixtureRoot, - allowedRoots: [], - maxBodyBytes: 512, - env -}); - -try { - const invalid = await requestJson('POST', '/search', {}, serverInfo); - if (invalid.status !== 400 || invalid.body?.ok !== false || invalid.body?.code !== 'INVALID_REQUEST') { - throw new Error('api-server should reject missing query'); - } - - const missingContentType = await requestRaw( - 'POST', - '/search', - JSON.stringify({ query: 'return' }), - serverInfo, - { headers: {} } - ); - if (missingContentType.status !== 415 || missingContentType.json?.code !== 'INVALID_REQUEST') { - throw new Error('api-server should reject missing content-type'); - } - - const oversizedPayload = { query: 'return', extra: 'x'.repeat(600) }; - const tooLarge = await requestRaw( - 'POST', - '/search', - JSON.stringify(oversizedPayload), - serverInfo, - { headers: { 'Content-Type': 'application/json' } } - ); - if (tooLarge.status !== 413 || tooLarge.json?.code !== 'INVALID_REQUEST') { - throw new Error('api-server should enforce body size limits'); - } - - const unknownField = await requestJson('POST', '/search', { - query: 'return', - extraField: true - }, serverInfo); - if (unknownField.status !== 400 || unknownField.body?.code !== 'INVALID_REQUEST') { - throw new Error('api-server should reject unknown fields'); - } -} catch (err) { - console.error(err?.message || err); - process.exit(1); -} finally { - await stop(); -} - -console.log('API search validation ok.'); diff --git a/tests/services/api/server-stream.test.js b/tests/services/api/server-stream.test.js new file mode 100644 index 000000000..208ef3601 --- /dev/null +++ b/tests/services/api/server-stream.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; + +const cohort = await prepareFixtureApiServerCohort({ + fixtureName: 'call-sites-determinism', + cacheName: 'api-server-stream', + fixtureOptions: { + requiredModes: ['code'] + } +}); + +let serverInfo = null; +let stopServer = null; +try { + const started = await cohort.start(); + serverInfo = started.serverInfo; + stopServer = started.stop; + if (!serverInfo?.port) { + throw new Error('api-server did not report a listening port'); + } + + const statusStream = await started.requestSse('GET', '/status/stream', null, serverInfo, { + stopOnEvent: 'done' + }); + const statusEvents = statusStream.events; + const statusResult = statusEvents.find((evt) => evt.event === 'result'); + if (!statusResult?.data?.status?.repo?.root) { + throw new Error('status stream missing repo payload'); + } + const statusBody = JSON.stringify(statusResult.data || {}); + if (statusBody.includes(cohort.fixtureRoot) || statusBody.includes(cohort.cacheRoot)) { + throw new Error('status stream leaked absolute paths'); + } + + const searchStream = await started.requestSse('POST', '/search/stream', { query: 'return', mode: 'code' }, serverInfo, { + stopOnEvent: 'done' + }); + const searchEvents = searchStream.events; + const searchResult = searchEvents.find((evt) => evt.event === 'result'); + const hits = searchResult?.data?.result?.code || []; + if (!hits.length) { + throw new Error('search stream returned no results'); + } + + await started.requestSse('POST', '/search/stream', { query: 'return', mode: 'code' }, serverInfo, { + abortAfterFirstChunk: true, + abortTimeoutMs: 1000 + }); + const followUpStream = await started.requestSse('GET', '/status/stream', null, serverInfo, { + stopOnEvent: 'done' + }); + const followUp = followUpStream.events; + const followResult = followUp.find((evt) => evt.event === 'result'); + if (!followResult?.data?.status?.repo?.root) { + throw new Error('stream abort should not break subsequent requests'); + } + const followBody = JSON.stringify(followResult.data || {}); + if (followBody.includes(cohort.fixtureRoot) || followBody.includes(cohort.cacheRoot)) { + throw new Error('follow-up status stream leaked absolute paths'); + } +} catch (err) { + console.error(err?.message || err); + process.exit(1); +} finally { + if (typeof stopServer === 'function') { + await stopServer(); + } +} + +console.log('api-server stream tests passed'); + diff --git a/tests/services/api/sse-backpressure.test.js b/tests/services/api/sse-backpressure.test.js deleted file mode 100644 index 01fdf570d..000000000 --- a/tests/services/api/sse-backpressure.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { EventEmitter } from 'node:events'; -import { createSseResponder } from '../../../tools/api/sse.js'; - -const withTimeout = (promise, ms, label) => Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)) -]); - -const req = new EventEmitter(); -const res = new EventEmitter(); -res.headersSent = false; -res.writableEnded = false; -res.destroyed = false; -res.writeHead = () => { - res.headersSent = true; -}; -res.write = () => false; -res.end = () => { - res.writableEnded = true; - res.emit('finish'); -}; - -const sse = createSseResponder(req, res); - -const headersPromise = withTimeout(sse.sendHeaders(), 200, 'sendHeaders'); -setTimeout(() => res.emit('close'), 10); -const headersOk = await headersPromise; -assert.equal(headersOk, false); -assert.equal(sse.isClosed(), true); - -const eventResult = await withTimeout(sse.sendEvent('progress', { ok: true }), 200, 'sendEvent'); -assert.equal(eventResult, false); - -console.log('SSE backpressure test passed'); diff --git a/tests/services/api/status-durability.test.js b/tests/services/api/status-durability.test.js new file mode 100644 index 000000000..b647347b8 --- /dev/null +++ b/tests/services/api/status-durability.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { atomicWriteText, resetAtomicWriteRuntimeMetricsForTests } from '../../../src/shared/io/atomic-write.js'; +import { status } from '../../../src/integrations/core/status.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'api-status-durability'); +const repoRoot = path.join(tempRoot, 'repo'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +applyTestEnv({ cacheRoot: tempRoot }); +resetAtomicWriteRuntimeMetricsForTests(); + +const targetPath = path.join(repoRoot, 'degraded.txt'); +const originalRename = fsPromises.rename; +let exdevAttempts = 0; +fsPromises.rename = async (...args) => { + const [, nextTarget] = args; + if (String(nextTarget || '').includes('degraded.txt')) { + exdevAttempts += 1; + const err = new Error('cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + } + return originalRename(...args); +}; +try { + await atomicWriteText(targetPath, 'degraded-ok'); +} finally { + fsPromises.rename = originalRename; +} + +assert.equal(fs.readFileSync(targetPath, 'utf8'), 'degraded-ok'); +assert.ok(exdevAttempts >= 1, 'expected EXDEV fallback path to execute'); + +const payload = await status({ repoRoot }); +assert.equal(payload?.durability?.runtime?.degradedDurability, true, 'expected status payload to expose degraded durability'); +assert.equal(payload?.durability?.runtime?.exdevRenameFallbackCount >= 1, true, 'expected status payload to count EXDEV fallback usage'); +assert.equal(typeof payload?.durability?.runtime?.lastExdevFallbackAt, 'string', 'expected status payload to expose last EXDEV fallback timestamp'); + +console.log('API status durability test passed'); diff --git a/tests/services/api/status-repo-error-classification.test.js b/tests/services/api/status-repo-error-classification.test.js new file mode 100644 index 000000000..60196e01e --- /dev/null +++ b/tests/services/api/status-repo-error-classification.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; + +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { createApiRouter } from '../../../tools/api/router.js'; +import { parseSseEvents } from '../../helpers/api-server.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'api-status-repo-error-classification'); +const allowedRepo = path.join(tempRoot, 'allowed'); +const forbiddenRepo = path.join(tempRoot, 'forbidden'); +const missingRepo = path.join(allowedRepo, 'missing'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(allowedRepo, { recursive: true }); +await fs.mkdir(forbiddenRepo, { recursive: true }); + +const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: allowedRepo, + defaultOutput: 'json', + metricsRegistry: null +}); +const server = http.createServer((req, res) => router.handleRequest(req, res)); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const { port } = server.address(); + +const requestJson = async (requestPath) => { + const response = await fetch(`http://127.0.0.1:${port}${requestPath}`); + return { + status: response.status, + body: await response.json() + }; +}; + +const requestSse = async (requestPath) => { + const response = await fetch(`http://127.0.0.1:${port}${requestPath}`); + return { + status: response.status, + events: parseSseEvents(await response.text()) + }; +}; + +try { + const missingStatus = await requestJson(`/status?repo=${encodeURIComponent(missingRepo)}`); + assert.equal(missingStatus.status, 400, 'status should map missing allowed repo to 400'); + assert.equal(missingStatus.body?.code, ERROR_CODES.INVALID_REQUEST); + + const forbiddenStatus = await requestJson(`/status?repo=${encodeURIComponent(forbiddenRepo)}`); + assert.equal(forbiddenStatus.status, 403, 'status should map forbidden repo to 403'); + assert.equal(forbiddenStatus.body?.code, ERROR_CODES.FORBIDDEN); + + const missingStream = await requestSse(`/status/stream?repo=${encodeURIComponent(missingRepo)}`); + const missingStreamError = missingStream.events.find((entry) => entry.event === 'error'); + assert.equal(missingStream.status, 200, 'status stream should return SSE envelope status'); + assert.equal(missingStreamError?.data?.code, ERROR_CODES.INVALID_REQUEST); + + const forbiddenStream = await requestSse(`/status/stream?repo=${encodeURIComponent(forbiddenRepo)}`); + const forbiddenStreamError = forbiddenStream.events.find((entry) => entry.event === 'error'); + assert.equal(forbiddenStream.status, 200, 'status stream should return SSE envelope status'); + assert.equal(forbiddenStreamError?.data?.code, ERROR_CODES.FORBIDDEN); +} finally { + await new Promise((resolve) => server.close(resolve)); + if (typeof router.close === 'function') { + router.close(); + } +} + +console.log('API status repo error classification test passed'); diff --git a/tests/services/api/trust-boundary-matrix.test.js b/tests/services/api/trust-boundary-matrix.test.js new file mode 100644 index 000000000..252c9bd9d --- /dev/null +++ b/tests/services/api/trust-boundary-matrix.test.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { buildApiTrustBoundaryStatusView } from '../../../tools/api/trust-boundary.js'; +import { prepareFixtureApiServerCohort } from '../../helpers/api-server.js'; + +const cohort = await prepareFixtureApiServerCohort({ + cacheName: 'api-trust-boundary-matrix' +}); +const allowedExtraRoot = path.join(cohort.cacheRoot, 'allowed-extra'); +await fsPromises.mkdir(allowedExtraRoot, { recursive: true }); + +const expectStartupFailure = async (options, expectedMessage) => { + let failed = false; + try { + await cohort.start(options); + } catch (error) { + failed = true; + assert.match(String(error?.message || error), expectedMessage); + } + assert.equal(failed, true, `expected startup failure matching ${expectedMessage}`); +}; + +await expectStartupFailure({ + allowedRoots: [], + host: '0.0.0.0', + authToken: '', + allowUnauthenticated: false, + startupTimeoutMs: 5000 +}, /requires PAIROFCLEATS_API_TOKEN|requires .*--auth-token/i); + +await expectStartupFailure({ + allowedRoots: [], + host: '0.0.0.0', + authToken: '', + allowUnauthenticated: true, + startupTimeoutMs: 5000 +}, /refuses --allow-unauthenticated/i); + +await expectStartupFailure({ + allowedRoots: [], + host: '0.0.0.0', + authToken: 'remote-token', + corsAllowAny: true, + startupTimeoutMs: 5000 +}, /refuses --cors-allow-any/i); + +const localUnauthenticated = await cohort.start({ + allowedRoots: [allowedExtraRoot], + host: '127.0.0.1', + authToken: '', + allowUnauthenticated: true +}); + +try { + assert.equal(localUnauthenticated.serverInfo?.trustBoundary?.bind?.scope, 'local'); + assert.equal(localUnauthenticated.serverInfo?.trustBoundary?.auth?.required, false); + assert.equal(localUnauthenticated.serverInfo?.trustBoundary?.repos?.mode, 'allowlisted'); + assert.ok( + Array.isArray(localUnauthenticated.serverInfo?.trustBoundary?.repos?.effectiveAllowedRepoRoots) + && localUnauthenticated.serverInfo.trustBoundary.repos.effectiveAllowedRepoRoots.length >= 2, + 'expected local startup to report effective allowed repo roots' + ); +} finally { + await localUnauthenticated.stop(); +} + +const remoteAuthenticated = await cohort.start({ + allowedRoots: [allowedExtraRoot], + host: '0.0.0.0', + authToken: 'remote-token' +}); + +try { + const capabilities = await remoteAuthenticated.requestJson('GET', '/capabilities', null, remoteAuthenticated.serverInfo); + assert.equal(capabilities.status, 200, 'expected authenticated remote /capabilities to succeed'); + assert.equal(remoteAuthenticated.serverInfo?.trustBoundary?.bind?.scope, 'non-local'); + assert.equal(remoteAuthenticated.serverInfo?.trustBoundary?.auth?.required, true); + assert.deepEqual( + capabilities.body?.trustBoundary, + buildApiTrustBoundaryStatusView(remoteAuthenticated.serverInfo?.trustBoundary), + 'expected /capabilities to expose a redacted trust-boundary status view' + ); +} finally { + await remoteAuthenticated.stop(); +} + +console.log('API trust boundary matrix test passed'); diff --git a/tests/services/api/workspace-allowlist.test.js b/tests/services/api/workspace-allowlist.test.js new file mode 100644 index 000000000..daf40fcc8 --- /dev/null +++ b/tests/services/api/workspace-allowlist.test.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createWorkspaceAllowlist } from '../../../tools/api/router/workspace-allowlist.js'; +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { normalizeIdentityPath } from '../../../src/workspace/identity.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('api-workspace-allowlist'); +const allowedRoot = path.join(tempRoot, 'allowed'); +const outsideRoot = path.join(tempRoot, 'outside'); +const repoRoot = path.join(allowedRoot, 'repo-a'); +const workspaceDir = path.join(allowedRoot, 'workspace'); +const workspacePath = path.join(workspaceDir, '.pairofcleats-workspace.jsonc'); +const allowedCacheRoot = path.join(allowedRoot, 'workspace-cache'); +const outsideCacheRoot = path.join(outsideRoot, 'workspace-cache'); + +await fs.mkdir(repoRoot, { recursive: true }); +await fs.mkdir(workspaceDir, { recursive: true }); +await fs.mkdir(outsideRoot, { recursive: true }); + +const writeWorkspace = async ({ cacheRoot = allowedCacheRoot } = {}) => { + await fs.writeFile(workspacePath, JSON.stringify({ + schemaVersion: 1, + name: 'API Workspace Allowlist', + cacheRoot, + repos: [ + { root: repoRoot, alias: 'repo-a' } + ] + }, null, 2), 'utf8'); +}; + +const resolvedRepos = []; +const allowlist = createWorkspaceAllowlist({ + defaultRepo: allowedRoot, + allowedRepoRoots: [], + resolveRepo: async (repoPath) => { + resolvedRepos.push(repoPath); + return repoPath; + } +}); + +try { + await writeWorkspace(); + const workspaceConfig = await allowlist.ensureWorkspaceAllowlist({ workspacePath }); + assert.equal( + workspaceConfig.workspacePath, + normalizeIdentityPath(workspacePath), + 'expected workspace path to round-trip' + ); + assert.equal(resolvedRepos.length, 1, 'expected workspace repos to be validated through resolveRepo'); + assert.equal(resolvedRepos[0], workspaceConfig.repos[0].repoRootCanonical); + + await assert.rejects( + () => allowlist.ensureWorkspaceAllowlist({ + workspacePath, + workspaceId: 'workspace-id-mismatch' + }), + /workspaceId does not match/i, + 'expected workspaceId mismatch to be rejected' + ); + + await writeWorkspace({ cacheRoot: outsideCacheRoot }); + await assert.rejects( + () => allowlist.ensureWorkspaceAllowlist({ workspacePath }), + (err) => err?.code === ERROR_CODES.FORBIDDEN + && /cache root not permitted/i.test(String(err?.message || '')), + 'expected cache roots outside the configured allowlist to be forbidden' + ); + + await assert.rejects( + () => allowlist.ensureWorkspaceAllowlist({ + workspacePath: path.join(outsideRoot, '.pairofcleats-workspace.jsonc') + }), + (err) => err?.code === ERROR_CODES.FORBIDDEN + && /workspace path not permitted/i.test(String(err?.message || '')), + 'expected workspace files outside the configured allowlist to be forbidden' + ); + + console.log('API workspace allowlist test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/services/asof-explicit-root-no-fallback.test.js b/tests/services/asof-explicit-root-no-fallback.test.js deleted file mode 100644 index 0587fc235..000000000 --- a/tests/services/asof-explicit-root-no-fallback.test.js +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { ensureFixtureIndex } from '../helpers/fixture-index.js'; -import { runSearchCli } from '../../src/retrieval/cli.js'; -import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; -import { getRepoCacheRoot, loadUserConfig } from '../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const cacheName = 'asof-explicit-root-no-fallback'; -const cacheRoot = resolveTestCachePath(root, cacheName); -await fs.rm(cacheRoot, { recursive: true, force: true }); - -const { fixtureRoot } = await ensureFixtureIndex({ - fixtureName: 'sample', - cacheName, - cacheScope: 'shared' -}); -const userConfig = loadUserConfig(fixtureRoot); -const repoCacheRoot = getRepoCacheRoot(fixtureRoot, userConfig); - -const snapshotId = 'snap-20260212000000-nofb01'; -await createPointerSnapshot({ - repoRoot: fixtureRoot, - userConfig, - modes: ['code'], - snapshotId -}); - -const snapshotPath = path.join(repoCacheRoot, 'snapshots', snapshotId, 'snapshot.json'); -const snapshotJson = JSON.parse(await fs.readFile(snapshotPath, 'utf8')); -snapshotJson.pointer = snapshotJson.pointer || {}; -snapshotJson.pointer.buildRootsByMode = snapshotJson.pointer.buildRootsByMode || {}; -snapshotJson.pointer.buildRootsByMode.code = 'builds/missing-build-root'; -snapshotJson.pointer.buildRoot = 'builds/missing-build-root'; -await fs.writeFile(snapshotPath, `${JSON.stringify(snapshotJson, null, 2)}\n`, 'utf8'); - -await assert.rejects( - () => runSearchCli([ - '--repo', - fixtureRoot, - '--mode', - 'code', - '--backend', - 'memory', - '--json', - '--compact', - '--as-of', - `snap:${snapshotId}`, - '--', - 'return' - ], { emitOutput: false, exitOnError: false }), - /missing build root/i, - 'explicit as-of snapshot should fail fast when its build root is missing' -); - -const latest = await runSearchCli([ - '--repo', - fixtureRoot, - '--mode', - 'code', - '--backend', - 'memory', - '--json', - '--compact', - '--', - 'return' -], { emitOutput: false, exitOnError: false }); - -assert.ok(Array.isArray(latest.code) && latest.code.length > 0, 'latest search should still succeed'); - -console.log('as-of explicit root no-fallback test passed'); diff --git a/tests/services/core-observability.test.js b/tests/services/core-observability.test.js new file mode 100644 index 000000000..b3eeb0c33 --- /dev/null +++ b/tests/services/core-observability.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { buildIndex, search } from '../../src/integrations/core/index.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { withTemporaryEnv } from '../helpers/test-env.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const cacheRoot = resolveTestCachePath(root, 'core-observability'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +await withTemporaryEnv({ + PAIROFCLEATS_TESTING: '1', + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_EMBEDDINGS: 'stub' +}, async () => { + const buildCorrelationId = 'core-build-correlation'; + const buildResult = await buildIndex(fixtureRoot, { + stage: 'stage2', + mode: 'code', + sqlite: false, + stubEmbeddings: true, + log: () => {}, + observability: { + correlation: { + correlationId: buildCorrelationId, + requestId: 'core-build-request' + } + } + }); + assert.equal( + buildResult?.observability?.correlation?.correlationId, + buildCorrelationId, + 'expected buildIndex to preserve explicit correlation id' + ); + assert.equal(buildResult?.observability?.surface, 'build'); + assert.equal(buildResult?.observability?.operation, 'build_index'); + + const searchCorrelationId = 'core-search-correlation'; + const searchResult = await search(fixtureRoot, { + query: 'return', + mode: 'code', + json: true, + observability: { + correlation: { + correlationId: searchCorrelationId, + requestId: 'core-search-request' + } + } + }); + assert.equal( + searchResult?.observability?.correlation?.correlationId, + searchCorrelationId, + 'expected search to preserve explicit correlation id' + ); + assert.equal(searchResult?.observability?.surface, 'search'); + assert.equal(searchResult?.observability?.operation, 'search'); + assert.ok(Array.isArray(searchResult?.code), 'expected search results to remain intact'); +}); + +console.log('core observability test passed'); diff --git a/tests/services/golden/advanced-surface-goldens.json b/tests/services/golden/advanced-surface-goldens.json new file mode 100644 index 000000000..c8e574aa8 --- /dev/null +++ b/tests/services/golden/advanced-surface-goldens.json @@ -0,0 +1,352 @@ +{ + "apiDiffFiltered": { + "request": { + "eventsPath": "/index/diffs//events?mode=code&kind=file.modified&maxEvents=1", + "showPath": "/index/diffs/?format=jsonl&mode=code&kind=file.modified&max-events=1" + }, + "response": { + "body": { + "diff": { + "entry": { + "from": { + "ref": "snap:snap-20260212000000-goldena", + "snapshotId": "snap-20260212000000-goldena" + }, + "id": "", + "modes": [ + "code" + ], + "to": { + "ref": "snap:snap-20260212000000-goldenb", + "snapshotId": "snap-20260212000000-goldenb" + } + }, + "events": [ + { + "chunkId": null, + "file": "src/phase14-advanced-surface.js", + "kind": "file.modified" + } + ], + "summary": { + "from": { + "ref": "snap:snap-20260212000000-goldena", + "snapshotId": "snap-20260212000000-goldena" + }, + "id": "", + "limits": { + "maxBytes": 2097152, + "maxEvents": 20000, + "reason": null + }, + "modes": [ + "code" + ], + "to": { + "ref": "snap:snap-20260212000000-goldenb", + "snapshotId": "snap-20260212000000-goldenb" + }, + "totals": { + "allEvents": 3, + "byKind": { + "chunk.added": 1, + "chunk.removed": 1, + "file.modified": 1 + }, + "emittedEvents": 3 + }, + "truncated": false + } + }, + "ok": true + }, + "status": 200 + }, + "streamedEvents": [ + { + "chunkId": null, + "file": "src/phase14-advanced-surface.js", + "kind": "file.modified" + } + ] + }, + "apiSearchAsOf": { + "request": { + "args": [ + "--json", + "--as-of", + "snap:snap-20260212000000-goldenb", + "--mode", + "code", + "--top", + "50" + ], + "payload": { + "asOf": "snap:snap-20260212000000-goldenb", + "mode": "code", + "query": "phase14beta", + "top": 50 + }, + "queryString": "q=phase14beta&mode=code&top=50&asOf=snap%3Asnap-20260212000000-goldenb" + }, + "response": { + "body": { + "ok": true, + "result": { + "asOf": { + "ref": "snap:snap-20260212000000-goldenb" + }, + "code": [ + { + "end": 45, + "file": "src/phase14-advanced-surface.js", + "repoAlias": null, + "start": 0 + } + ] + } + }, + "status": 200 + } + }, + "federatedRisk": { + "degradedRequest": { + "args": [ + "--json", + "--compact", + "--mode", + "code", + "--risk-tag", + "security", + "--risk-category", + "injection", + "--risk-flow", + "request.body->eval", + "--top", + "4" + ], + "payload": { + "limits": { + "concurrency": 1, + "perRepoTop": 4 + }, + "query": "risky-degraded", + "search": { + "mode": "code", + "riskCategory": "injection", + "riskFlow": "request.body->eval", + "riskTag": "security", + "top": 3 + } + } + }, + "degradedResponse": { + "backend": "federated", + "code": [ + { + "end": 1, + "file": "src/beta.js", + "repoAlias": "beta", + "start": 1 + } + ], + "meta": { + "limits": { + "concurrency": 1, + "merge": "rrf", + "perRepoTop": 4, + "rrfK": 60, + "top": 3 + }, + "selection": { + "selectedRepos": [ + { + "alias": "alpha", + "enabled": true, + "priority": 10 + }, + { + "alias": "beta", + "enabled": true, + "priority": 5 + } + ] + }, + "workspace": { + "name": "", + "workspaceId": "" + } + }, + "ok": true, + "repos": [ + { + "error": { + "code": "NO_INDEX", + "message": "simulated missing index" + }, + "status": "missing_index" + }, + { + "error": null, + "status": "ok" + } + ], + "warnings": [] + }, + "request": { + "args": [ + "--json", + "--compact", + "--mode", + "code", + "--risk-tag", + "security", + "--risk-category", + "injection", + "--risk-flow", + "request.body->eval", + "--top", + "4" + ], + "payload": { + "limits": { + "concurrency": 1, + "perRepoTop": 4 + }, + "query": "risky", + "search": { + "mode": "code", + "riskCategory": "injection", + "riskFlow": "request.body->eval", + "riskTag": "security", + "top": 3 + } + } + }, + "successResponse": { + "backend": "federated", + "code": [ + { + "end": 1, + "file": "src/alpha.js", + "repoAlias": "alpha", + "start": 1 + }, + { + "end": 1, + "file": "src/beta.js", + "repoAlias": "beta", + "start": 1 + } + ], + "meta": { + "limits": { + "concurrency": 1, + "merge": "rrf", + "perRepoTop": 4, + "rrfK": 60, + "top": 3 + }, + "selection": { + "selectedRepos": [ + { + "alias": "alpha", + "enabled": true, + "priority": 10 + }, + { + "alias": "beta", + "enabled": true, + "priority": 5 + } + ] + }, + "workspace": { + "name": "", + "workspaceId": "" + } + }, + "ok": true, + "repos": [ + { + "error": null, + "status": "ok" + }, + { + "error": null, + "status": "ok" + } + ], + "warnings": [] + } + }, + "federatedWorkspace": { + "request": { + "args": [ + "--json", + "--compact", + "--mode", + "code", + "--top", + "4" + ], + "payload": { + "limits": { + "concurrency": 1, + "perRepoTop": 4 + }, + "query": "workspace-risk", + "search": { + "mode": "code", + "top": 3 + }, + "select": { + "tag": [ + "team-a" + ] + } + } + }, + "response": { + "backend": "federated", + "code": [ + { + "end": 1, + "file": "src/alpha.js", + "repoAlias": "alpha", + "start": 1 + } + ], + "meta": { + "limits": { + "concurrency": 1, + "merge": "rrf", + "perRepoTop": 4, + "rrfK": 60, + "top": 3 + }, + "selection": { + "selectedRepos": [ + { + "alias": "alpha", + "enabled": true, + "priority": 10 + } + ] + }, + "workspace": { + "name": "", + "workspaceId": "" + } + }, + "ok": true, + "repos": [ + { + "error": null, + "status": "ok" + } + ], + "warnings": [] + } + } +} diff --git a/tests/services/index-diff.test.js b/tests/services/index-diff.test.js deleted file mode 100644 index 7e8ec64d4..000000000 --- a/tests/services/index-diff.test.js +++ /dev/null @@ -1,451 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../src/shared/dict-utils.js'; -import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; -import { computeIndexDiff, showDiff } from '../../src/index/diffs/compute.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-diff-service'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const userConfig = { - cache: { root: cacheRoot }, - sqlite: { use: false }, - lmdb: { use: false } -}; - -const writeJson = async (filePath, value) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); -}; - -const sha1Value = (value) => crypto.createHash('sha1').update(String(value)).digest('hex'); - -const sha1File = async (filePath) => { - const content = await fs.readFile(filePath); - return crypto.createHash('sha1').update(content).digest('hex'); -}; - -const writePiecesManifest = async (indexDir, files) => { - const entries = []; - for (const file of files) { - const absolute = path.join(indexDir, file.path); - const stat = await fs.stat(absolute); - entries.push({ - type: file.type, - name: file.name, - format: 'json', - path: file.path, - bytes: Number(stat.size || 0), - checksum: `sha1:${await sha1File(absolute)}` - }); - } - await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { - version: 2, - artifactSurfaceVersion: '0.2.0', - pieces: entries - }); -}; - -const seedBuild = async ({ - repoCacheRoot, - buildId, - files, - chunkSignature, - configHash, - toolVersion, - fileMetaRows = null, - chunkMetaRows = null -}) => { - const buildRoot = path.join(repoCacheRoot, 'builds', buildId); - const indexDir = path.join(buildRoot, 'index-code'); - await fs.mkdir(indexDir, { recursive: true }); - - const fileMeta = Array.isArray(fileMetaRows) && fileMetaRows.length - ? fileMetaRows - : files.map((entry, index) => ({ - id: index + 1, - file: entry.file, - hash: sha1Value(entry.content), - size: entry.content.length, - ext: 'js' - })); - await writeJson(path.join(indexDir, 'file_meta.json'), fileMeta); - - const chunkMeta = Array.isArray(chunkMetaRows) && chunkMetaRows.length - ? chunkMetaRows - : files.map((entry, index) => ({ - id: index, - fileId: index + 1, - file: entry.file, - start: 0, - end: entry.content.length, - startLine: 1, - endLine: 1, - kind: 'function', - name: entry.file, - chunkId: entry.chunkId, - metaV2: { - chunkId: entry.chunkId, - chunkUid: `ck:${buildId}:${entry.chunkId}`, - signature: chunkSignature[entry.file], - virtualPath: entry.file, - file: entry.file - } - })); - await writeJson(path.join(indexDir, 'chunk_meta.json'), chunkMeta); - - await writeJson(path.join(indexDir, 'index_state.json'), { - generatedAt: new Date().toISOString(), - mode: 'code', - artifactSurfaceVersion: '0.2.0', - buildId, - configHash, - tool: { version: toolVersion } - }); - - await writePiecesManifest(indexDir, [ - { type: 'meta', name: 'file_meta', path: 'file_meta.json' }, - { type: 'chunks', name: 'chunk_meta', path: 'chunk_meta.json' }, - { type: 'stats', name: 'index_state', path: 'index_state.json' } - ]); - - await writeJson(path.join(buildRoot, 'build_state.json'), { - schemaVersion: 1, - buildId, - configHash, - tool: { version: toolVersion }, - validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } - }); - return buildRoot; -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-a', - files: [ - { file: 'src/a.js', content: 'export const a = 1;', chunkId: 'chunk-a' } - ], - chunkSignature: { 'src/a.js': 'sig-a' }, - configHash: 'cfg-shared', - toolVersion: '1.0.0' -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-a', - buildRoot: 'builds/build-a', - buildRoots: { code: 'builds/build-a' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-diffa' -}); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-b', - files: [ - { file: 'src/a.js', content: 'export const a = 2;', chunkId: 'chunk-a' }, - { file: 'src/b.js', content: 'export const b = 1;', chunkId: 'chunk-b' } - ], - chunkSignature: { 'src/a.js': 'sig-b', 'src/b.js': 'sig-new' }, - configHash: 'cfg-shared', - toolVersion: '1.0.0' -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-b', - buildRoot: 'builds/build-b', - buildRoots: { code: 'builds/build-b' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-diffb' -}); - -const first = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffb', - modes: ['code'], - includeRelations: false, - persist: true -}); -assert.equal(first.persisted, true, 'expected persisted diff result'); -assert.ok(first.diffId.startsWith('diff_'), 'expected deterministic diff id prefix'); -assert.ok( - Number(first.summary?.totals?.byKind?.['file.modified'] || 0) >= 1, - 'expected file.modified event in summary' -); - -const shown = showDiff({ - repoRoot, - userConfig, - diffId: first.diffId, - format: 'jsonl' -}); -assert.ok(Array.isArray(shown.events) && shown.events.length > 0, 'expected persisted events'); -const chunkModified = shown.events.find((event) => event.kind === 'chunk.modified'); -assert.ok(chunkModified, 'expected chunk.modified event'); -assert.equal(chunkModified.chunkId, 'chunk-a', 'chunk events must use stable metaV2.chunkId'); - -const second = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffb', - modes: ['code'], - includeRelations: false, - persist: true -}); -assert.equal(second.diffId, first.diffId, 'diffId should be deterministic for identical inputs'); -assert.equal(second.reused, true, 'second run should reuse existing persisted diff'); - -const truncated = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffb', - modes: ['code'], - includeRelations: false, - persist: false, - maxEvents: 1 -}); -assert.equal(truncated.summary.truncated, true, 'expected truncation when maxEvents is low'); -assert.equal(Array.isArray(truncated.events) ? truncated.events.length : 0, 1, 'expected bounded events list'); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-c', - files: [ - { file: 'src/a.js', content: 'export const a = 3;', chunkId: 'chunk-a' } - ], - chunkSignature: { 'src/a.js': 'sig-c' }, - configHash: 'cfg-different', - toolVersion: '1.0.0' -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-c', - buildRoot: 'builds/build-c', - buildRoots: { code: 'builds/build-c' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-diffc' -}); - -await assert.rejects( - () => computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffc', - modes: ['code'], - persist: false - }), - /configHash mismatch/, - 'config mismatch should fail without allowMismatch' -); - -const mismatchAllowed = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffc', - modes: ['code'], - allowMismatch: true, - persist: false -}); -assert.equal(mismatchAllowed.summary.compat.configHashMismatch, true, 'mismatch should be annotated when allowed'); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-d', - files: [ - { file: 'src/a.js', content: 'export const a = 4;', chunkId: 'chunk-a' } - ], - chunkSignature: { 'src/a.js': 'sig-d' }, - configHash: 'cfg-shared', - toolVersion: '9.9.9' -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-d', - buildRoot: 'builds/build-d', - buildRoots: { code: 'builds/build-d' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-diffd' -}); - -const toolMismatch = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffa', - to: 'snap:snap-20260212000000-diffd', - modes: ['code'], - persist: false -}); -assert.equal(toolMismatch.summary.compat.toolVersionMismatch, true, 'tool mismatch should be annotated'); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-e', - files: [], - chunkSignature: {}, - configHash: 'cfg-shared', - toolVersion: '1.0.0', - fileMetaRows: [ - { - id: 1, - file: 'src/many.js', - hash: sha1Value('export const many = 1;'), - size: 'export const many = 1;'.length, - ext: 'js' - } - ], - chunkMetaRows: [ - { - id: 0, - fileId: 1, - file: 'src/many.js', - start: 0, - end: 22, - startLine: 1, - endLine: 1, - kind: 'function', - name: 'many-a', - chunkId: 'many-chunk-a', - metaV2: { - chunkId: 'many-chunk-a', - chunkUid: 'ck:build-e:many-chunk-a', - signature: 'sig-many-e-a', - virtualPath: 'src/many.js', - file: 'src/many.js' - } - } - ] -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-e', - buildRoot: 'builds/build-e', - buildRoots: { code: 'builds/build-e' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-diffe' -}); - -await seedBuild({ - repoCacheRoot, - buildId: 'build-f', - files: [], - chunkSignature: {}, - configHash: 'cfg-shared', - toolVersion: '1.0.0', - fileMetaRows: [ - { - id: 1, - file: 'src/many.js', - hash: sha1Value('export const many = 2;'), - size: 'export const many = 2;'.length, - ext: 'js' - } - ], - chunkMetaRows: [ - { - id: 0, - fileId: 1, - file: 'src/many.js', - start: 0, - end: 11, - startLine: 1, - endLine: 1, - kind: 'function', - name: 'many-a', - chunkId: 'many-chunk-a', - metaV2: { - chunkId: 'many-chunk-a', - chunkUid: 'ck:build-f:many-chunk-a', - signature: 'sig-many-f-a', - virtualPath: 'src/many.js', - file: 'src/many.js' - } - }, - { - id: 1, - fileId: 1, - file: 'src/many.js', - start: 11, - end: 22, - startLine: 1, - endLine: 1, - kind: 'function', - name: 'many-b', - chunkId: 'many-chunk-b', - metaV2: { - chunkId: 'many-chunk-b', - chunkUid: 'ck:build-f:many-chunk-b', - signature: 'sig-many-f-b', - virtualPath: 'src/many.js', - file: 'src/many.js' - } - } - ] -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-f', - buildRoot: 'builds/build-f', - buildRoots: { code: 'builds/build-f' } -}); -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-difff' -}); - -const chunkLimited = await computeIndexDiff({ - repoRoot, - userConfig, - from: 'snap:snap-20260212000000-diffe', - to: 'snap:snap-20260212000000-difff', - modes: ['code'], - includeRelations: false, - maxChunksPerFile: 1, - persist: false -}); -assert.equal( - chunkLimited.summary?.modesSummary?.code?.limits?.chunkDiffSkipped, - true, - 'chunk limit summary should report per-file chunk-diff skipping' -); -assert.equal( - chunkLimited.summary?.modesSummary?.code?.limits?.reason, - 'max-chunks-per-file', - 'chunk limit summary reason should describe per-file cap skips' -); - -console.log('index diff service test passed'); diff --git a/tests/services/indexer/backpressure-cli.test.js b/tests/services/indexer/backpressure-cli.test.js new file mode 100644 index 000000000..d726670a6 --- /dev/null +++ b/tests/services/indexer/backpressure-cli.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +const { repoRoot, configPath, runCli, parseCliJson } = await createIndexerServiceCliFixture({ + cacheName: 'indexer-service-backpressure', + config: { + queue: { + maxQueued: 1, + maxRunning: 1, + maxTotal: 1, + resourceBudgetUnits: 2 + } + } +}); + +const first = runCli('enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code', '--json'); +assert.equal(first.status, 0, 'expected first enqueue to succeed'); +const firstPayload = parseCliJson(first); +assert.equal(firstPayload.ok, true); + +const second = runCli('enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code', '--stage', 'stage2', '--json'); +assert.equal(second.status, 1, 'expected overload enqueue to exit non-zero'); +const secondPayload = parseCliJson(second); +assert.equal(secondPayload.ok, false, 'expected overload payload to be marked failed'); +assert.equal(secondPayload.code, 'QUEUE_BACKPRESSURE_MAX_QUEUED', 'expected stable queue overload code'); +assert.equal(secondPayload.backpressure?.state, 'saturated', 'expected overload payload to expose saturated backpressure state'); +assert.equal(secondPayload.backpressure?.rejectReason, 'max_queued', 'expected explicit overload reason in payload'); + +const status = runCli('status', '--config', configPath, '--json'); +assert.equal(status.status, 0, 'expected status command to succeed'); +const statusPayload = parseCliJson(status); +assert.equal(statusPayload.backpressure?.state, 'saturated', 'expected status to surface queue backpressure state'); +assert.equal(statusPayload.backpressure?.reasons.includes('max_queued'), true, 'expected status to expose queue saturation reasons'); +assert.equal(typeof statusPayload.backpressure?.slo?.state, 'string', 'expected status to include queue SLO state'); + +console.log('indexer service backpressure cli test passed'); diff --git a/tests/services/indexer/compact-cli.test.js b/tests/services/indexer/compact-cli.test.js new file mode 100644 index 000000000..dfeea72e6 --- /dev/null +++ b/tests/services/indexer/compact-cli.test.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + ensureQueueDir, + loadQuarantine, + loadQueue, + saveQuarantine, + saveQueue +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'indexer-service-compact-cli'); +const repoRoot = path.join(tempRoot, 'repo'); +const queueDir = path.join(tempRoot, 'queue'); +const logsDir = path.join(queueDir, 'logs'); +const reportsDir = path.join(queueDir, 'reports'); +const configPath = path.join(tempRoot, 'service.json'); +const env = applyTestEnv({ syncProcess: false }); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await ensureQueueDir(queueDir); +await fsPromises.mkdir(logsDir, { recursive: true }); +await fsPromises.mkdir(reportsDir, { recursive: true }); + +const makeJob = (id, status, createdAt, extra = {}) => ({ + id, + status, + queueName: 'index', + repo: repoRoot, + repoRoot, + mode: 'code', + reason: 'test', + stage: 'stage1', + createdAt, + startedAt: extra.startedAt || null, + finishedAt: extra.finishedAt || null, + progress: { sequence: 0, updatedAt: createdAt, kind: null, note: null }, + lease: { + owner: null, + version: 0, + expiresAt: null, + acquiredAt: null, + renewedAt: null, + releasedAt: null, + releasedReason: null, + lastOwner: null + }, + transition: { + sequence: 1, + from: 'queued', + to: status, + at: extra.finishedAt || createdAt, + reason: status + }, + logPath: path.join(logsDir, `${id}.log`), + reportPath: path.join(reportsDir, `${id}.json`), + ...(extra.quarantine ? { quarantine: extra.quarantine } : {}) +}); + +const queueJobs = [ + makeJob('job-queued', 'queued', '2026-03-18T10:00:00.000Z'), + makeJob('job-done-old', 'done', '2026-03-18T09:00:00.000Z', { + finishedAt: '2026-03-18T09:05:00.000Z' + }), + makeJob('job-done-new', 'done', '2026-03-18T09:30:00.000Z', { + finishedAt: '2026-03-18T09:45:00.000Z' + }) +]; +const quarantineJobs = [ + makeJob('job-quarantine-old', 'failed', '2026-03-18T08:00:00.000Z', { + finishedAt: '2026-03-18T08:05:00.000Z', + quarantine: { + state: 'quarantined', + quarantinedAt: '2026-03-18T08:06:00.000Z', + reason: 'old', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }), + makeJob('job-quarantine-new', 'failed', '2026-03-18T08:30:00.000Z', { + finishedAt: '2026-03-18T08:35:00.000Z', + quarantine: { + state: 'quarantined', + quarantinedAt: '2026-03-18T08:36:00.000Z', + reason: 'new', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }) +]; + +for (const job of [...queueJobs, ...quarantineJobs]) { + await fsPromises.writeFile(job.logPath, `${job.id}\n`, 'utf8'); + await fsPromises.writeFile(job.reportPath, JSON.stringify({ id: job.id }), 'utf8'); +} +await fsPromises.writeFile(path.join(logsDir, 'orphan.log'), 'orphan\n', 'utf8'); +await fsPromises.writeFile(path.join(reportsDir, 'orphan.json'), '{"orphan":true}', 'utf8'); + +await saveQueue(queueDir, { jobs: queueJobs }, 'index'); +await saveQuarantine(queueDir, { jobs: quarantineJobs }, 'index'); + +const config = { + queueDir, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ], + queue: { + retention: { + doneJobs: 1, + failedJobs: 0, + quarantinedJobs: 1, + retriedQuarantinedJobs: 0 + } + } +}; +await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); + +const result = runNode( + [path.join(root, 'tools', 'service', 'indexer-service.js'), 'compact', '--config', configPath, '--json'], + 'indexer-service compact', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'indexer-service compact failed'); + process.exit(result.status ?? 1); +} + +const payload = JSON.parse(result.stdout || '{}'); +assert.equal(payload.ok, true); +assert.equal(payload.removed.queue, 1); +assert.equal(payload.removed.quarantine, 1); +assert.equal(payload.removed.logs, 3); +assert.equal(payload.removed.reports, 3); +assert.deepEqual(payload.removedJobIds.queue, ['job-done-old']); +assert.deepEqual(payload.removedJobIds.quarantine, ['job-quarantine-old']); + +const queueAfter = await loadQueue(queueDir, 'index'); +assert.deepEqual(queueAfter.jobs.map((job) => job.id).sort(), ['job-done-new', 'job-queued']); +const quarantineAfter = await loadQuarantine(queueDir, 'index'); +assert.deepEqual(quarantineAfter.jobs.map((job) => job.id), ['job-quarantine-new']); + +console.log('indexer service compact cli test passed'); diff --git a/tests/services/indexer/embedding-replay-contract.test.js b/tests/services/indexer/embedding-replay-contract.test.js new file mode 100644 index 000000000..c7388b855 --- /dev/null +++ b/tests/services/indexer/embedding-replay-contract.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectEmbeddingReplayState, repairEmbeddingReplayState } from '../../../tools/service/embedding-replay.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-indexer-embedding-replay-contract'); +const repoRoot = path.join(tempRoot, 'repo'); +const buildRoot = path.join(repoRoot, 'builds', 'build-1'); +const indexDir = path.join(buildRoot, 'index-code'); +const backendStageDir = path.join(buildRoot, '.embeddings-backend-staging', 'index-code'); +const buildStatePath = path.join(buildRoot, 'build_state.json'); +const indexStatePath = path.join(indexDir, 'index_state.json'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); +await fs.mkdir(backendStageDir, { recursive: true }); + +await fs.writeFile(path.join(indexDir, 'dense_vectors_uint8.bin'), 'vector-bytes'); +await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ ok: true }, null, 2)); +await fs.writeFile(buildStatePath, JSON.stringify({ + stage: 'stage3', + updatedAt: '2026-03-18T00:00:00.000Z', + phases: { + stage3: { + status: 'running' + } + }, + progress: { + code: { + completed: 12, + total: 48 + } + } +}, null, 2)); +await fs.writeFile(indexStatePath, JSON.stringify({ + generatedAt: '2026-03-18T00:00:00.000Z', + updatedAt: '2026-03-18T00:00:00.000Z', + embeddings: { + ready: false, + pending: true, + embeddingIdentityKey: 'identity-1' + } +}, null, 2)); + +const job = { + id: 'embedding-job-1', + repo: repoRoot, + repoRoot, + buildRoot, + indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 +}; + +const before = await collectEmbeddingReplayState(job); +assert.equal(before.jobId, 'embedding-job-1'); +assert.equal(before.partialDurableState, true, 'expected pending embeddings state to be treated as partial durable state'); +assert.equal(before.backendStage.exists, true, 'expected stale backend stage directory to be detected'); +assert.equal(before.artifacts.presentCount >= 2, true, 'expected embedding artifacts to be summarized'); +assert.equal(before.buildState?.stage, 'stage3'); +assert.equal(before.buildState?.progress?.completed, 12); + +const indexDirOnlyBefore = await collectEmbeddingReplayState({ + ...job, + buildRoot: undefined +}); +assert.equal(indexDirOnlyBefore.buildRoot, buildRoot, 'expected replay state to infer build root from indexDir-only jobs'); +assert.equal(indexDirOnlyBefore.backendStage.path, backendStageDir); +assert.equal(indexDirOnlyBefore.backendStage.exists, true); + +const repair = await repairEmbeddingReplayState(job); +assert.equal(repair.repaired, true, 'expected repair to take action'); +assert.equal(repair.actions.some((entry) => entry.type === 'remove-backend-stage-dir'), true); +assert.equal(repair.actions.some((entry) => entry.type === 'reset-pending-index-state'), true); + +const after = await collectEmbeddingReplayState(job); +assert.equal(after.backendStage.exists, false, 'expected repair to remove stale backend stage directory'); +assert.equal(after.embeddings.pending, false, 'expected repair to clear stale pending bit'); +assert.equal(after.embeddings.ready, false, 'expected repair to keep embeddings unready after interrupted run'); +assert.equal(after.embeddings.replay?.repairedBy, 'service-indexer'); +assert.equal(Array.isArray(after.embeddings.replay?.actions), true); + +const repairedState = JSON.parse(await fs.readFile(indexStatePath, 'utf8')); +assert.equal(repairedState.embeddings.pending, false); +assert.equal(repairedState.embeddings.ready, false); +assert.equal(repairedState.embeddings.replay.partialDurableState, true); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('indexer service embedding replay contract test passed'); diff --git a/tests/services/indexer/embeddings-envelope-cli.test.js b/tests/services/indexer/embeddings-envelope-cli.test.js new file mode 100644 index 000000000..e25d1a5d3 --- /dev/null +++ b/tests/services/indexer/embeddings-envelope-cli.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +const { configPath, runCliJson } = await createIndexerServiceCliFixture({ + cacheName: 'indexer-service-embeddings-envelope', + config: ({ repoRoot }) => ({ + queue: { + maxRetries: 2, + maxQueued: 20, + maxRunning: 1, + resourceBudgetUnits: 4 + }, + worker: { + concurrency: 1 + }, + embeddings: { + queue: { + maxRetries: 5, + maxQueued: 3, + maxRunning: 2, + resourceBudgetUnits: 12 + }, + worker: { + concurrency: 2, + maxMemoryMb: 6144 + } + }, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] + }) +}); + +const indexStatus = runCliJson('status', '--config', configPath, '--queue', 'index', '--json'); +const embeddingsStatus = runCliJson('status', '--config', configPath, '--queue', 'embeddings', '--json'); + +assert.equal(indexStatus.envelope?.queueClass, 'index'); +assert.equal(indexStatus.envelope?.retry?.maxRetries, 2); +assert.equal(indexStatus.envelope?.worker?.concurrency, 1); +assert.equal(indexStatus.envelope?.worker?.maxMemoryMb, null); +assert.equal(indexStatus.envelope?.admission?.resourceBudgetUnits, 4); + +assert.equal(embeddingsStatus.envelope?.queueClass, 'embeddings'); +assert.equal(embeddingsStatus.envelope?.retry?.maxRetries, 5); +assert.equal(embeddingsStatus.envelope?.worker?.concurrency, 2); +assert.equal(embeddingsStatus.envelope?.worker?.maxMemoryMb, 6144); +assert.equal(embeddingsStatus.envelope?.admission?.maxQueued, 3); +assert.equal(embeddingsStatus.envelope?.admission?.resourceBudgetUnits, 12); + +const embeddingsSmoke = runCliJson('smoke', '--config', configPath, '--queue', 'embeddings', '--json'); +assert.equal(embeddingsSmoke.envelope?.queueClass, 'embeddings'); +assert.equal(embeddingsSmoke.envelope?.retry?.maxRetries, 5); + +console.log('indexer service embeddings envelope cli test passed'); diff --git a/tests/services/indexer/indexer-service-cli-fixture.js b/tests/services/indexer/indexer-service-cli-fixture.js new file mode 100644 index 000000000..48b9f7529 --- /dev/null +++ b/tests/services/indexer/indexer-service-cli-fixture.js @@ -0,0 +1,66 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +export const createIndexerServiceCliFixture = async ({ + cacheName, + config = {} +} = {}) => { + if (!cacheName) { + throw new TypeError('createIndexerServiceCliFixture requires cacheName'); + } + + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, cacheName); + const repoRoot = path.join(tempRoot, 'repo'); + const queueDir = path.join(tempRoot, 'queue'); + const configPath = path.join(tempRoot, 'service.json'); + const scriptPath = path.join(root, 'tools', 'service', 'indexer-service.js'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(repoRoot, { recursive: true }); + + const resolvedConfig = typeof config === 'function' + ? config({ root, tempRoot, repoRoot, queueDir, configPath }) + : config; + const serviceConfig = { + queueDir, + ...(resolvedConfig || {}), + repos: resolvedConfig?.repos || [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] + }; + await fsPromises.writeFile(configPath, JSON.stringify(serviceConfig, null, 2)); + + const env = applyTestEnv({ syncProcess: false }); + const runCli = (...args) => runNode( + [scriptPath, ...args], + `indexer-service ${args[0] || 'cli'}`, + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + const parseCliJson = (result) => JSON.parse(result.stdout || '{}'); + const runCliJson = (...args) => { + const result = runCli(...args); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || `indexer-service ${args[0]} failed`); + } + return parseCliJson(result); + }; + + return { + root, + tempRoot, + repoRoot, + queueDir, + configPath, + scriptPath, + runCli, + parseCliJson, + runCliJson + }; +}; diff --git a/tests/services/indexer/indexer-service-embedding-job-uses-build-root.test.js b/tests/services/indexer/indexer-service-embedding-job-uses-build-root.test.js deleted file mode 100644 index daecf54e1..000000000 --- a/tests/services/indexer/indexer-service-embedding-job-uses-build-root.test.js +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { buildEmbeddingsArgs, normalizeEmbeddingJob } from '../../../tools/service/indexer-service-helpers.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const repoRoot = resolveTestCachePath(process.cwd(), 'indexer-service-embedding-job'); -const buildRoot = path.join(repoRoot, 'builds', 'b1'); -const indexDir = path.join(buildRoot, 'index-code'); - -const normalized = normalizeEmbeddingJob({ - repoRoot, - buildRoot, - indexDir, - mode: 'code', - embeddingPayloadFormatVersion: 2 -}); - -assert.equal(normalized.buildRoot, path.resolve(buildRoot)); -assert.equal(normalized.indexDir, path.resolve(indexDir)); - -const buildPath = path.join(process.cwd(), 'tools', 'build/embeddings.js'); -const args = buildEmbeddingsArgs({ - buildPath, - repoPath: repoRoot, - mode: 'code', - indexRoot: normalized.buildRoot -}); - -const indexFlag = args.indexOf('--index-root'); -assert.ok(indexFlag >= 0, 'expected --index-root arg'); -assert.equal(args[indexFlag + 1], normalized.buildRoot); - -console.log('indexer-service embedding job build-root test passed'); diff --git a/tests/services/indexer/indexer-service-sync-failure-exit.test.js b/tests/services/indexer/indexer-service-sync-failure-exit.test.js deleted file mode 100644 index c3308a6f1..000000000 --- a/tests/services/indexer/indexer-service-sync-failure-exit.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'indexer-service-sync-failure'); -const configPath = path.join(tempRoot, 'service.json'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const config = { - queueDir: path.join(tempRoot, 'queue'), - repos: [ - { id: 'missing-url-repo', path: path.join(tempRoot, 'missing-repo') } - ] -}; -await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); - -const run = spawnSync( - process.execPath, - [path.join(root, 'tools', 'service', 'indexer-service.js'), 'sync', '--config', configPath, '--json'], - { encoding: 'utf8' } -); - -assert.equal(run.status, 1, `expected sync failures to exit 1, got ${run.status}`); -const payload = JSON.parse(run.stdout || '{}'); -assert.equal(payload?.ok, false, 'expected sync payload ok=false on repo failures'); -assert.equal(Array.isArray(payload?.results), true, 'expected sync payload results array'); -assert.equal(payload.results[0]?.id, 'missing-url-repo'); -assert.equal(payload.results[0]?.ok, false); -assert.match(String(payload.results[0]?.message || ''), /Missing repo url/i); - -console.log('indexer service sync failure-exit test passed'); diff --git a/tests/services/indexer/indexer-service.test.js b/tests/services/indexer/indexer-service.test.js deleted file mode 100644 index ed72ee49d..000000000 --- a/tests/services/indexer/indexer-service.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'indexer-service'); -const repoRoot = path.join(tempRoot, 'repo'); -const queueDir = path.join(tempRoot, 'queue'); -const configPath = path.join(tempRoot, 'service.json'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); - -const config = { - queueDir, - repos: [ - { id: 'repo', path: repoRoot, syncPolicy: 'none' } - ] -}; -await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); - -const enqueue = spawnSync( - process.execPath, - [path.join(root, 'tools', 'service', 'indexer-service.js'), 'enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code'], - { encoding: 'utf8' } -); -if (enqueue.status !== 0) { - console.error(enqueue.stderr || enqueue.stdout || 'indexer-service enqueue failed'); - process.exit(enqueue.status ?? 1); -} - -const status = spawnSync( - process.execPath, - [path.join(root, 'tools', 'service', 'indexer-service.js'), 'status', '--config', configPath], - { encoding: 'utf8' } -); -if (status.status !== 0) { - console.error(status.stderr || status.stdout || 'indexer-service status failed'); - process.exit(status.status ?? 1); -} - -const payload = JSON.parse(status.stdout || '{}'); -assert.equal(payload.queue?.queued, 1); -assert.ok(fs.existsSync(path.join(queueDir, 'queue.json'))); - -console.log('indexer service test passed'); - diff --git a/tests/services/indexer/load-shedding-cli.test.js b/tests/services/indexer/load-shedding-cli.test.js new file mode 100644 index 000000000..356156143 --- /dev/null +++ b/tests/services/indexer/load-shedding-cli.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { saveQueue } from '../../../tools/service/queue.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'indexer-service-load-shedding'); +const repoRoot = path.join(tempRoot, 'repo'); +const queueDir = path.join(tempRoot, 'queue'); +const configPath = path.join(tempRoot, 'service.json'); +const env = applyTestEnv({ syncProcess: false }); +const servicePath = path.join(root, 'tools', 'service', 'indexer-service.js'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); + +const config = { + queueDir, + queue: { + maxQueued: 10, + maxRunning: 10, + maxTotal: 20, + resourceBudgetUnits: 40, + slo: { + maxQueueAgeMs: { + degraded: 1000, + overloaded: 15000 + }, + maxRunLatencyMs: { + degraded: 1000, + overloaded: 15000 + }, + maxRetryRate: { + degraded: 0.25, + overloaded: 0.5 + }, + maxSaturationRatio: { + degraded: 0.5, + overloaded: 0.9 + }, + deferDelayMs: { + degraded: 2000, + overloaded: 12000 + } + } + }, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] +}; +await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + +await saveQueue(queueDir, { + jobs: [ + { + id: 'existing-aged', + status: 'queued', + queueName: 'index', + repo: repoRoot, + mode: 'code', + stage: 'stage1', + createdAt: new Date(Date.now() - 2000).toISOString(), + attempts: 0 + } + ] +}, 'index'); + +const runCli = (...args) => runNode( + [servicePath, ...args], + `indexer-service ${args[0] || 'cli'}`, + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +const enqueue = runCli('enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'both', '--stage', 'stage3', '--json'); +assert.equal(enqueue.status, 0, 'expected degraded heavy enqueue to succeed with deferral'); +const enqueuePayload = JSON.parse(enqueue.stdout || '{}'); +assert.equal(enqueuePayload.ok, true, 'expected enqueue payload to be successful'); +assert.equal(enqueuePayload.deferred, true, 'expected heavy work to be deferred'); +assert.equal(enqueuePayload.backpressure?.action, 'defer', 'expected enqueue payload to expose defer action'); +assert.equal(enqueuePayload.backpressure?.slo?.state, 'degraded', 'expected enqueue payload to expose degraded SLO state'); + +const status = runCli('status', '--config', configPath, '--json'); +assert.equal(status.status, 0, 'expected status command to succeed'); +const statusPayload = JSON.parse(status.stdout || '{}'); +assert.equal(statusPayload.backpressure?.slo?.state === 'degraded' || statusPayload.backpressure?.slo?.state === 'overloaded', true, 'expected status to expose a non-healthy SLO state'); +assert.equal(statusPayload.backpressure?.slo?.actions?.workerMode, 'priority-only', 'expected status to expose priority-only mode'); + +console.log('indexer service load shedding cli test passed'); diff --git a/tests/services/indexer/metrics-contract-cli.test.js b/tests/services/indexer/metrics-contract-cli.test.js new file mode 100644 index 000000000..45793c5be --- /dev/null +++ b/tests/services/indexer/metrics-contract-cli.test.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + claimNextJob, + completeJob, + enqueueJob, + ensureQueueDir, + loadQueue, + requeueStaleJobs, + saveQueue +} from '../../../tools/service/queue.js'; +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +applyTestEnv(); + +const { repoRoot, queueDir, configPath, runCli, parseCliJson } = await createIndexerServiceCliFixture({ + cacheName: 'indexer-service-metrics-contract', + config: { + queue: { + maxQueued: 1, + maxRunning: 1, + maxTotal: 2, + resourceBudgetUnits: 2 + } + } +}); +await ensureQueueDir(queueDir); + +await enqueueJob(queueDir, { + id: 'job-1', + createdAt: '2026-03-19T00:00:00.000Z', + repo: repoRoot, + mode: 'code', + reason: null, + stage: 'stage1', + maxRetries: 2 +}, null, 'index'); + +const runningJob = await claimNextJob(queueDir, 'index', { ownerId: 'worker-1' }); +assert.equal(runningJob?.id, 'job-1', 'expected first job to claim'); + +await enqueueJob(queueDir, { + id: 'job-2', + createdAt: '2026-03-19T00:01:00.000Z', + repo: repoRoot, + mode: 'code', + reason: null, + stage: 'stage2', + maxRetries: 0 +}, null, 'index'); + +await completeJob(queueDir, 'job-1', 'queued', { + exitCode: 1, + retry: true, + attempts: 1 +}, 'index', { + ownerId: 'worker-1', + expectedLeaseVersion: runningJob?.lease?.version +}); + +const queue = await loadQueue(queueDir, 'index'); +const staleQueueEntry = queue.jobs.find((job) => job.id === 'job-2'); +assert.ok(staleQueueEntry, 'expected second job to be present in queue'); +staleQueueEntry.status = 'running'; +staleQueueEntry.nextEligibleAt = null; +staleQueueEntry.lease.expiresAt = '2026-03-18T23:59:00.000Z'; +staleQueueEntry.lease.owner = 'worker-2'; +staleQueueEntry.lease.version = 1; +staleQueueEntry.lease.acquiredAt = '2026-03-18T23:58:00.000Z'; +staleQueueEntry.lease.renewedAt = '2026-03-18T23:59:00.000Z'; +staleQueueEntry.lastHeartbeatAt = '2026-03-18T23:59:00.000Z'; +staleQueueEntry.startedAt = '2026-03-18T23:58:00.000Z'; +await saveQueue(queueDir, queue, 'index'); + +const staleSweep = await requeueStaleJobs(queueDir, 'index', { maxRetries: 0 }); +assert.equal(staleSweep.quarantined, 1, 'expected stale lease to quarantine once retry budget is exhausted'); + +const status = runCli('status', '--config', configPath, '--json'); +assert.equal( + status.status, + 0, + `expected status command to succeed; stderr=${status.stderr || ''}; stdout=${status.stdout || ''}` +); + +const payload = parseCliJson(status); +assert.equal(payload.ok, true, 'expected status payload to succeed'); +assert.equal(payload.metrics?.retryRate?.value, 1, 'expected retry rate to reflect one retried active job'); +assert.equal(payload.metrics?.retryRate?.retriedActiveJobs, 1, 'expected retried active job count'); +assert.equal(payload.metrics?.leaseExpiry?.quarantinedJobs, 1, 'expected one lease-expiry quarantine record'); +assert.equal(payload.metrics?.leaseExpiry?.totalRecords, 1, 'expected lease-expiry total records to aggregate current state'); +assert.equal(payload.metrics?.queueAge?.oldestQueuedMs >= 0, true, 'expected queue age metrics to be present'); +assert.equal(payload.metrics?.saturation?.state, payload.backpressure?.state, 'expected saturation contract to align with backpressure state'); +assert.equal(payload.metrics?.saturation?.ratio, payload.backpressure?.saturationRatio, 'expected saturation ratio to align with backpressure ratio'); +assert.equal(payload.metrics?.saturation?.sloState, payload.backpressure?.slo?.state, 'expected saturation SLO state to align'); + +console.log('indexer service metrics contract cli test passed'); diff --git a/tests/services/indexer/quarantine-cli.test.js b/tests/services/indexer/quarantine-cli.test.js new file mode 100644 index 000000000..80568eb00 --- /dev/null +++ b/tests/services/indexer/quarantine-cli.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + claimNextJob, + ensureQueueDir, + enqueueJob, + quarantineJob +} from '../../../tools/service/queue.js'; +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +const { repoRoot, queueDir, configPath, runCliJson } = await createIndexerServiceCliFixture({ + cacheName: 'indexer-service-quarantine-cli' +}); +await ensureQueueDir(queueDir); + +await enqueueJob(queueDir, { + id: 'job-cli-poison', + createdAt: new Date().toISOString(), + repo: repoRoot, + repoRoot, + mode: 'code', + reason: 'test', + stage: 'stage1' +}, null, 'index'); +const claimed = await claimNextJob(queueDir, 'index', { ownerId: 'worker-cli' }); +await quarantineJob(queueDir, claimed.id, 'cli-poison', 'index', { + ownerId: 'worker-cli', + expectedLeaseVersion: claimed.lease?.version ?? null, + sourceStatus: 'running', + result: { + exitCode: 1, + error: 'cli poison' + } +}); + +const quarantineList = runCliJson('quarantine', '--config', configPath, '--json'); +assert.equal(quarantineList.ok, true); +assert.equal(quarantineList.summary?.quarantined, 1); +assert.equal(quarantineList.jobs?.[0]?.id, 'job-cli-poison'); + +const quarantineInspect = runCliJson('quarantine', '--config', configPath, '--job', 'job-cli-poison', '--json'); +assert.equal(quarantineInspect.job?.quarantine?.reason, 'cli-poison'); + +const retryPayload = runCliJson('retry-quarantined', '--config', configPath, '--job', 'job-cli-poison', '--json'); +assert.equal(retryPayload.ok, true); +assert.equal(retryPayload.retriedFromId, 'job-cli-poison'); +assert.notEqual(retryPayload.job?.id, 'job-cli-poison'); + +const statusPayload = runCliJson('status', '--config', configPath, '--json'); +assert.equal(statusPayload.queue?.queued, 1); +assert.equal(statusPayload.quarantine?.retried, 1); + +const purgePayload = runCliJson('purge-quarantined', '--config', configPath, '--job', 'job-cli-poison', '--json'); +assert.equal(purgePayload.ok, true); +assert.equal(purgePayload.removed, 1); + +const finalQuarantineList = runCliJson('quarantine', '--config', configPath, '--json'); +assert.equal(finalQuarantineList.summary?.total, 0); + +console.log('indexer service quarantine cli test passed'); diff --git a/tests/services/indexer/queue-identity-cli-enqueue.test.js b/tests/services/indexer/queue-identity-cli-enqueue.test.js new file mode 100644 index 000000000..52bbb1bfe --- /dev/null +++ b/tests/services/indexer/queue-identity-cli-enqueue.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createQueueIdentityCliFixture } from './queue-identity-cli-fixture.js'; + +const { repoRoot, configPath, runCli, parseCliJson } = await createQueueIdentityCliFixture({ + cacheName: 'indexer-service-queue-identity-enqueue' +}); + +const enqueue = runCli( + 'enqueue', + '--config', configPath, + '--queue', 'auto', + '--reason', 'embeddings', + '--stage', 'stage3', + '--repo', repoRoot, + '--mode', 'code', + '--json' +); +assert.equal(enqueue.status, 0, enqueue.stderr || enqueue.stdout); +const enqueuePayload = parseCliJson(enqueue); +assert.equal(enqueuePayload.ok, true); + +const embeddingsStatus = runCli('status', '--config', configPath, '--queue', 'embeddings-stage3-code', '--json'); +assert.equal(embeddingsStatus.status, 0, embeddingsStatus.stderr || embeddingsStatus.stdout); +const embeddingsStatusPayload = parseCliJson(embeddingsStatus); +assert.equal(embeddingsStatusPayload.queue?.queued, 1, 'expected auto embeddings enqueue to land in embeddings-stage3-code'); + +console.log('indexer service queue identity enqueue cli test passed'); diff --git a/tests/services/indexer/queue-identity-cli-fixture.js b/tests/services/indexer/queue-identity-cli-fixture.js new file mode 100644 index 000000000..d31a583d4 --- /dev/null +++ b/tests/services/indexer/queue-identity-cli-fixture.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; + +import { + isEmbeddingsQueueName, + isMonitoredIndexQueueName, + resolveServiceQueueName +} from '../../../tools/service/indexer-service/queue-identity.js'; +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +export const createQueueIdentityCliFixture = ({ cacheName }) => createIndexerServiceCliFixture({ + cacheName, + config: ({ repoRoot }) => ({ + queue: { + maxRetries: 2, + maxQueued: 20, + maxRunning: 1, + maxTotal: 21 + }, + worker: { + concurrency: 1 + }, + embeddings: { + queue: { + maxRetries: 5, + maxQueued: 3, + maxRunning: 2, + maxTotal: 5 + }, + worker: { + concurrency: 2 + } + }, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] + }) +}); + +export const assertQueueIdentityHelpers = () => { + assert.equal(isEmbeddingsQueueName('embeddings-stage3'), true); + assert.equal(isEmbeddingsQueueName('index-stage2'), false); + assert.equal(isMonitoredIndexQueueName('index-stage2'), true); + assert.equal(isMonitoredIndexQueueName('embeddings-stage3'), false); + assert.equal( + resolveServiceQueueName({ queueName: 'auto', reason: 'embeddings', stage: 'stage3', mode: 'code' }), + 'embeddings-stage3-code' + ); +}; diff --git a/tests/services/indexer/queue-identity-cli-shutdown.test.js b/tests/services/indexer/queue-identity-cli-shutdown.test.js new file mode 100644 index 000000000..454f3ca1c --- /dev/null +++ b/tests/services/indexer/queue-identity-cli-shutdown.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createQueueIdentityCliFixture } from './queue-identity-cli-fixture.js'; + +const { repoRoot, configPath, runCli, parseCliJson } = await createQueueIdentityCliFixture({ + cacheName: 'indexer-service-queue-identity-shutdown' +}); + +const shutdown = runCli( + 'shutdown', + '--config', configPath, + '--queue', 'auto', + '--reason', 'embeddings', + '--stage', 'stage3', + '--mode', 'code', + '--shutdown-mode', 'stop-accepting', + '--json' +); +assert.equal(shutdown.status, 0, shutdown.stderr || shutdown.stdout); + +const blockedEnqueue = runCli( + 'enqueue', + '--config', configPath, + '--queue', 'auto', + '--reason', 'embeddings', + '--stage', 'stage3', + '--repo', repoRoot, + '--mode', 'code', + '--json' +); +assert.notEqual(blockedEnqueue.status, 0, 'expected stop-accepting embeddings queue to block enqueue'); +const blockedPayload = parseCliJson(blockedEnqueue); +assert.equal(blockedPayload.code, 'SERVICE_STOP_ACCEPTING'); + +const autoQueueStatus = runCli('status', '--config', configPath, '--queue', 'auto', '--json'); +assert.equal(autoQueueStatus.status, 0, autoQueueStatus.stderr || autoQueueStatus.stdout); +const autoQueueStatusPayload = parseCliJson(autoQueueStatus); +assert.equal(autoQueueStatusPayload.queue?.queued, 0, 'expected no jobs to be written into raw auto queue state'); + +console.log('indexer service queue identity shutdown cli test passed'); diff --git a/tests/services/indexer/queue-identity-cli.test.js b/tests/services/indexer/queue-identity-cli.test.js new file mode 100644 index 000000000..e76ce5857 --- /dev/null +++ b/tests/services/indexer/queue-identity-cli.test.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + assertQueueIdentityHelpers, + createQueueIdentityCliFixture +} from './queue-identity-cli-fixture.js'; + +const { configPath, runCli, parseCliJson } = await createQueueIdentityCliFixture({ + cacheName: 'indexer-service-queue-identity-status' +}); + +assertQueueIdentityHelpers(); + +const autoStatus = runCli('status', '--config', configPath, '--queue', 'auto', '--reason', 'embeddings', '--stage', 'stage3', '--mode', 'code', '--json'); +assert.equal(autoStatus.status, 0, autoStatus.stderr || autoStatus.stdout); +const autoStatusPayload = parseCliJson(autoStatus); +assert.equal(autoStatusPayload.name, 'embeddings-stage3-code'); +assert.equal(autoStatusPayload.envelope?.queueClass, 'embeddings'); +assert.equal(autoStatusPayload.envelope?.worker?.concurrency, 2); + +const derivedIndexStatus = runCli('status', '--config', configPath, '--queue', 'index-stage2', '--json'); +assert.equal(derivedIndexStatus.status, 0, derivedIndexStatus.stderr || derivedIndexStatus.stdout); +const derivedIndexPayload = parseCliJson(derivedIndexStatus); +assert.equal(derivedIndexPayload.name, 'index-stage2'); +assert.equal(derivedIndexPayload.envelope?.queueClass, 'index'); + +console.log('indexer service queue identity status cli test passed'); diff --git a/tests/services/indexer/repair-cli-cleanup-orphans.test.js b/tests/services/indexer/repair-cli-cleanup-orphans.test.js new file mode 100644 index 000000000..14dd1846f --- /dev/null +++ b/tests/services/indexer/repair-cli-cleanup-orphans.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + configPath, + runCliJson, + seedOrphanArtifacts, + readAuditLines +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-cleanup-orphans' +}); + +const { orphanLogPath, orphanReportPath } = await seedOrphanArtifacts(); + +const cleanupDryRun = runCliJson('cleanup-orphans', '--config', configPath, '--dry-run', '--json'); +assert.equal(cleanupDryRun.ok, true); +assert.equal(cleanupDryRun.orphans?.logs.includes(path.resolve(orphanLogPath)), true, 'expected dry-run cleanup to preserve orphan reporting'); + +const cleanupActual = runCliJson('cleanup-orphans', '--config', configPath, '--json'); +assert.equal(cleanupActual.ok, true); +assert.equal(cleanupActual.removed?.logs.includes(path.resolve(orphanLogPath)), true, 'expected cleanup to remove orphan log'); +assert.equal(cleanupActual.removed?.reports.includes(path.resolve(orphanReportPath)), true, 'expected cleanup to remove orphan report'); + +const auditLines = await readAuditLines(); +assert.equal(auditLines.length >= 1, true, 'expected orphan cleanup mutation to append an audit entry'); + +console.log('indexer service repair cli cleanup-orphans test passed'); diff --git a/tests/services/indexer/repair-cli-cleanup.test.js b/tests/services/indexer/repair-cli-cleanup.test.js new file mode 100644 index 000000000..d12adbdcd --- /dev/null +++ b/tests/services/indexer/repair-cli-cleanup.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +import { getQueuePaths } from '../../../tools/service/queue.js'; +import { getServiceShutdownPaths } from '../../../tools/service/shutdown-state.js'; +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + queueDir, + configPath, + runCliJson, + seedStaleLocks, + readAuditLines +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-unlock' +}); + +await seedStaleLocks(); + +const unlockDryRun = runCliJson('unlock', '--config', configPath, '--lock', 'all', '--dry-run', '--json'); +assert.equal(unlockDryRun.ok, true); +assert.equal(unlockDryRun.results.every((entry) => entry.removed === false), true, 'expected dry-run unlock to avoid deleting lock files'); + +const unlockActual = runCliJson('unlock', '--config', configPath, '--lock', 'all', '--json'); +assert.equal(unlockActual.ok, true); +assert.equal(unlockActual.results.filter((entry) => entry.removed).length >= 1, true, 'expected unlock to remove at least one stale lock file'); +assert.equal(await fs.stat(getQueuePaths(queueDir, 'index').lockPath).then(() => true).catch(() => false), false, 'expected queue lock file to be absent after unlock'); +assert.equal(await fs.stat(getServiceShutdownPaths(queueDir, 'index').lockPath).then(() => true).catch(() => false), false, 'expected shutdown lock file to be absent after unlock'); + +const auditLines = await readAuditLines(); +assert.equal(auditLines.length >= 1, true, 'expected unlock repair mutation to append an audit entry'); + +console.log('indexer service repair cli unlock test passed'); diff --git a/tests/services/indexer/repair-cli-fixture.js b/tests/services/indexer/repair-cli-fixture.js new file mode 100644 index 000000000..7f839392e --- /dev/null +++ b/tests/services/indexer/repair-cli-fixture.js @@ -0,0 +1,152 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + ensureQueueDir, + enqueueJob, + getQueuePaths, + loadQueue, + saveQuarantine, + saveQueue +} from '../../../tools/service/queue.js'; +import { getRepairAuditPath } from '../../../tools/service/repair.js'; +import { getServiceShutdownPaths } from '../../../tools/service/shutdown-state.js'; +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +export const createRepairCliFixture = async ({ cacheName }) => { + const fixture = await createIndexerServiceCliFixture({ cacheName }); + const { repoRoot, queueDir } = fixture; + + await ensureQueueDir(queueDir); + await fs.mkdir(path.join(queueDir, 'logs'), { recursive: true }); + await fs.mkdir(path.join(queueDir, 'reports'), { recursive: true }); + + const enqueueIndexJob = async ({ + id, + stage = 'stage1' + }) => { + await enqueueJob(queueDir, { + id, + createdAt: new Date().toISOString(), + repo: repoRoot, + repoRoot, + mode: 'code', + stage + }, null, 'index'); + }; + + const seedStaleRunningJob = async () => { + await enqueueIndexJob({ id: 'job-running-stale', stage: 'stage2' }); + const claimed = await claimNextJob(queueDir, 'index', { + ownerId: 'pid:999999', + leaseMs: 5 + }); + const queuePayload = await loadQueue(queueDir, 'index'); + const runningJob = queuePayload.jobs.find((entry) => entry.id === claimed.id); + const expiredAt = new Date(Date.now() - 60_000).toISOString(); + runningJob.lastHeartbeatAt = expiredAt; + runningJob.lease.expiresAt = expiredAt; + await saveQueue(queueDir, queuePayload, 'index'); + }; + + const seedQueuedJob = async () => { + await enqueueIndexJob({ id: 'job-queued', stage: 'stage1' }); + }; + + const seedRetryJob = async () => { + await saveQuarantine(queueDir, { + jobs: [ + { + id: 'job-repair-retry', + createdAt: new Date().toISOString(), + status: 'failed', + queueName: 'index', + repo: repoRoot, + repoRoot, + mode: 'code', + stage: 'stage3', + attempts: 0, + maxRetries: null, + nextEligibleAt: null, + lastHeartbeatAt: null, + progress: { + sequence: 1, + updatedAt: new Date().toISOString(), + kind: 'quarantine', + note: 'repair-source' + }, + lease: { + owner: null, + version: 0, + expiresAt: null, + acquiredAt: null, + renewedAt: null, + releasedAt: new Date().toISOString(), + releasedReason: 'repair-source', + lastOwner: null + }, + transition: { + sequence: 1, + from: 'queued', + to: 'failed', + at: new Date().toISOString(), + reason: 'repair-source' + }, + logPath: path.join(queueDir, 'logs', 'job-repair-retry.log'), + reportPath: path.join(queueDir, 'reports', 'job-repair-retry.json'), + result: { + error: 'repair source' + }, + lastError: 'repair source', + quarantine: { + state: 'quarantined', + quarantinedAt: new Date().toISOString(), + reason: 'repair-source', + sourceStatus: 'queued', + sourceQueueName: 'index', + releasedAt: null, + releaseReason: null, + retryJobId: null + } + } + ] + }, 'index'); + }; + + const seedOrphanArtifacts = async () => { + const orphanLogPath = path.join(queueDir, 'logs', 'orphan.log'); + const orphanReportPath = path.join(queueDir, 'reports', 'orphan.json'); + await fs.writeFile(orphanLogPath, 'orphan log'); + await fs.writeFile(orphanReportPath, '{"orphan":true}'); + return { + orphanLogPath, + orphanReportPath + }; + }; + + const seedStaleLocks = async () => { + const staleLockPayload = JSON.stringify({ + pid: 999999, + startedAt: new Date(Date.now() - (31 * 60 * 1000)).toISOString(), + scope: 'test-repair' + }, null, 2); + await fs.writeFile(getQueuePaths(queueDir, 'index').lockPath, staleLockPayload); + await fs.writeFile(getServiceShutdownPaths(queueDir, 'index').lockPath, staleLockPayload); + }; + + const readAuditLines = async () => { + const auditPath = getRepairAuditPath(queueDir, 'index'); + return (await fs.readFile(auditPath, 'utf8')).trim().split(/\r?\n/).filter(Boolean); + }; + + return { + ...fixture, + seedQueuedJob, + seedStaleRunningJob, + seedRetryJob, + seedOrphanArtifacts, + seedStaleLocks, + readAuditLines + }; +}; diff --git a/tests/services/indexer/repair-cli-inspect.test.js b/tests/services/indexer/repair-cli-inspect.test.js new file mode 100644 index 000000000..c0977897d --- /dev/null +++ b/tests/services/indexer/repair-cli-inspect.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + configPath, + runCliJson, + seedStaleRunningJob, + seedOrphanArtifacts, + seedStaleLocks +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-inspect' +}); + +await seedStaleRunningJob(); +const { orphanLogPath, orphanReportPath } = await seedOrphanArtifacts(); +await seedStaleLocks(); + +const inspectPayload = runCliJson('inspect', '--config', configPath, '--json'); +assert.equal(inspectPayload.ok, true); +assert.equal(inspectPayload.heartbeat?.stale >= 1, true, 'expected inspect to surface stale running jobs'); +assert.equal(inspectPayload.orphans?.logs.includes(path.resolve(orphanLogPath)), true, 'expected inspect to report orphan logs'); +assert.equal(inspectPayload.orphans?.reports.includes(path.resolve(orphanReportPath)), true, 'expected inspect to report orphan reports'); +assert.equal(inspectPayload.locks.every((entry) => entry.safeToUnlock === true), true, 'expected inspect to classify stale locks as safe to unlock'); + +const heartbeatPayload = runCliJson('heartbeat-status', '--config', configPath, '--json'); +assert.equal(heartbeatPayload.summary?.stale, 1, 'expected heartbeat status to classify stale running job'); +assert.equal(heartbeatPayload.jobs?.[0]?.status, 'stale'); + +console.log('indexer service repair cli inspect test passed'); diff --git a/tests/services/indexer/repair-cli-purge.test.js b/tests/services/indexer/repair-cli-purge.test.js new file mode 100644 index 000000000..d85f9d2fd --- /dev/null +++ b/tests/services/indexer/repair-cli-purge.test.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + configPath, + runCliJson, + seedRetryJob, + readAuditLines +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-purge' +}); + +await seedRetryJob(); + +const purgeDryRun = runCliJson('purge', '--config', configPath, '--job', 'job-repair-retry', '--dry-run', '--json'); +assert.equal(purgeDryRun.ok, true); +assert.equal(purgeDryRun.dryRun, true); + +const purgeActual = runCliJson('purge', '--config', configPath, '--job', 'job-repair-retry', '--json'); +assert.equal(purgeActual.ok, true); +assert.equal(purgeActual.removed, 1); + +const auditLines = await readAuditLines(); +assert.equal(auditLines.length >= 1, true, 'expected purge mutation to append an audit entry'); + +console.log('indexer service repair cli purge test passed'); diff --git a/tests/services/indexer/repair-cli-retry.test.js b/tests/services/indexer/repair-cli-retry.test.js new file mode 100644 index 000000000..295f7a2c4 --- /dev/null +++ b/tests/services/indexer/repair-cli-retry.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + configPath, + runCliJson, + seedRetryJob, + readAuditLines +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-retry' +}); + +await seedRetryJob(); + +const retryDryRun = runCliJson('retry', '--config', configPath, '--job', 'job-repair-retry', '--dry-run', '--json'); +assert.equal(retryDryRun.ok, true); +assert.equal(retryDryRun.dryRun, true); + +const retryActual = runCliJson('retry', '--config', configPath, '--job', 'job-repair-retry', '--json'); +assert.equal(retryActual.ok, true); +assert.notEqual(retryActual.job?.id, 'job-repair-retry'); + +const inspectRetriedPayload = runCliJson('inspect', '--config', configPath, '--job', retryActual.job.id, '--json'); +assert.equal(Array.isArray(inspectRetriedPayload.duplicateGroups), true, 'expected inspect to surface duplicate job groups'); +assert.equal(inspectRetriedPayload.duplicateGroups.length >= 1, true, 'expected retried job to remain inspectable as a duplicate group'); +assert.equal(inspectRetriedPayload.deliveryContract?.semantics, 'at-least-once'); +assert.equal( + inspectRetriedPayload.deliveryContract?.sideEffectFences?.duplicateSuppression, + 'idempotency-key-active-scan', + 'expected inspect to expose duplicate-suppression fence' +); + +const auditLines = await readAuditLines(); +assert.equal(auditLines.length >= 1, true, 'expected retry mutation to append an audit entry'); + +console.log('indexer service repair cli retry test passed'); diff --git a/tests/services/indexer/repair-cli.test.js b/tests/services/indexer/repair-cli.test.js new file mode 100644 index 000000000..fb250b1c3 --- /dev/null +++ b/tests/services/indexer/repair-cli.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { loadQueue } from '../../../tools/service/queue.js'; +import { createRepairCliFixture } from './repair-cli-fixture.js'; + +const { + queueDir, + configPath, + runCliJson, + seedQueuedJob, + readAuditLines +} = await createRepairCliFixture({ + cacheName: 'indexer-service-repair-cli-quarantine' +}); + +await seedQueuedJob(); + +const quarantineDryRun = runCliJson('quarantine-job', '--config', configPath, '--job', 'job-queued', '--reason', 'manual-quarantine', '--dry-run', '--json'); +assert.equal(quarantineDryRun.ok, true); +assert.equal(quarantineDryRun.dryRun, true); +assert.equal((await loadQueue(queueDir, 'index')).jobs.some((entry) => entry.id === 'job-queued'), true, 'expected dry-run quarantine to leave queue untouched'); + +const quarantineActual = runCliJson('quarantine-job', '--config', configPath, '--job', 'job-queued', '--reason', 'manual-quarantine', '--json'); +assert.equal(quarantineActual.ok, true); +assert.equal(quarantineActual.job?.quarantine?.reason, 'manual-quarantine'); + +const auditLines = await readAuditLines(); +assert.equal(auditLines.length >= 1, true, 'expected quarantine mutation to append an audit entry'); + +console.log('indexer service repair cli quarantine test passed'); diff --git a/tests/services/indexer/repair-state.test.js b/tests/services/indexer/repair-state.test.js new file mode 100644 index 000000000..6835c22f3 --- /dev/null +++ b/tests/services/indexer/repair-state.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureQueueDir, saveQueue, saveQuarantine } from '../../../tools/service/queue.js'; +import { describeOrphanArtifacts, describeRepairLocks } from '../../../tools/service/repair.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `service-repair-state-${process.pid}-${Date.now()}`); +const queueDir = path.join(tempRoot, 'queue'); +const logsDir = path.join(queueDir, 'logs'); +const reportsDir = path.join(queueDir, 'reports'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); +await fs.mkdir(logsDir, { recursive: true }); +await fs.mkdir(reportsDir, { recursive: true }); + +const indexJob = { + id: 'job-index', + status: 'done', + queueName: 'index', + createdAt: '2026-03-31T12:00:00.000Z', + finishedAt: '2026-03-31T12:05:00.000Z', + logPath: path.join(logsDir, 'job-index.log'), + reportPath: path.join(reportsDir, 'job-index.json') +}; +const otherQueueJob = { + id: 'job-other-queue', + status: 'done', + queueName: 'embeddings-stage3', + createdAt: '2026-03-31T12:10:00.000Z', + finishedAt: '2026-03-31T12:15:00.000Z' +}; + +await fs.writeFile(indexJob.logPath, 'index\n', 'utf8'); +await fs.writeFile(indexJob.reportPath, '{"id":"job-index"}\n', 'utf8'); +await fs.writeFile(path.join(logsDir, 'job-other-queue.log'), 'other\n', 'utf8'); +await fs.writeFile(path.join(reportsDir, 'job-other-queue.json'), '{"id":"job-other-queue"}\n', 'utf8'); +await fs.writeFile(path.join(logsDir, 'orphan.log'), 'orphan\n', 'utf8'); +await fs.writeFile(path.join(reportsDir, 'orphan.json'), '{"orphan":true}\n', 'utf8'); + +await saveQueue(queueDir, { jobs: [indexJob] }, 'index'); +await saveQuarantine(queueDir, { jobs: [] }, 'index'); +await saveQueue(queueDir, { jobs: [otherQueueJob] }, 'embeddings-stage3'); +await saveQuarantine(queueDir, { jobs: [] }, 'embeddings-stage3'); + +const orphans = await describeOrphanArtifacts(queueDir, 'index'); +assert.deepEqual( + orphans.logs.map((entry) => path.basename(entry)).sort(), + ['orphan.log'], + 'expected orphan detection to preserve log artifacts referenced by other queue partitions' +); +assert.deepEqual( + orphans.reports.map((entry) => path.basename(entry)).sort(), + ['orphan.json'], + 'expected orphan detection to preserve report artifacts referenced by other queue partitions' +); + +const malformedLockPath = path.join(queueDir, 'queue.lock'); +await fs.writeFile(malformedLockPath, '{not-json}\n', 'utf8'); +const locks = await describeRepairLocks(queueDir, 'index'); +const queueLock = locks.find((entry) => entry.kind === 'queue'); +assert.equal(queueLock?.exists, true, 'expected synthetic queue lock to exist'); +assert.equal(queueLock?.safeToUnlock, false, 'expected unreadable lock metadata to stay unsafe to unlock'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('service repair state test passed'); diff --git a/tests/services/indexer/service-repos-failure-reason.test.js b/tests/services/indexer/service-repos-failure-reason.test.js index 8ef63567d..23d8a17c5 100644 --- a/tests/services/indexer/service-repos-failure-reason.test.js +++ b/tests/services/indexer/service-repos-failure-reason.test.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { formatGitFailure } from '../../../tools/service/repos.js'; +import path from 'node:path'; +import { formatGitFailure, resolveRepoEntry } from '../../../tools/service/repos.js'; assert.equal( formatGitFailure({ status: null, signal: 'SIGINT', stderr: '', stdout: '' }, 'fallback'), @@ -23,4 +24,16 @@ assert.equal( 'fatal: bad ref' ); +if (process.platform === 'win32') { + const repoEntries = [ + { id: 'sample', path: path.join('C:', 'Temp', 'Repo-A') } + ]; + const resolved = resolveRepoEntry( + path.join('c:', 'temp', 'repo-a'), + repoEntries, + process.cwd() + ); + assert.equal(resolved?.id, 'sample', 'expected resolveRepoEntry to match Windows paths case-insensitively'); +} + console.log('service repos failure reason test passed'); diff --git a/tests/services/indexer/service-sync-failure-exit.test.js b/tests/services/indexer/service-sync-failure-exit.test.js new file mode 100644 index 000000000..7c8ade078 --- /dev/null +++ b/tests/services/indexer/service-sync-failure-exit.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'indexer-service-sync-failure'); +const configPath = path.join(tempRoot, 'service.json'); +const env = applyTestEnv({ syncProcess: false }); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +const config = { + queueDir: path.join(tempRoot, 'queue'), + repos: [ + { id: 'missing-url-repo', path: path.join(tempRoot, 'missing-repo') } + ] +}; +await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); + +const run = runNode( + [path.join(root, 'tools', 'service', 'indexer-service.js'), 'sync', '--config', configPath, '--json'], + 'indexer-service sync failure', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(run.status, 1, `expected sync failures to exit 1, got ${run.status}`); +const payload = JSON.parse(run.stdout || '{}'); +assert.equal(payload?.ok, false, 'expected sync payload ok=false on repo failures'); +assert.equal(Array.isArray(payload?.results), true, 'expected sync payload results array'); +assert.equal(payload.results[0]?.id, 'missing-url-repo'); +assert.equal(payload.results[0]?.ok, false); +assert.match(String(payload.results[0]?.message || ''), /Missing repo url/i); + +console.log('indexer service sync failure-exit test passed'); diff --git a/tests/services/indexer/indexer-service-sync-signal-contract.test.js b/tests/services/indexer/service-sync-signal-contract.test.js similarity index 100% rename from tests/services/indexer/indexer-service-sync-signal-contract.test.js rename to tests/services/indexer/service-sync-signal-contract.test.js diff --git a/tests/services/indexer/indexer-service-top-level-error-contract.test.js b/tests/services/indexer/service-top-level-error-contract.test.js similarity index 100% rename from tests/services/indexer/indexer-service-top-level-error-contract.test.js rename to tests/services/indexer/service-top-level-error-contract.test.js diff --git a/tests/services/indexer/service.test.js b/tests/services/indexer/service.test.js new file mode 100644 index 000000000..50481e3cb --- /dev/null +++ b/tests/services/indexer/service.test.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + buildEmbeddingsArgs, + normalizeEmbeddingJob, + resolveEmbeddingBackendStageDir +} from '../../../tools/service/indexer-service-helpers.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'indexer-service'); +const repoRoot = path.join(tempRoot, 'repo'); +const queueDir = path.join(tempRoot, 'queue'); +const configPath = path.join(tempRoot, 'service.json'); +const env = applyTestEnv({ syncProcess: false }); +const servicePath = path.join(root, 'tools', 'service', 'indexer-service.js'); +const runServiceCli = (args, label) => runNode( + [servicePath, ...args], + label, + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); + +const buildRoot = path.join(repoRoot, 'builds', 'b1'); +const indexDir = path.join(buildRoot, 'index-code'); +const normalized = normalizeEmbeddingJob({ + repoRoot, + buildRoot, + indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 +}); +assert.equal(normalized.buildRoot, path.resolve(buildRoot)); +assert.equal(normalized.indexDir, path.resolve(indexDir)); +assert.equal(normalized.indexDirUnderBuildRoot, true); + +const normalizedIndexOnly = normalizeEmbeddingJob({ + repoRoot, + indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 +}); +assert.equal(normalizedIndexOnly.buildRoot, path.resolve(buildRoot)); +assert.equal(normalizedIndexOnly.indexDir, path.resolve(indexDir)); +assert.equal(normalizedIndexOnly.indexDirUnderBuildRoot, true); +assert.equal( + resolveEmbeddingBackendStageDir(normalizedIndexOnly, 'code'), + path.join(buildRoot, '.embeddings-backend-staging', 'index-code') +); + +const legacyIndexRoot = normalizeEmbeddingJob({ + repoRoot, + indexRoot: indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 1 +}); +assert.equal(legacyIndexRoot.buildRoot, path.resolve(buildRoot)); +assert.equal(legacyIndexRoot.indexDir, path.resolve(indexDir)); +assert.equal(legacyIndexRoot.legacyIndexRoot, path.resolve(indexDir)); + +const outsideIndexDir = path.join(repoRoot, 'outside', 'index-code'); +const normalizedOutside = normalizeEmbeddingJob({ + repoRoot, + buildRoot, + indexDir: outsideIndexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 +}); +assert.equal(normalizedOutside.indexDirUnderBuildRoot, false); + +const buildPath = path.join(root, 'tools', 'build', 'embeddings.js'); +const args = buildEmbeddingsArgs({ + buildPath, + repoPath: repoRoot, + mode: 'code', + indexRoot: normalized.buildRoot +}); +const indexFlag = args.indexOf('--index-root'); +assert.ok(indexFlag >= 0, 'expected --index-root arg'); +assert.equal(args[indexFlag + 1], normalized.buildRoot); + +const config = { + queueDir, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] +}; +await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); + +const enqueue = runServiceCli( + ['enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code'], + 'indexer-service enqueue' +); +if (enqueue.status !== 0) { + console.error(enqueue.stderr || enqueue.stdout || 'indexer-service enqueue failed'); + process.exit(enqueue.status ?? 1); +} +const enqueuePayload = JSON.parse(enqueue.stdout || '{}'); +assert.equal(enqueuePayload.ok, true); +assert.equal(enqueuePayload.duplicate, false); +assert.ok(typeof enqueuePayload.idempotencyKey === 'string' && enqueuePayload.idempotencyKey.length > 0); + +const duplicateEnqueue = runServiceCli( + ['enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code'], + 'indexer-service duplicate enqueue' +); +if (duplicateEnqueue.status !== 0) { + console.error(duplicateEnqueue.stderr || duplicateEnqueue.stdout || 'indexer-service duplicate enqueue failed'); + process.exit(duplicateEnqueue.status ?? 1); +} +const duplicatePayload = JSON.parse(duplicateEnqueue.stdout || '{}'); +assert.equal(duplicatePayload.ok, true); +assert.equal(duplicatePayload.duplicate, true); +assert.equal(duplicatePayload.replaySuppressed, true); +assert.equal(duplicatePayload.job?.id, enqueuePayload.job?.id); + +const status = runServiceCli( + ['status', '--config', configPath], + 'indexer-service status' +); +if (status.status !== 0) { + console.error(status.stderr || status.stdout || 'indexer-service status failed'); + process.exit(status.status ?? 1); +} + +const payload = JSON.parse(status.stdout || '{}'); +assert.equal(payload.queue?.queued, 1); +assert.ok(fs.existsSync(path.join(queueDir, 'queue.json'))); + +console.log('indexer service test passed'); + diff --git a/tests/services/indexer/shutdown-cli.test.js b/tests/services/indexer/shutdown-cli.test.js new file mode 100644 index 000000000..e2988c56f --- /dev/null +++ b/tests/services/indexer/shutdown-cli.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createIndexerServiceCliFixture } from './indexer-service-cli-fixture.js'; + +const { repoRoot, configPath, runCli, parseCliJson } = await createIndexerServiceCliFixture({ + cacheName: 'indexer-service-shutdown-cli' +}); + +const shutdown = runCli('shutdown', '--config', configPath, '--shutdown-mode', 'stop-accepting', '--json'); +assert.equal(shutdown.status, 0, shutdown.stderr || shutdown.stdout); +const shutdownPayload = parseCliJson(shutdown); +assert.equal(shutdownPayload.shutdown?.mode, 'stop-accepting'); +assert.equal(shutdownPayload.shutdown?.accepting, false); + +const statusBlocked = runCli('status', '--config', configPath, '--json'); +assert.equal(statusBlocked.status, 0, statusBlocked.stderr || statusBlocked.stdout); +const statusPayload = parseCliJson(statusBlocked); +assert.equal(statusPayload.shutdown?.mode, 'stop-accepting'); +assert.equal(statusPayload.shutdown?.accepting, false); + +const blockedEnqueue = runCli('enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code', '--json'); +assert.notEqual(blockedEnqueue.status, 0, 'enqueue should fail while stop-accepting is active'); +const blockedPayload = parseCliJson(blockedEnqueue); +assert.equal(blockedPayload.code, 'SERVICE_STOP_ACCEPTING'); +assert.equal(blockedPayload.shutdown?.mode, 'stop-accepting'); + +const resumed = runCli('resume', '--config', configPath, '--json'); +assert.equal(resumed.status, 0, resumed.stderr || resumed.stdout); +const resumedPayload = parseCliJson(resumed); +assert.equal(resumedPayload.shutdown?.mode, 'running'); +assert.equal(resumedPayload.shutdown?.accepting, true); + +const enqueue = runCli('enqueue', '--config', configPath, '--repo', repoRoot, '--mode', 'code', '--json'); +assert.equal(enqueue.status, 0, enqueue.stderr || enqueue.stdout); +const enqueuePayload = parseCliJson(enqueue); +assert.equal(enqueuePayload.ok, true); + +console.log('indexer service shutdown cli test passed'); diff --git a/tests/services/indexer/worker-shutdown.test.js b/tests/services/indexer/worker-shutdown.test.js new file mode 100644 index 000000000..847ba3c91 --- /dev/null +++ b/tests/services/indexer/worker-shutdown.test.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createQueueWorker } from '../../../tools/service/indexer-service/queue-worker.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const createHarness = () => { + let activeCount = 0; + let remainingJobs = []; + let state = { + mode: 'running', + accepting: true, + stopClaiming: false, + forceAbort: false, + deadlineAt: null + }; + const completedReasons = []; + const workerStates = []; + const finalized = []; + const printPayloads = []; + const requestState = (mode, timeoutMs = null) => { + const requestedAt = new Date().toISOString(); + const deadlineAt = timeoutMs != null + ? new Date(Date.parse(requestedAt) + timeoutMs).toISOString() + : null; + state = { + mode, + accepting: mode === 'running', + stopClaiming: mode === 'cancel' || mode === 'force-stop', + forceAbort: mode === 'cancel' || mode === 'force-stop', + deadlineAt + }; + }; + return { + setJobs(jobs) { + remainingJobs = jobs.map((job) => ({ ...job })); + }, + get finalized() { + return finalized; + }, + get completedReasons() { + return completedReasons; + }, + get workerStates() { + return workerStates; + }, + get printPayloads() { + return printPayloads; + }, + requestState, + createWorker() { + return createQueueWorker({ + queueDir: '/tmp/service-queue', + resolvedQueueName: 'index', + staleQueueMaxRetries: 2, + monitorBuildProgress: false, + startBuildProgressMonitor: () => async () => {}, + touchJobHeartbeat: async () => null, + requeueStaleJobs: async () => null, + claimNextJob: async () => { + if (state.stopClaiming) return null; + const next = remainingJobs.shift() || null; + if (next) activeCount += 1; + return next; + }, + ensureQueueDir: async () => {}, + executeClaimedJob: async ({ job, abortSignal }) => { + if (job.kind === 'slow') { + return await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ + handled: false, + runResult: { + exitCode: 0, + signal: null, + executionMode: 'subprocess', + daemon: null, + cancelled: false, + shutdownMode: null + } + }); + }, 250); + abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + resolve({ + handled: false, + runResult: { + exitCode: 130, + signal: null, + executionMode: 'subprocess', + daemon: null, + cancelled: true, + shutdownMode: state.mode + } + }); + }, { once: true }); + }); + } + await sleep(20); + return { + handled: false, + runResult: { + exitCode: 0, + signal: null, + executionMode: 'subprocess', + daemon: null, + cancelled: false, + shutdownMode: null + } + }; + }, + finalizeJobRun: async ({ job, runResult }) => { + activeCount = Math.max(0, activeCount - 1); + finalized.push({ id: job.id, cancelled: runResult?.cancelled === true, shutdownMode: runResult?.shutdownMode || null }); + }, + buildDefaultRunResult: () => ({ + exitCode: 1, + signal: null, + executionMode: 'subprocess', + daemon: null, + cancelled: false, + shutdownMode: null + }), + printPayload: (payload) => { + printPayloads.push(payload); + }, + summarizeBackpressure: async () => null, + queueSummary: async () => ({ + total: remainingJobs.length + activeCount, + queued: remainingJobs.length, + running: activeCount, + done: 0, + failed: 0, + retries: 0 + }), + loadShutdownState: async () => state, + requestShutdownState: async ({ mode, timeoutMs = null }) => { + requestState(mode, timeoutMs); + return state; + }, + updateShutdownWorkerState: async (patch) => { + workerStates.push(patch); + return patch; + }, + completeShutdownState: async ({ reason }) => { + completedReasons.push(reason || null); + return { reason }; + }, + resolveLeasePolicy: () => ({ + leaseMs: 1000, + renewIntervalMs: 100, + progressIntervalMs: 100, + workloadClass: 'balanced', + maxRenewalGapMs: 500, + maxConsecutiveRenewalFailures: 2 + }), + jobHeartbeatIntervalMs: 100, + shutdownPollIntervalMs: 50 + }); + } + }; +}; + +const drainHarness = createHarness(); +drainHarness.setJobs([ + { id: 'drain-a', repo: '/tmp/a', mode: 'code', stage: 'stage1', kind: 'fast' }, + { id: 'drain-b', repo: '/tmp/b', mode: 'code', stage: 'stage1', kind: 'fast' } +]); +drainHarness.requestState('drain', 1000); +await drainHarness.createWorker().runWorkLoop({ + requestedConcurrency: 1, + intervalMs: 10, + watch: true, + serviceExecutionMode: 'subprocess' +}); +assert.deepEqual(drainHarness.finalized.map((entry) => entry.id), ['drain-a', 'drain-b']); +assert.deepEqual(drainHarness.completedReasons, ['drain-complete']); + +const cancelHarness = createHarness(); +cancelHarness.setJobs([ + { id: 'cancel-a', repo: '/tmp/a', mode: 'code', stage: 'stage1', kind: 'slow' }, + { id: 'cancel-b', repo: '/tmp/b', mode: 'code', stage: 'stage1', kind: 'fast' } +]); +const cancelWorker = cancelHarness.createWorker(); +const cancelPromise = cancelWorker.runWorkLoop({ + requestedConcurrency: 1, + intervalMs: 10, + watch: true, + serviceExecutionMode: 'subprocess' +}); +await sleep(40); +cancelHarness.requestState('cancel', 500); +await cancelPromise; +assert.deepEqual(cancelHarness.finalized.map((entry) => entry.id), ['cancel-a']); +assert.equal(cancelHarness.finalized[0]?.cancelled, true); +assert.equal(cancelHarness.completedReasons.at(-1), 'cancel-complete'); + +const timeoutHarness = createHarness(); +timeoutHarness.setJobs([ + { id: 'timeout-a', repo: '/tmp/a', mode: 'code', stage: 'stage1', kind: 'slow' } +]); +timeoutHarness.requestState('drain', 20); +await timeoutHarness.createWorker().runWorkLoop({ + requestedConcurrency: 1, + intervalMs: 10, + watch: true, + serviceExecutionMode: 'subprocess' +}); +assert.equal(timeoutHarness.finalized[0]?.cancelled, true); +assert.equal(timeoutHarness.completedReasons.at(-1), 'force-stop-complete'); + +console.log('indexer service worker shutdown test passed'); diff --git a/tests/services/mcp/capabilities-payload.test.js b/tests/services/mcp/capabilities-payload.test.js index 5845b102b..44f79743c 100644 --- a/tests/services/mcp/capabilities-payload.test.js +++ b/tests/services/mcp/capabilities-payload.test.js @@ -1,11 +1,15 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; +import assert from 'node:assert/strict'; import { MCP_SCHEMA_VERSION } from '../../../src/integrations/mcp/defs.js'; import { getCapabilities } from '../../../src/shared/capabilities.js'; +import { getRuntimeCapabilityManifest } from '../../../src/shared/runtime-capability-manifest.js'; +import { DEFAULT_MODEL_ID } from '../../../tools/shared/dict-utils.js'; import { startMcpServer } from '../../helpers/mcp-client.js'; const caps = getCapabilities({ refresh: true }); +const expectedManifest = getRuntimeCapabilityManifest({ runtimeCapabilities: caps, defaultModelId: DEFAULT_MODEL_ID }); const modes = ['legacy']; if (caps?.mcp?.sdk) modes.push('sdk'); @@ -39,6 +43,11 @@ for (const mode of modes) { if (!result.capabilities?.experimental?.pairofcleats?.capabilities) { throw new Error(`[${mode}] initialize missing capabilities payload.`); } + assert.deepEqual( + result.capabilities.experimental.pairofcleats.manifest, + expectedManifest, + `[${mode}] initialize manifest mismatch.` + ); send({ jsonrpc: '2.0', id: 2, method: 'shutdown' }); await readMessage(); diff --git a/tests/services/mcp/fairness.test.js b/tests/services/mcp/fairness.test.js new file mode 100644 index 000000000..c4415da5f --- /dev/null +++ b/tests/services/mcp/fairness.test.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { startMcpServer } from '../../helpers/mcp-client.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const root = process.cwd(); +const cacheRoot = resolveTestCachePath(root, 'mcp-fairness'); +const repoRoot = path.join(cacheRoot, 'repo'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +const gitInit = spawnSync('git', ['init', '-q'], { cwd: repoRoot, stdio: 'ignore' }); +if (gitInit.status !== 0) { + throw new Error('Failed to initialize temporary git repository for MCP fairness test.'); +} + +const initializeServer = async (session, id) => { + session.send({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await session.readMessage(); +}; + +const shutdownServer = async (session, id) => { + session.send({ jsonrpc: '2.0', id, method: 'shutdown' }); + await session.readMessage(); + session.send({ jsonrpc: '2.0', method: 'exit' }); +}; + +const parsePayload = (response) => { + try { + return JSON.parse(response?.result?.content?.[0]?.text || '{}'); + } catch { + return {}; + } +}; + +const session = await startMcpServer({ + cacheRoot, + timeoutMs: 30000, + env: { + PAIROFCLEATS_TEST_MCP_DELAY_MS: '400', + PAIROFCLEATS_TEST_MCP_DELAY_TOOL_NAMES: 'search' + } +}); + +try { + await initializeServer(session, 1); + + session.send({ + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'search', + arguments: { repoPath: repoRoot, query: 'needle' } + } + }); + session.send({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'config_status', + arguments: { repoPath: repoRoot } + } + }); + + const first = await session.readMessage(); + const second = await session.readMessage(); + if (first?.id !== 42 || second?.id !== 41) { + throw new Error(`Expected fast config_status response before delayed search (got ${first?.id}, ${second?.id}).`); + } + + session.send({ + jsonrpc: '2.0', + id: 51, + method: 'tools/call', + params: { + name: 'search', + arguments: { repoPath: repoRoot, query: 'cancelled needle' } + } + }); + session.send({ + jsonrpc: '2.0', + id: 52, + method: 'tools/call', + params: { + name: 'config_status', + arguments: { repoPath: repoRoot } + } + }); + session.send({ + jsonrpc: '2.0', + method: '$/cancelRequest', + params: { id: 51 } + }); + + const third = await session.readMessage(); + const fourth = await session.readMessage(); + const responses = [third, fourth]; + const configResponse = responses.find((entry) => entry?.id === 52); + const cancelledResponse = responses.find((entry) => entry?.id === 51); + if (!configResponse) { + throw new Error('Expected config_status response during concurrent cancellation scenario.'); + } + const cancelledPayload = parsePayload(cancelledResponse); + if (!cancelledResponse?.result?.isError || cancelledPayload.code !== 'CANCELLED') { + throw new Error('Expected cancelled search response while fast request continued.'); + } + + await shutdownServer(session, 2); +} finally { + await session.shutdown(); +} + +console.log('MCP fairness test passed'); diff --git a/tests/services/mcp/lane-reservation-fairness.test.js b/tests/services/mcp/lane-reservation-fairness.test.js new file mode 100644 index 000000000..56d402d16 --- /dev/null +++ b/tests/services/mcp/lane-reservation-fairness.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { startMcpServer } from '../../helpers/mcp-client.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const root = process.cwd(); +const cacheRoot = resolveTestCachePath(root, 'mcp-lane-reservation-fairness'); +const repoRoot = path.join(cacheRoot, 'repo'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +const gitInit = spawnSync('git', ['init', '-q'], { cwd: repoRoot, stdio: 'ignore' }); +if (gitInit.status !== 0) { + throw new Error('Failed to initialize temporary git repository for MCP lane reservation fairness test.'); +} + +const initializeServer = async (session, id) => { + session.send({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await session.readMessage(); +}; + +const shutdownServer = async (session, id) => { + session.send({ jsonrpc: '2.0', id, method: 'shutdown' }); + await session.readMessage(); + session.send({ jsonrpc: '2.0', method: 'exit' }); +}; + +const session = await startMcpServer({ + cacheRoot, + timeoutMs: 30000, + env: { + PAIROFCLEATS_MCP_QUEUE_MAX: '3', + PAIROFCLEATS_TEST_MCP_DELAY_MS: '400', + PAIROFCLEATS_TEST_MCP_DELAY_TOOL_NAMES: 'search' + } +}); + +try { + await initializeServer(session, 1); + + session.send({ + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'search', + arguments: { repoPath: repoRoot, query: 'slow one' } + } + }); + session.send({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'search', + arguments: { repoPath: repoRoot, query: 'slow two' } + } + }); + session.send({ + jsonrpc: '2.0', + id: 43, + method: 'tools/call', + params: { + name: 'search', + arguments: { repoPath: repoRoot, query: 'slow three' } + } + }); + session.send({ + jsonrpc: '2.0', + id: 44, + method: 'tools/call', + params: { + name: 'config_status', + arguments: { repoPath: repoRoot } + } + }); + + const responses = []; + while (responses.length < 4) { + const message = await session.readMessage(); + if (message?.method === 'notifications/progress') continue; + responses.push(message); + } + + const overloaded = responses.find((entry) => entry?.id === 43); + if (!overloaded || overloaded?.error?.data?.code !== 'QUEUE_OVERLOADED') { + throw new Error('Expected third slow request to be rejected by lane reservation overload policy.'); + } + if (overloaded?.error?.data?.reason !== 'fast-lane-reserved-capacity') { + throw new Error(`Expected reserved fast-lane overload reason, got ${overloaded?.error?.data?.reason || 'missing'}.`); + } + + const fastResponse = responses.find((entry) => entry?.id === 44); + if (!fastResponse?.result) { + throw new Error('Expected fast config_status request to be admitted while slow lane reserve was saturated.'); + } + + const fastIndex = responses.findIndex((entry) => entry?.id === 44); + const slowSuccessIndex = responses.findIndex((entry) => ( + (entry?.id === 41 || entry?.id === 42) && entry?.result + )); + if (fastIndex < 0 || slowSuccessIndex < 0 || fastIndex > slowSuccessIndex) { + throw new Error('Expected fast request to complete before any admitted slow request under mixed load.'); + } + + await shutdownServer(session, 2); +} finally { + await session.shutdown(); +} + +console.log('MCP lane reservation fairness test passed'); diff --git a/tests/services/mcp/mcp-robustness.test.js b/tests/services/mcp/mcp-robustness.test.js deleted file mode 100644 index f745a8836..000000000 --- a/tests/services/mcp/mcp-robustness.test.js +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import { spawn } from 'node:child_process'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); -const root = process.cwd(); -const serverPath = path.join(root, 'tools', 'mcp', 'server.js'); -const tempRoot = resolveTestCachePath(root, 'mcp-robustness'); -const queueCache = path.join(tempRoot, 'queue-cache'); -const timeoutCache = path.join(tempRoot, 'timeout-cache'); -const cancelCache = path.join(tempRoot, 'cancel-cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(queueCache, { recursive: true }); -await fsPromises.mkdir(timeoutCache, { recursive: true }); -await fsPromises.mkdir(cancelCache, { recursive: true }); - -function encodeMessage(payload) { - const json = JSON.stringify(payload); - return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`; -} - -function createReader(stream) { - let buffer = Buffer.alloc(0); - const tryRead = () => { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) return null; - const header = buffer.slice(0, headerEnd).toString('utf8'); - const match = header.match(/Content-Length:\s*(\d+)/i); - if (!match) { - buffer = buffer.slice(headerEnd + 4); - return null; - } - const length = parseInt(match[1], 10); - const total = headerEnd + 4 + length; - if (buffer.length < total) return null; - const body = buffer.slice(headerEnd + 4, total).toString('utf8'); - buffer = buffer.slice(total); - return JSON.parse(body); - }; - const notifications = []; - const readRaw = async () => { - const existing = tryRead(); - if (existing) return existing; - return new Promise((resolve) => { - const onData = (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - const parsed = tryRead(); - if (!parsed) return; - stream.off('data', onData); - resolve(parsed); - }; - stream.on('data', onData); - }); - }; - const readMessage = async () => { - while (true) { - const parsed = await readRaw(); - if (parsed && parsed.method && parsed.id === undefined) { - notifications.push(parsed); - continue; - } - return parsed; - } - }; - return { readMessage, notifications }; -} - -function waitForExit(server, label, timeoutMs = 5000) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - server.kill('SIGKILL'); - reject(new Error(`MCP ${label} did not exit in time`)); - }, timeoutMs); - server.once('exit', (code, signal) => { - clearTimeout(timer); - resolve({ code, signal }); - }); - }); -} - -async function runQueueTest() { - const server = spawn(process.execPath, [serverPath], { - stdio: ['pipe', 'pipe', 'inherit'], - env: { - ...process.env, PAIROFCLEATS_HOME: queueCache, - PAIROFCLEATS_CACHE_ROOT: queueCache, - PAIROFCLEATS_MCP_QUEUE_MAX: '1' - } - }); - const { readMessage } = createReader(server.stdout); - const timeout = setTimeout(() => { - console.error('MCP queue test timed out.'); - server.kill('SIGKILL'); - process.exit(1); - }, 30000); - const send = (payload) => server.stdin.write(encodeMessage(payload)); - - try { - send({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }); - await readMessage(); - - send({ - jsonrpc: '2.0', - id: 0, - method: 'tools/list', - params: {} - }); - const idZeroResponse = await readMessage(); - if (idZeroResponse?.id !== 0) { - throw new Error('Expected MCP response to preserve id=0'); - } - - send({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'index_status', arguments: { repoPath: root } } - }); - send({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'index_status', arguments: { repoPath: root } } - }); - - const first = await readMessage(); - const second = await readMessage(); - const responses = [first, second]; - const overload = responses.find((msg) => msg?.error?.code === -32001); - if (!overload || overload.error?.data?.code !== 'QUEUE_OVERLOADED') { - throw new Error('Expected queue overload error response.'); - } - - send({ jsonrpc: '2.0', id: 4, method: 'shutdown' }); - await readMessage(); - send({ jsonrpc: '2.0', method: 'exit' }); - await waitForExit(server, 'queue test server'); - } catch (err) { - server.kill('SIGKILL'); - throw err; - } finally { - clearTimeout(timeout); - server.stdin.end(); - } -} - -async function runCancelTest() { - const server = spawn(process.execPath, [serverPath], { - stdio: ['pipe', 'pipe', 'inherit'], - env: { - ...process.env, PAIROFCLEATS_HOME: cancelCache, - PAIROFCLEATS_CACHE_ROOT: cancelCache, - PAIROFCLEATS_TEST_MCP_DELAY_MS: '250' - } - }); - const { readMessage } = createReader(server.stdout); - const timeout = setTimeout(() => { - console.error('MCP cancel test timed out.'); - server.kill('SIGKILL'); - process.exit(1); - }, 30000); - const send = (payload) => server.stdin.write(encodeMessage(payload)); - - try { - send({ - jsonrpc: '2.0', - id: 20, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }); - await readMessage(); - - send({ - jsonrpc: '2.0', - id: 21, - method: 'tools/call', - params: { name: 'index_status', arguments: { repoPath: root } } - }); - send({ - jsonrpc: '2.0', - method: '$/cancelRequest', - params: { id: 21 } - }); - - const response = await readMessage(); - const payloadText = response.result?.content?.[0]?.text || ''; - const payload = JSON.parse(payloadText || '{}'); - if (!response.result?.isError || payload.code !== 'CANCELLED') { - throw new Error('Expected cancelled tool response.'); - } - - send({ jsonrpc: '2.0', id: 22, method: 'shutdown' }); - await readMessage(); - send({ jsonrpc: '2.0', method: 'exit' }); - await waitForExit(server, 'cancel test server'); - } catch (err) { - server.kill('SIGKILL'); - throw err; - } finally { - clearTimeout(timeout); - server.stdin.end(); - } -} - -async function runProgressThrottleTest() { - const server = spawn(process.execPath, [serverPath], { - stdio: ['pipe', 'pipe', 'inherit'], - env: { - ...process.env, PAIROFCLEATS_HOME: cancelCache, - PAIROFCLEATS_CACHE_ROOT: cancelCache, - PAIROFCLEATS_TEST_MCP_DELAY_MS: '250' - } - }); - const { readMessage, notifications } = createReader(server.stdout); - const timeout = setTimeout(() => { - console.error('MCP progress throttle test timed out.'); - server.kill('SIGKILL'); - process.exit(1); - }, 30000); - const send = (payload) => server.stdin.write(encodeMessage(payload)); - - try { - send({ - jsonrpc: '2.0', - id: 30, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }); - await readMessage(); - - send({ - jsonrpc: '2.0', - id: 31, - method: 'tools/call', - params: { name: 'index_status', arguments: { repoPath: root } } - }); - await readMessage(); - - const progressCount = notifications.filter((msg) => msg?.method === 'notifications/progress').length; - if (progressCount < 1 || progressCount > 2) { - throw new Error(`Expected throttled progress notifications (1-2), got ${progressCount}.`); - } - - send({ jsonrpc: '2.0', id: 32, method: 'shutdown' }); - await readMessage(); - send({ jsonrpc: '2.0', method: 'exit' }); - await waitForExit(server, 'progress test server'); - } catch (err) { - server.kill('SIGKILL'); - throw err; - } finally { - clearTimeout(timeout); - server.stdin.end(); - } -} - -async function runTimeoutTest() { - const server = spawn(process.execPath, [serverPath], { - stdio: ['pipe', 'pipe', 'inherit'], - env: { - ...process.env, PAIROFCLEATS_HOME: timeoutCache, - PAIROFCLEATS_CACHE_ROOT: timeoutCache, - PAIROFCLEATS_MCP_TOOL_TIMEOUT_MS: '1' - } - }); - const { readMessage } = createReader(server.stdout); - const timeout = setTimeout(() => { - console.error('MCP timeout test timed out.'); - server.kill('SIGKILL'); - process.exit(1); - }, 30000); - const send = (payload) => server.stdin.write(encodeMessage(payload)); - - try { - send({ - jsonrpc: '2.0', - id: 10, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }); - await readMessage(); - - send({ - jsonrpc: '2.0', - id: 11, - method: 'tools/call', - params: { name: 'index_status', arguments: { repoPath: root } } - }); - const response = await readMessage(); - const payloadText = response.result?.content?.[0]?.text || ''; - const payload = JSON.parse(payloadText || '{}'); - if (!response.result?.isError || payload.code !== 'TOOL_TIMEOUT') { - throw new Error('Expected tool timeout error response.'); - } - - send({ jsonrpc: '2.0', id: 12, method: 'shutdown' }); - await readMessage(); - send({ jsonrpc: '2.0', method: 'exit' }); - await waitForExit(server, 'timeout test server'); - } catch (err) { - server.kill('SIGKILL'); - throw err; - } finally { - clearTimeout(timeout); - server.stdin.end(); - } -} - -runQueueTest() - .then(runCancelTest) - .then(runProgressThrottleTest) - .then(runTimeoutTest) - .then(() => { - console.log('MCP robustness tests passed'); - }) - .catch((err) => { - console.error(err?.message || err); - process.exit(1); - }); - diff --git a/tests/services/mcp/mcp-schema.test.js b/tests/services/mcp/mcp-schema.test.js deleted file mode 100644 index 2e276f941..000000000 --- a/tests/services/mcp/mcp-schema.test.js +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import { spawn } from 'node:child_process'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { getToolCatalog, getToolDefs, MCP_SCHEMA_VERSION } from '../../../src/integrations/mcp/defs.js'; -import { stableStringify } from '../../../src/shared/stable-json.js'; -import { DEFAULT_MODEL_ID } from '../../../tools/shared/dict-utils.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); -const root = process.cwd(); -const serverPath = path.join(root, 'tools', 'mcp', 'server.js'); -const sampleRepo = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'mcp-schema'); -const cacheRoot = path.join(tempRoot, 'cache'); -const emptyRepo = path.join(tempRoot, 'empty'); -const defaultCacheHome = path.join(tempRoot, 'default-cache-home'); -const snapshotPath = path.join(root, 'tests', 'fixtures', 'mcp', 'schema-snapshot.json'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.mkdir(emptyRepo, { recursive: true }); - -// The config_status/index_status tools report dictionary paths based on the test-only fallback -// lookup in the default cache root. That default location depends on environment variables -// like XDG_CACHE_HOME / LOCALAPPDATA. In CI, the default cache is usually empty, while local -// dev machines often have dictionaries downloaded, causing snapshot instability. -// -// To keep the snapshot stable across environments, force the default cache root to a temp -// location and seed it with exactly one dictionary file and a placeholder vector extension -// binary. These are used by config_status/index_status via test-only fallback lookups that -// bypass PAIROFCLEATS_CACHE_ROOT. -const fallbackDictionaryFiles = [ - path.join(defaultCacheHome, 'pairofcleats', 'dictionaries', 'combined.txt'), - path.join(defaultCacheHome, 'PairOfCleats', 'dictionaries', 'combined.txt') -]; -for (const filePath of fallbackDictionaryFiles) { - await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); - await fsPromises.writeFile(filePath, 'test\n', 'utf8'); -} - -// getExtensionsDir() uses getDefaultCacheRoot() in testing mode, which means the presence of the -// sqlite vector extension binary can change the warnings emitted by config_status. Create a -// zero-byte placeholder at the expected default path to make the snapshot deterministic. -const binarySuffix = process.platform === 'win32' - ? '.dll' - : (process.platform === 'darwin' ? '.dylib' : '.so'); -const platformKey = `${process.platform}-${process.arch}`; -const extensionRelPath = path.join( - 'extensions', - 'sqlite-vec', - platformKey, - `vec0${binarySuffix}` -); -const fallbackExtensionFiles = [ - path.join(defaultCacheHome, 'pairofcleats', extensionRelPath), - path.join(defaultCacheHome, 'PairOfCleats', extensionRelPath) -]; -for (const filePath of fallbackExtensionFiles) { - await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); - await fsPromises.writeFile(filePath, Buffer.alloc(0)); -} - -function encodeMessage(payload) { - const json = JSON.stringify(payload); - return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`; -} - -function createReader(stream) { - let buffer = Buffer.alloc(0); - const tryRead = () => { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) return null; - const header = buffer.slice(0, headerEnd).toString('utf8'); - const match = header.match(/Content-Length:\s*(\d+)/i); - if (!match) { - buffer = buffer.slice(headerEnd + 4); - return null; - } - const length = parseInt(match[1], 10); - const total = headerEnd + 4 + length; - if (buffer.length < total) return null; - const body = buffer.slice(headerEnd + 4, total).toString('utf8'); - buffer = buffer.slice(total); - return JSON.parse(body); - }; - const notifications = []; - const readRaw = async () => { - const existing = tryRead(); - if (existing) return existing; - return new Promise((resolve) => { - const onData = (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - const parsed = tryRead(); - if (!parsed) return; - stream.off('data', onData); - resolve(parsed); - }; - stream.on('data', onData); - }); - }; - const readMessage = async () => { - while (true) { - const parsed = await readRaw(); - if (parsed && parsed.method && parsed.id === undefined) { - notifications.push(parsed); - continue; - } - return parsed; - } - }; - return { readMessage, notifications }; -} - -const server = spawn(process.execPath, [serverPath], { - stdio: ['pipe', 'pipe', 'inherit'], - env: { - ...process.env, PAIROFCLEATS_HOME: cacheRoot, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - // Force the default cache root to our seeded test directory to keep schema snapshots stable. - XDG_CACHE_HOME: defaultCacheHome, - LOCALAPPDATA: process.platform === 'win32' ? defaultCacheHome : '' - } -}); - -const { readMessage } = createReader(server.stdout); -const timeout = setTimeout(() => { - console.error('MCP schema test timed out.'); - server.kill('SIGKILL'); - process.exit(1); -}, 30000); - -function send(payload) { - server.stdin.write(encodeMessage(payload)); -} - -const shapeValue = (value) => { - if (Array.isArray(value)) { - return value.map((entry) => shapeValue(entry)); - } - if (value && typeof value === 'object') { - const out = {}; - for (const key of Object.keys(value).sort()) { - out[key] = shapeValue(value[key]); - } - return out; - } - if (value === null) return ''; - return `<${typeof value}>`; -}; - -const toolSchemaSnapshot = getToolDefs(DEFAULT_MODEL_ID).map((tool) => ({ - name: tool.name, - required: Array.isArray(tool.inputSchema?.required) - ? [...tool.inputSchema.required].sort() - : [], - properties: Object.keys(tool.inputSchema?.properties || {}).sort() -})); -const toolCatalog = getToolCatalog(DEFAULT_MODEL_ID); -if (!toolCatalog.schemaVersion) { - throw new Error('MCP schemaVersion missing from tool catalog.'); -} -if (toolCatalog.schemaVersion !== MCP_SCHEMA_VERSION) { - throw new Error(`MCP schemaVersion mismatch (expected ${MCP_SCHEMA_VERSION}).`); -} -if (!toolCatalog.toolVersion) { - throw new Error('MCP toolVersion missing from tool catalog.'); -} - -const findFirstDiff = (expected, actual, currentPath = '') => { - if (expected === actual) return null; - - const classify = (value) => { - if (value === null) return 'null'; - if (Array.isArray(value)) return 'array'; - return typeof value; - }; - - const expectedType = classify(expected); - const actualType = classify(actual); - if (expectedType !== actualType) { - return { - path: currentPath || '', - expected, - actual, - reason: `type mismatch (${expectedType} vs ${actualType})` - }; - } - - if (expectedType === 'array') { - if (expected.length !== actual.length) { - return { - path: currentPath || '', - expected: `len=${expected.length}`, - actual: `len=${actual.length}`, - reason: 'array length mismatch' - }; - } - for (let i = 0; i < expected.length; i += 1) { - const next = findFirstDiff(expected[i], actual[i], `${currentPath}[${i}]`); - if (next) return next; - } - return null; - } - - if (expectedType === 'object') { - const expectedKeys = expected ? Object.keys(expected) : []; - const actualKeys = actual ? Object.keys(actual) : []; - expectedKeys.sort(); - actualKeys.sort(); - const expectedSet = new Set(expectedKeys); - const actualSet = new Set(actualKeys); - for (const key of expectedKeys) { - if (!actualSet.has(key)) { - return { - path: currentPath ? `${currentPath}.${key}` : key, - expected: '', - actual: '', - reason: 'missing key' - }; - } - } - for (const key of actualKeys) { - if (!expectedSet.has(key)) { - return { - path: currentPath ? `${currentPath}.${key}` : key, - expected: '', - actual: '', - reason: 'unexpected key' - }; - } - } - for (const key of expectedKeys) { - const next = findFirstDiff( - expected[key], - actual[key], - currentPath ? `${currentPath}.${key}` : key - ); - if (next) return next; - } - return null; - } - - return { - path: currentPath || '', - expected, - actual, - reason: 'value mismatch' - }; -}; - -async function run() { - send({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }); - await readMessage(); - - send({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'index_status', - arguments: { repoPath: sampleRepo } - } - }); - const status = await readMessage(); - const statusText = status.result?.content?.[0]?.text || ''; - const statusPayload = JSON.parse(statusText || '{}'); - - send({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'config_status', - arguments: { repoPath: emptyRepo } - } - }); - const configStatus = await readMessage(); - const configText = configStatus.result?.content?.[0]?.text || ''; - const configPayload = JSON.parse(configText || '{}'); - - send({ jsonrpc: '2.0', id: 4, method: 'shutdown' }); - await readMessage(); - send({ jsonrpc: '2.0', method: 'exit' }); - - return { - tools: toolSchemaSnapshot, - responses: { - index_status: shapeValue(statusPayload), - config_status: shapeValue(configPayload) - } - }; -} - -run() - .then(async (actual) => { - clearTimeout(timeout); - server.stdin.end(); - const expectedRaw = await fsPromises.readFile(snapshotPath, 'utf8'); - const expected = JSON.parse(expectedRaw); - const expectedStable = stableStringify(expected); - const actualStable = stableStringify(actual); - if (actualStable !== expectedStable) { - console.error('MCP schema snapshot mismatch.'); - const diff = findFirstDiff(expected, actual); - if (diff) { - console.error(`First diff (${diff.reason}) at: ${diff.path}`); - console.error(`Expected: ${JSON.stringify(diff.expected)}`); - console.error(`Actual: ${JSON.stringify(diff.actual)}`); - } - - const debugActualPath = path.join(tempRoot, 'schema-snapshot.actual.json'); - const debugExpectedPath = path.join(tempRoot, 'schema-snapshot.expected.json'); - await fsPromises.writeFile(debugActualPath, `${actualStable}\n`, 'utf8'); - await fsPromises.writeFile(debugExpectedPath, `${expectedStable}\n`, 'utf8'); - console.error(`Wrote expected snapshot to: ${debugExpectedPath}`); - console.error(`Wrote actual snapshot to: ${debugActualPath}`); - - const updateSnapshots = process.env.PAIROFCLEATS_UPDATE_SNAPSHOTS === '1' - || process.env.UPDATE_SNAPSHOTS === '1'; - if (updateSnapshots) { - await fsPromises.writeFile(snapshotPath, `${actualStable}\n`, 'utf8'); - console.error(`Updated snapshot at: ${snapshotPath}`); - process.exit(0); - } - - console.error('Set PAIROFCLEATS_UPDATE_SNAPSHOTS=1 to update schema-snapshot.json.'); - process.exit(1); - } - console.log('MCP schema snapshot test passed'); - }) - .catch((err) => { - clearTimeout(timeout); - console.error(err?.message || err); - server.kill('SIGKILL'); - process.exit(1); - }); - diff --git a/tests/services/mcp/mcp-mode-selection.test.js b/tests/services/mcp/mode-selection.test.js similarity index 100% rename from tests/services/mcp/mcp-mode-selection.test.js rename to tests/services/mcp/mode-selection.test.js diff --git a/tests/services/mcp/observability-correlation.test.js b/tests/services/mcp/observability-correlation.test.js new file mode 100644 index 000000000..7319dae58 --- /dev/null +++ b/tests/services/mcp/observability-correlation.test.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { startMcpServer } from '../../helpers/mcp-client.js'; + +const cacheRoot = path.join(process.cwd(), 'tests', '.cache', 'mcp-observability-correlation'); +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + extraEnv: { + PAIROFCLEATS_TEST_MCP_DELAY_MS: '50' + }, + syncProcess: false +}); + +const { send, readMessage, notifications, shutdown } = await startMcpServer({ + cacheRoot, + timeoutMs: 240000, + env +}); + +try { + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await readMessage(); + + notifications.length = 0; + send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'config_status', + arguments: { + repoPath: process.cwd() + }, + _meta: { + progressToken: 'obs-progress-token', + correlationId: 'mcp-correlation-test', + requestId: 'mcp-request-test' + } + } + }); + const response = await readMessage(); + const payload = JSON.parse(response?.result?.content?.[0]?.text || '{}'); + assert.equal(payload?.observability?.correlation?.correlationId, 'mcp-correlation-test'); + assert.equal(payload?.observability?.correlation?.requestId, 'mcp-request-test'); + + const progressEvents = notifications.filter( + (msg) => msg.method === 'notifications/progress' && msg.params?.tool === 'config_status' + ); + assert.ok(progressEvents.length > 0, 'expected MCP config_status progress notifications'); + assert.equal( + progressEvents.every((msg) => msg.params?.observability?.correlation?.correlationId === 'mcp-correlation-test'), + true, + 'expected progress notifications to preserve MCP correlation' + ); + + send({ jsonrpc: '2.0', id: 3, method: 'shutdown' }); + await readMessage(); + send({ jsonrpc: '2.0', method: 'exit' }); +} finally { + await shutdown(); +} + +console.log('MCP observability correlation test passed'); diff --git a/tests/services/mcp/repo-path-resolution.test.js b/tests/services/mcp/repo-path-resolution.test.js index 720ccf097..1ffa283d6 100644 --- a/tests/services/mcp/repo-path-resolution.test.js +++ b/tests/services/mcp/repo-path-resolution.test.js @@ -6,6 +6,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { resolveRepoPath, resolveToolTimeoutMs } from '../../../tools/mcp/repo.js'; +import { resolveMcpRepoContext } from '../../../tools/mcp/tools/helpers.js'; import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -51,6 +52,25 @@ applyTestEnv({ cacheRoot }); try { const rootCache = await ensureRepoArtifacts(repoRoot, 'root-build'); + const configPath = path.join(repoRoot, '.pairofcleats.json'); + await fsPromises.writeFile( + configPath, + JSON.stringify({ + runtime: { + nodeOptions: '--trace-warnings', + maxOldSpaceMb: 1280, + uvThreadpoolSize: 11 + }, + mcp: { + toolTimeoutMs: 4321, + toolTimeouts: { + search: 9876 + } + } + }, null, 2), + 'utf8' + ); + const resolvedFromNested = resolveRepoPath(nested); assert.equal( normPath(resolvedFromNested), @@ -58,6 +78,23 @@ try { 'repoPath should resolve to repository root when canonical root has artifacts' ); + const runtimeBaseEnv = { ...process.env }; + delete runtimeBaseEnv.NODE_OPTIONS; + delete runtimeBaseEnv.UV_THREADPOOL_SIZE; + delete runtimeBaseEnv.PAIROFCLEATS_NODE_OPTIONS; + delete runtimeBaseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; + delete runtimeBaseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; + const mcpContext = resolveMcpRepoContext(nested, { baseEnv: runtimeBaseEnv }); + assert.equal( + normPath(mcpContext.repoPath), + normPath(repoRoot), + 'MCP context helper should preserve artifact-aware repoPath resolution' + ); + assert.equal(mcpContext.userConfig.runtime?.uvThreadpoolSize, 11); + assert.equal(mcpContext.runtimeEnv.UV_THREADPOOL_SIZE, '11'); + assert.match(String(mcpContext.runtimeEnv.NODE_OPTIONS || ''), /--trace-warnings/); + assert.match(String(mcpContext.runtimeEnv.NODE_OPTIONS || ''), /--max-old-space-size=1280/); + await fsPromises.rm(rootCache, { recursive: true, force: true }); await ensureRepoArtifacts(nested, 'nested-build'); const resolvedWithNestedArtifacts = resolveRepoPath(nested); @@ -67,20 +104,6 @@ try { 'repoPath should remain explicit when canonical root has no artifacts and explicit path does' ); - const configPath = path.join(repoRoot, '.pairofcleats.json'); - await fsPromises.writeFile( - configPath, - JSON.stringify({ - mcp: { - toolTimeoutMs: 4321, - toolTimeouts: { - search: 9876 - } - } - }, null, 2), - 'utf8' - ); - const perToolTimeout = resolveToolTimeoutMs('search', { repoPath: nested }, { envToolTimeoutMs: 111, defaultToolTimeoutMs: 222, diff --git a/tests/services/mcp/robustness.test.js b/tests/services/mcp/robustness.test.js new file mode 100644 index 000000000..b4b1ea4af --- /dev/null +++ b/tests/services/mcp/robustness.test.js @@ -0,0 +1,275 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { startMcpServer } from '../../helpers/mcp-client.js'; + +applyTestEnv(); +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'mcp-robustness'); +const queueCache = path.join(tempRoot, 'queue-cache'); +const timeoutCache = path.join(tempRoot, 'timeout-cache'); +const cancelCache = path.join(tempRoot, 'cancel-cache'); +const duplicateIdCache = path.join(tempRoot, 'duplicate-id-cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(queueCache, { recursive: true }); +await fsPromises.mkdir(timeoutCache, { recursive: true }); +await fsPromises.mkdir(cancelCache, { recursive: true }); +await fsPromises.mkdir(duplicateIdCache, { recursive: true }); + +const initializeServer = async (session, id) => { + session.send({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await session.readMessage(); +}; + +const shutdownServer = async (session, id) => { + session.send({ jsonrpc: '2.0', id, method: 'shutdown' }); + await session.readMessage(); + session.send({ jsonrpc: '2.0', method: 'exit' }); +}; + +const parseToolErrorPayload = (response) => { + const payloadText = response?.result?.content?.[0]?.text || ''; + try { + return JSON.parse(payloadText || '{}'); + } catch { + return {}; + } +}; + +async function runQueueTest() { + const session = await startMcpServer({ + cacheRoot: queueCache, + timeoutMs: 30000, + env: { + PAIROFCLEATS_MCP_QUEUE_MAX: '1' + } + }); + try { + await initializeServer(session, 1); + + session.send({ + jsonrpc: '2.0', + id: 0, + method: 'tools/list', + params: {} + }); + const idZeroResponse = await session.readMessage(); + if (idZeroResponse?.id !== 0) { + throw new Error('Expected MCP response to preserve id=0'); + } + + session.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + session.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + + const first = await session.readMessage(); + const second = await session.readMessage(); + const responses = [first, second]; + const overload = responses.find((msg) => msg?.error?.code === -32001); + if (!overload || overload.error?.data?.code !== 'QUEUE_OVERLOADED') { + throw new Error('Expected queue overload error response.'); + } + + await shutdownServer(session, 4); + } finally { + await session.shutdown(); + } +} + +async function runCancelTest() { + const session = await startMcpServer({ + cacheRoot: cancelCache, + timeoutMs: 30000, + env: { + PAIROFCLEATS_TEST_MCP_DELAY_MS: '250' + } + }); + try { + await initializeServer(session, 20); + + session.send({ + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + session.send({ + jsonrpc: '2.0', + method: '$/cancelRequest', + params: { id: 21 } + }); + + const response = await session.readMessage(); + const payload = parseToolErrorPayload(response); + if (!response.result?.isError || payload.code !== 'CANCELLED') { + throw new Error('Expected cancelled tool response.'); + } + + await shutdownServer(session, 22); + } finally { + await session.shutdown(); + } +} + +async function runDuplicateRequestIdTest() { + const session = await startMcpServer({ + cacheRoot: duplicateIdCache, + timeoutMs: 30000, + env: { + PAIROFCLEATS_TEST_MCP_DELAY_MS: '250', + PAIROFCLEATS_TEST_MCP_DELAY_TOOL_NAMES: 'index_status' + } + }); + try { + await initializeServer(session, 40); + + session.send({ + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + session.send({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + session.send({ + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { name: 'config_status', arguments: { repoPath: root } } + }); + session.send({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { name: 'config_status', arguments: { repoPath: root } } + }); + + const responses = [ + await session.readMessage(), + await session.readMessage(), + await session.readMessage(), + await session.readMessage() + ]; + const duplicateErrors = responses.filter((message) => ( + message?.error?.code === -32600 + && message.error?.data?.code === ERROR_CODES.INVALID_REQUEST + && message.error?.data?.reason === 'duplicate-request-id' + )); + if (duplicateErrors.length !== 2) { + throw new Error(`Expected two duplicate request id errors, got ${duplicateErrors.length}.`); + } + const states = duplicateErrors + .map((message) => message.error?.data?.state) + .sort(); + if (states.join(',') !== 'pending,running') { + throw new Error(`Expected duplicate errors for pending and running requests, got ${states.join(',')}.`); + } + const successfulIds = responses + .filter((message) => message?.result && !message.result?.isError) + .map((message) => message.id) + .sort((a, b) => a - b); + if (successfulIds.join(',') !== '41,42') { + throw new Error(`Expected original requests 41 and 42 to complete, got ${successfulIds.join(',')}.`); + } + + await shutdownServer(session, 43); + } finally { + await session.shutdown(); + } +} + +async function runProgressThrottleTest() { + const session = await startMcpServer({ + cacheRoot: cancelCache, + timeoutMs: 30000, + env: { + PAIROFCLEATS_TEST_MCP_DELAY_MS: '250' + } + }); + try { + await initializeServer(session, 30); + + session.send({ + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + await session.readMessage(); + + const progressCount = session.notifications + .filter((msg) => msg?.method === 'notifications/progress') + .length; + if (progressCount < 1 || progressCount > 2) { + throw new Error(`Expected throttled progress notifications (1-2), got ${progressCount}.`); + } + + await shutdownServer(session, 32); + } finally { + await session.shutdown(); + } +} + +async function runTimeoutTest() { + const session = await startMcpServer({ + cacheRoot: timeoutCache, + timeoutMs: 30000, + env: { + PAIROFCLEATS_MCP_TOOL_TIMEOUT_MS: '1' + } + }); + try { + await initializeServer(session, 10); + + session.send({ + jsonrpc: '2.0', + id: 11, + method: 'tools/call', + params: { name: 'index_status', arguments: { repoPath: root } } + }); + const response = await session.readMessage(); + const payload = parseToolErrorPayload(response); + if (!response.result?.isError || payload.code !== 'TOOL_TIMEOUT') { + throw new Error('Expected tool timeout error response.'); + } + + await shutdownServer(session, 12); + } finally { + await session.shutdown(); + } +} + +runQueueTest() + .then(runCancelTest) + .then(runDuplicateRequestIdTest) + .then(runProgressThrottleTest) + .then(runTimeoutTest) + .then(() => { + console.log('MCP robustness tests passed'); + }) + .catch((err) => { + console.error(err?.message || err); + process.exit(1); + }); diff --git a/tests/services/mcp/mcp-runner-abort-kills-child.test.js b/tests/services/mcp/runner-abort-kills-child.test.js similarity index 100% rename from tests/services/mcp/mcp-runner-abort-kills-child.test.js rename to tests/services/mcp/runner-abort-kills-child.test.js diff --git a/tests/services/mcp/mcp-schema-version.test.js b/tests/services/mcp/schema-version.test.js similarity index 100% rename from tests/services/mcp/mcp-schema-version.test.js rename to tests/services/mcp/schema-version.test.js diff --git a/tests/services/mcp/schema.test.js b/tests/services/mcp/schema.test.js new file mode 100644 index 000000000..696881edd --- /dev/null +++ b/tests/services/mcp/schema.test.js @@ -0,0 +1,329 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getToolCatalog, getToolDefs, MCP_SCHEMA_VERSION } from '../../../src/integrations/mcp/defs.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { DEFAULT_MODEL_ID } from '../../../tools/shared/dict-utils.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { startMcpServer } from '../../helpers/mcp-client.js'; + +applyTestEnv(); +const root = process.cwd(); +const sampleRepo = path.join(root, 'tests', 'fixtures', 'sample'); +const tempRoot = resolveTestCachePath(root, 'mcp-schema'); +const cacheRoot = path.join(tempRoot, 'cache'); +const emptyRepo = path.join(tempRoot, 'empty'); +const defaultCacheHome = path.join(tempRoot, 'default-cache-home'); +const snapshotPath = path.join(root, 'tests', 'fixtures', 'mcp', 'schema-snapshot.json'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.mkdir(emptyRepo, { recursive: true }); + +// The config_status/index_status tools report dictionary paths based on the test-only fallback +// lookup in the default cache root. That default location depends on environment variables +// like XDG_CACHE_HOME / LOCALAPPDATA. In CI, the default cache is usually empty, while local +// dev machines often have dictionaries downloaded, causing snapshot instability. +// +// To keep the snapshot stable across environments, force the default cache root to a temp +// location and seed it with exactly one dictionary file and a placeholder vector extension +// binary. These are used by config_status/index_status via test-only fallback lookups that +// bypass PAIROFCLEATS_CACHE_ROOT. +const fallbackDictionaryFiles = [ + path.join(defaultCacheHome, 'pairofcleats', 'dictionaries', 'combined.txt'), + path.join(defaultCacheHome, 'PairOfCleats', 'dictionaries', 'combined.txt') +]; +for (const filePath of fallbackDictionaryFiles) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, 'test\n', 'utf8'); +} + +// getExtensionsDir() uses getDefaultCacheRoot() in testing mode, which means the presence of the +// sqlite vector extension binary can change the warnings emitted by config_status. Create a +// zero-byte placeholder at the expected default path to make the snapshot deterministic. +const binarySuffix = process.platform === 'win32' + ? '.dll' + : (process.platform === 'darwin' ? '.dylib' : '.so'); +const platformKey = `${process.platform}-${process.arch}`; +const extensionRelPath = path.join( + 'extensions', + 'sqlite-vec', + platformKey, + `vec0${binarySuffix}` +); +const fallbackExtensionFiles = [ + path.join(defaultCacheHome, 'pairofcleats', extensionRelPath), + path.join(defaultCacheHome, 'PairOfCleats', extensionRelPath) +]; +for (const filePath of fallbackExtensionFiles) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, Buffer.alloc(0)); +} + +const shapeValue = (value) => { + if (Array.isArray(value)) { + return value.map((entry) => shapeValue(entry)); + } + if (value && typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = shapeValue(value[key]); + } + return out; + } + if (value === null) return ''; + return `<${typeof value}>`; +}; + +const toolSchemaSnapshot = getToolDefs(DEFAULT_MODEL_ID).map((tool) => ({ + name: tool.name, + required: Array.isArray(tool.inputSchema?.required) + ? [...tool.inputSchema.required].sort() + : [], + properties: Object.keys(tool.inputSchema?.properties || {}).sort() +})); +const riskExplainTool = getToolDefs(DEFAULT_MODEL_ID).find((tool) => tool.name === 'risk_explain'); +if (!riskExplainTool) { + throw new Error('risk_explain tool missing from MCP tool defs.'); +} +if (!('includePartialFlows' in (riskExplainTool.inputSchema?.properties || {}))) { + throw new Error('risk_explain tool schema missing includePartialFlows.'); +} +if (!('maxPartialFlows' in (riskExplainTool.inputSchema?.properties || {}))) { + throw new Error('risk_explain tool schema missing maxPartialFlows.'); +} +const riskExplainFilterProperties = riskExplainTool.inputSchema?.properties?.filters?.properties || {}; +for (const key of ['flowId', 'flow_id', 'flow-id', 'sourceRule', 'source_rule', 'source-rule', 'sinkRule', 'sink_rule', 'sink-rule']) { + if (!(key in riskExplainFilterProperties)) { + throw new Error(`risk_explain tool schema missing ${key} alias.`); + } +} +const riskDeltaTool = getToolDefs(DEFAULT_MODEL_ID).find((tool) => tool.name === 'risk_delta'); +if (!riskDeltaTool) { + throw new Error('risk_delta tool missing from MCP tool defs.'); +} +if (!('from' in (riskDeltaTool.inputSchema?.properties || {}))) { + throw new Error('risk_delta tool schema missing from.'); +} +if (!('to' in (riskDeltaTool.inputSchema?.properties || {}))) { + throw new Error('risk_delta tool schema missing to.'); +} +if (!('seed' in (riskDeltaTool.inputSchema?.properties || {}))) { + throw new Error('risk_delta tool schema missing seed.'); +} +const riskDeltaFilterProperties = riskDeltaTool.inputSchema?.properties?.filters?.properties || {}; +for (const key of ['flowId', 'flow_id', 'flow-id', 'sourceRule', 'source_rule', 'source-rule', 'sinkRule', 'sink_rule', 'sink-rule']) { + if (!(key in riskDeltaFilterProperties)) { + throw new Error(`risk_delta tool schema missing ${key} alias.`); + } +} +const contextPackTool = getToolDefs(DEFAULT_MODEL_ID).find((tool) => tool.name === 'context_pack'); +if (!contextPackTool) { + throw new Error('context_pack tool missing from MCP tool defs.'); +} +if (!('filters' in (contextPackTool.inputSchema?.properties || {}))) { + throw new Error('context_pack tool schema missing filters.'); +} +const contextPackFilterProperties = contextPackTool.inputSchema?.properties?.filters?.properties || {}; +for (const key of ['flowId', 'flow_id', 'flow-id', 'sourceRule', 'source_rule', 'source-rule', 'sinkRule', 'sink_rule', 'sink-rule']) { + if (!(key in contextPackFilterProperties)) { + throw new Error(`context_pack tool schema missing ${key} alias.`); + } +} +const toolCatalog = getToolCatalog(DEFAULT_MODEL_ID); +if (!toolCatalog.schemaVersion) { + throw new Error('MCP schemaVersion missing from tool catalog.'); +} +if (toolCatalog.schemaVersion !== MCP_SCHEMA_VERSION) { + throw new Error(`MCP schemaVersion mismatch (expected ${MCP_SCHEMA_VERSION}).`); +} +if (!toolCatalog.toolVersion) { + throw new Error('MCP toolVersion missing from tool catalog.'); +} + +const findFirstDiff = (expected, actual, currentPath = '') => { + if (expected === actual) return null; + + const classify = (value) => { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; + }; + + const expectedType = classify(expected); + const actualType = classify(actual); + if (expectedType !== actualType) { + return { + path: currentPath || '', + expected, + actual, + reason: `type mismatch (${expectedType} vs ${actualType})` + }; + } + + if (expectedType === 'array') { + if (expected.length !== actual.length) { + return { + path: currentPath || '', + expected: `len=${expected.length}`, + actual: `len=${actual.length}`, + reason: 'array length mismatch' + }; + } + for (let i = 0; i < expected.length; i += 1) { + const next = findFirstDiff(expected[i], actual[i], `${currentPath}[${i}]`); + if (next) return next; + } + return null; + } + + if (expectedType === 'object') { + const expectedKeys = expected ? Object.keys(expected) : []; + const actualKeys = actual ? Object.keys(actual) : []; + expectedKeys.sort(); + actualKeys.sort(); + const expectedSet = new Set(expectedKeys); + const actualSet = new Set(actualKeys); + for (const key of expectedKeys) { + if (!actualSet.has(key)) { + return { + path: currentPath ? `${currentPath}.${key}` : key, + expected: '', + actual: '', + reason: 'missing key' + }; + } + } + for (const key of actualKeys) { + if (!expectedSet.has(key)) { + return { + path: currentPath ? `${currentPath}.${key}` : key, + expected: '', + actual: '', + reason: 'unexpected key' + }; + } + } + for (const key of expectedKeys) { + const next = findFirstDiff( + expected[key], + actual[key], + currentPath ? `${currentPath}.${key}` : key + ); + if (next) return next; + } + return null; + } + + return { + path: currentPath || '', + expected, + actual, + reason: 'value mismatch' + }; +}; + +async function run(session) { + session.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await session.readMessage(); + + session.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'index_status', + arguments: { repoPath: sampleRepo } + } + }); + const status = await session.readMessage(); + const statusText = status.result?.content?.[0]?.text || ''; + const statusPayload = JSON.parse(statusText || '{}'); + + session.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'config_status', + arguments: { repoPath: emptyRepo } + } + }); + const configStatus = await session.readMessage(); + const configText = configStatus.result?.content?.[0]?.text || ''; + const configPayload = JSON.parse(configText || '{}'); + + session.send({ jsonrpc: '2.0', id: 4, method: 'shutdown' }); + await session.readMessage(); + session.send({ jsonrpc: '2.0', method: 'exit' }); + + return { + tools: toolSchemaSnapshot, + responses: { + index_status: shapeValue(statusPayload), + config_status: shapeValue(configPayload) + } + }; +} + +const session = await startMcpServer({ + cacheRoot, + timeoutMs: 30000, + env: { + // Force the default cache root to our seeded test directory to keep schema snapshots stable. + XDG_CACHE_HOME: defaultCacheHome, + LOCALAPPDATA: process.platform === 'win32' ? defaultCacheHome : '' + } +}); + +run(session) + .then(async (actual) => { + try { + const expectedRaw = await fsPromises.readFile(snapshotPath, 'utf8'); + const expected = JSON.parse(expectedRaw); + const expectedStable = stableStringify(expected); + const actualStable = stableStringify(actual); + if (actualStable !== expectedStable) { + console.error('MCP schema snapshot mismatch.'); + const diff = findFirstDiff(expected, actual); + if (diff) { + console.error(`First diff (${diff.reason}) at: ${diff.path}`); + console.error(`Expected: ${JSON.stringify(diff.expected)}`); + console.error(`Actual: ${JSON.stringify(diff.actual)}`); + } + + const debugActualPath = path.join(tempRoot, 'schema-snapshot.actual.json'); + const debugExpectedPath = path.join(tempRoot, 'schema-snapshot.expected.json'); + await fsPromises.writeFile(debugActualPath, `${actualStable}\n`, 'utf8'); + await fsPromises.writeFile(debugExpectedPath, `${expectedStable}\n`, 'utf8'); + console.error(`Wrote expected snapshot to: ${debugExpectedPath}`); + console.error(`Wrote actual snapshot to: ${debugActualPath}`); + + const updateSnapshots = process.env.PAIROFCLEATS_UPDATE_SNAPSHOTS === '1' + || process.env.UPDATE_SNAPSHOTS === '1'; + if (updateSnapshots) { + await fsPromises.writeFile(snapshotPath, `${actualStable}\n`, 'utf8'); + console.error(`Updated snapshot at: ${snapshotPath}`); + return; + } + + console.error('Set PAIROFCLEATS_UPDATE_SNAPSHOTS=1 to update schema-snapshot.json.'); + process.exitCode = 1; + return; + } + console.log('MCP schema snapshot test passed'); + } finally { + await session.shutdown(); + } + }) + .catch(async (err) => { + console.error(err?.message || err); + process.exitCode = 1; + await session.shutdown(); + }); diff --git a/tests/services/mcp/mcp-search-arg-mapping.test.js b/tests/services/mcp/search-arg-mapping.test.js similarity index 100% rename from tests/services/mcp/mcp-search-arg-mapping.test.js rename to tests/services/mcp/search-arg-mapping.test.js diff --git a/tests/services/mcp/search-build-identity-observability.test.js b/tests/services/mcp/search-build-identity-observability.test.js new file mode 100644 index 000000000..8f634a9c6 --- /dev/null +++ b/tests/services/mcp/search-build-identity-observability.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { readCurrentBuildGeneration } from '../../../src/shared/indexing/build-pointer.js'; +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; +import { startMcpServer } from '../../helpers/mcp-client.js'; + +const cacheName = 'mcp-search-build-identity'; +const cacheRoot = path.join(process.cwd(), 'tests', '.cache', cacheName); +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); + +const { fixtureRoot, env, userConfig } = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName, + cacheScope: 'shared', + requiredModes: ['code'] +}); + +const repoCacheRoot = getRepoCacheRoot(fixtureRoot, userConfig); +const currentInfo = readCurrentBuildGeneration({ + currentJsonPath: path.join(repoCacheRoot, 'builds', 'current.json'), + repoCacheRoot, + buildsRoot: path.join(repoCacheRoot, 'builds') +}); + +const { send, readMessage, shutdown } = await startMcpServer({ + cacheRoot, + timeoutMs: 240000, + env +}); + +try { + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + await readMessage(); + + send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'search', + arguments: { + repoPath: fixtureRoot, + query: 'return', + mode: 'code', + top: 3 + } + } + }); + const response = await readMessage(); + const payload = JSON.parse(response?.result?.content?.[0]?.text || '{}'); + assert.equal(payload?.observability?.context?.buildId, currentInfo.buildId); + assert.equal( + payload?.observability?.context?.activeBuildRoot, + currentInfo.activeRoot, + 'expected MCP search result observability to expose the active generation root' + ); + assert.equal( + payload?.observability?.context?.buildGenerationKey, + currentInfo.generationKey, + 'expected MCP search result observability to expose the active generation key' + ); + assert.equal( + payload?.retrieval?.freshness?.activeGeneration?.buildId, + currentInfo.buildId, + 'expected MCP retrieval metadata to expose the active build id' + ); + assert.equal( + payload?.retrieval?.freshness?.activeGeneration?.activeBuildRoot, + currentInfo.activeRoot, + 'expected MCP retrieval metadata to expose the active build root' + ); + assert.equal( + payload?.retrieval?.freshness?.activeGeneration?.buildGenerationKey, + currentInfo.generationKey, + 'expected MCP retrieval metadata to expose the active generation key' + ); + + send({ jsonrpc: '2.0', id: 3, method: 'shutdown' }); + await readMessage(); + send({ jsonrpc: '2.0', method: 'exit' }); +} finally { + await shutdown(); +} + +console.log('MCP search build identity observability test passed'); diff --git a/tests/services/mcp/tool-config-status.test.js b/tests/services/mcp/tool-config-status.test.js index 9488f25ee..b88f5e408 100644 --- a/tests/services/mcp/tool-config-status.test.js +++ b/tests/services/mcp/tool-config-status.test.js @@ -36,6 +36,12 @@ try { if (missing.length) { throw new Error(`config_status missing warnings: ${missing.join(', ')}`); } + if (!payload.durability || typeof payload.durability !== 'object') { + throw new Error('config_status missing durability payload'); + } + if (payload.durability?.runtime?.degradedDurability !== false) { + throw new Error('config_status expected clean durability state for empty-repo baseline'); + } send({ jsonrpc: '2.0', id: 3, method: 'shutdown' }); await readMessage(); diff --git a/tests/services/mcp/tool-index-status.test.js b/tests/services/mcp/tool-index-status.test.js index ec8683dce..cfe5ee670 100644 --- a/tests/services/mcp/tool-index-status.test.js +++ b/tests/services/mcp/tool-index-status.test.js @@ -2,10 +2,30 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { startMcpServer } from '../../helpers/mcp-client.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { resolveRepoPath } from '../../../tools/mcp/repo.js'; const cacheRoot = path.join(process.cwd(), 'tests', '.cache', 'mcp-index-status'); const sampleRepo = path.join(process.cwd(), 'tests', 'fixtures', 'sample'); await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; +const resolvedRepo = resolveRepoPath(sampleRepo); +const userConfig = loadUserConfig(resolvedRepo); +const repoCacheRoot = getRepoCacheRoot(resolvedRepo, userConfig); +await fsPromises.mkdir(repoCacheRoot, { recursive: true }); +await fsPromises.writeFile(path.join(repoCacheRoot, 'watch-state.json'), JSON.stringify({ + consistency: 'catching-up', + quiescent: false, + backlogDepth: 3, + pendingReplay: true, + lastConsistentGeneration: { + buildId: 'watch-build-1', + buildRoot: path.join(repoCacheRoot, 'builds', 'watch-build-1'), + status: 'ok', + startedAt: '2026-03-23T12:00:00.000Z', + finishedAt: '2026-03-23T12:00:01.000Z' + } +}, null, 2)); const { send, readMessage, shutdown } = await startMcpServer({ cacheRoot }); @@ -33,6 +53,12 @@ try { if (!parsed.repoPath || !parsed.repoId) { throw new Error('index_status response missing repo info'); } + if (parsed.watch?.state?.consistency !== 'catching-up' || parsed.watch?.state?.backlogDepth !== 3) { + throw new Error('index_status response missing persisted watch consistency state'); + } + if (parsed.watch?.state?.lastConsistentGeneration?.buildId !== 'watch-build-1') { + throw new Error('index_status response missing last consistent generation'); + } send({ jsonrpc: '2.0', id: 3, method: 'shutdown' }); await readMessage(); diff --git a/tests/services/queue/admission-policy.test.js b/tests/services/queue/admission-policy.test.js new file mode 100644 index 000000000..b33f1bc10 --- /dev/null +++ b/tests/services/queue/admission-policy.test.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + evaluateQueueBackpressure, + resolveEnqueueBackpressure, + resolveQueueAdmissionPolicy, + resolveQueueSloPolicy +} from '../../../tools/service/admission-policy.js'; + +const policy = resolveQueueAdmissionPolicy({ + queueName: 'index', + queueConfig: { + maxQueued: 2, + maxRunning: 1, + maxTotal: 2, + resourceBudgetUnits: 10 + }, + workerConfig: { + concurrency: 1 + } +}); + +const jobs = [ + { id: 'job-a', status: 'running', queueName: 'index', stage: 'stage2', mode: 'code' }, + { id: 'job-b', status: 'queued', queueName: 'index', stage: 'stage1', mode: 'code' } +]; + +const backpressure = evaluateQueueBackpressure({ + jobs, + queueName: 'index', + policy +}); +assert.equal(backpressure.state, 'saturated', 'expected total active saturation to surface as saturated'); +assert.equal(backpressure.reasons.includes('max_running'), true, 'expected running saturation reason'); +assert.equal(backpressure.reasons.includes('max_total'), true, 'expected total saturation reason'); +assert.equal(backpressure.reasons.includes('resource_budget'), false, 'expected room to remain in the resource budget'); + +const block = resolveEnqueueBackpressure({ + jobs, + job: { id: 'job-c', queueName: 'index', stage: 'stage3', mode: 'both' }, + queueName: 'index', + policy +}); +assert.equal(block?.code, 'QUEUE_BACKPRESSURE_MAX_TOTAL', 'expected projected active limit to reject the enqueue'); +assert.equal(block?.reason, 'max_total', 'expected explicit rejection reason code'); + +const sloPolicy = resolveQueueSloPolicy({ + queueName: 'index', + queueConfig: { + slo: { + maxQueueAgeMs: { + degraded: 1000, + overloaded: 5000 + }, + maxRunLatencyMs: { + degraded: 1000, + overloaded: 5000 + }, + maxRetryRate: { + degraded: 0.25, + overloaded: 0.5 + }, + maxSaturationRatio: { + degraded: 0.5, + overloaded: 0.9 + }, + deferDelayMs: { + degraded: 2000, + overloaded: 7000 + } + } + } +}); +const nowMs = Date.parse('2026-03-18T12:00:10.000Z'); +const degradedJobs = [ + { + id: 'job-degraded', + status: 'queued', + queueName: 'index', + stage: 'stage1', + createdAt: '2026-03-18T12:00:08.000Z', + attempts: 0 + } +]; +const degradedBackpressure = evaluateQueueBackpressure({ + jobs: degradedJobs, + queueName: 'index', + policy, + sloPolicy, + nowMs +}); +assert.equal(degradedBackpressure.slo.state, 'degraded', 'expected aged queue to enter degraded SLO state'); +assert.equal(degradedBackpressure.slo.actions.workerMode, 'priority-only', 'expected degraded queues to advertise priority-only mode'); + +const deferredHeavy = resolveEnqueueBackpressure({ + jobs: degradedJobs, + job: { id: 'job-heavy', queueName: 'index', stage: 'stage3', mode: 'both' }, + queueName: 'index', + policy, + sloPolicy, + nowMs +}); +assert.equal(deferredHeavy?.action, 'defer', 'expected degraded heavy work to defer instead of silently enqueueing'); +assert.equal(deferredHeavy?.delayMs, 2000, 'expected degraded defer window to follow SLO policy'); +assert.equal(deferredHeavy?.jobTier, 'heavy', 'expected stage3 work to be classified as heavy'); + +const acceptedPriority = resolveEnqueueBackpressure({ + jobs: degradedJobs, + job: { id: 'job-light', queueName: 'index', stage: 'stage1', mode: 'code' }, + queueName: 'index', + policy, + sloPolicy, + nowMs +}); +assert.equal(acceptedPriority?.action, 'accept', 'expected degraded priority work to remain admitted'); + +const overloadedJobs = [ + { + id: 'job-overloaded-a', + status: 'queued', + queueName: 'index', + stage: 'stage1', + createdAt: '2026-03-18T11:59:55.000Z', + attempts: 1 + }, + { + id: 'job-overloaded-b', + status: 'running', + queueName: 'index', + stage: 'stage2', + createdAt: '2026-03-18T11:59:50.000Z', + startedAt: '2026-03-18T11:59:50.000Z', + attempts: 1 + } +]; +const overloadedBackpressure = evaluateQueueBackpressure({ + jobs: overloadedJobs, + queueName: 'index', + policy: resolveQueueAdmissionPolicy({ + queueName: 'index', + queueConfig: { + maxQueued: 10, + maxRunning: 10, + maxTotal: 20, + resourceBudgetUnits: 40 + }, + workerConfig: { + concurrency: 2 + } + }), + sloPolicy, + nowMs +}); +assert.equal(overloadedBackpressure.slo.state, 'overloaded', 'expected old and retrying work to enter overloaded SLO state'); + +const rejectedHeavy = resolveEnqueueBackpressure({ + jobs: overloadedJobs, + job: { id: 'job-heavy-reject', queueName: 'index', stage: 'stage3', mode: 'both' }, + queueName: 'index', + policy: resolveQueueAdmissionPolicy({ + queueName: 'index', + queueConfig: { + maxQueued: 10, + maxRunning: 10, + maxTotal: 20, + resourceBudgetUnits: 40 + }, + workerConfig: { + concurrency: 2 + } + }), + sloPolicy, + nowMs +}); +assert.equal(rejectedHeavy?.action, 'reject', 'expected overloaded heavy work to be rejected'); +assert.equal(rejectedHeavy?.code, 'QUEUE_SLO_OVERLOADED', 'expected overloaded rejection to use stable SLO code'); + +const embeddingsPolicy = resolveQueueAdmissionPolicy({ + queueName: 'embeddings-stage3', + queueConfig: { + maxQueued: 3 + }, + workerConfig: { + concurrency: 2 + } +}); +assert.equal(embeddingsPolicy.queueClass, 'embeddings', 'expected embeddings-* queues to retain embeddings policy class'); +assert.equal(embeddingsPolicy.maxTotal, 5, 'expected embeddings queue defaults to diverge from index totals'); +assert.equal(resolveQueueSloPolicy({ queueName: 'embeddings-stage3' }).queueClass, 'embeddings', 'expected embeddings queue SLO policy to retain class-specific defaults'); + +const zeroConcurrencyPolicy = resolveQueueAdmissionPolicy({ + queueName: 'index-stage2', + queueConfig: {}, + workerConfig: { + concurrency: 0 + } +}); +assert.equal(zeroConcurrencyPolicy.maxRunning, 0, 'expected explicit zero worker concurrency to remain zero'); +assert.equal(zeroConcurrencyPolicy.maxTotal, 20, 'expected zero worker concurrency to preserve derived queue totals'); + +console.log('service queue admission policy test passed'); diff --git a/tests/services/queue/compaction.test.js b/tests/services/queue/compaction.test.js new file mode 100644 index 000000000..4acf71ffa --- /dev/null +++ b/tests/services/queue/compaction.test.js @@ -0,0 +1,269 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; + +import { + compactQueueState, + ensureQueueDir, + getQueuePaths, + getQuarantinePaths, + loadQuarantine, + loadQueue, + readQueueJournal, + replayQueueStateFromJournal, + saveQuarantine, + saveQueue +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-compaction'); +const queueDir = path.join(tempRoot, 'queue'); +const logsDir = path.join(queueDir, 'logs'); +const reportsDir = path.join(queueDir, 'reports'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); +await fsPromises.mkdir(logsDir, { recursive: true }); +await fsPromises.mkdir(reportsDir, { recursive: true }); + +const makeJob = (id, status, createdAt, extra = {}) => ({ + id, + status, + queueName: 'index', + repo: `/tmp/${id}`, + repoRoot: `/tmp/${id}`, + mode: 'code', + reason: 'test', + stage: 'stage1', + createdAt, + startedAt: extra.startedAt || null, + finishedAt: extra.finishedAt || null, + lastHeartbeatAt: extra.lastHeartbeatAt || null, + attempts: extra.attempts ?? 0, + maxRetries: extra.maxRetries ?? 2, + progress: { + sequence: 0, + updatedAt: extra.finishedAt || extra.startedAt || createdAt, + kind: null, + note: null + }, + lease: extra.lease || { + owner: status === 'running' ? 'worker-compaction' : null, + version: status === 'running' ? 1 : 0, + expiresAt: status === 'running' ? new Date(Date.parse(createdAt) + 60000).toISOString() : null, + acquiredAt: status === 'running' ? createdAt : null, + renewedAt: status === 'running' ? createdAt : null, + releasedAt: null, + releasedReason: null, + lastOwner: null + }, + transition: { + sequence: 1, + from: 'queued', + to: status, + at: extra.finishedAt || extra.startedAt || createdAt, + reason: status + }, + logPath: path.join(logsDir, `${id}.log`), + reportPath: path.join(reportsDir, `${id}.json`), + ...(extra.quarantine ? { quarantine: extra.quarantine } : {}) +}); + +const writeArtifactsFor = async (jobs) => { + for (const job of jobs) { + await fsPromises.writeFile(job.logPath, `log:${job.id}\n`, 'utf8'); + await fsPromises.writeFile(job.reportPath, JSON.stringify({ id: job.id }), 'utf8'); + } +}; + +const queueJobs = [ + makeJob('job-queued', 'queued', '2026-03-18T10:00:00.000Z'), + makeJob('job-running', 'running', '2026-03-18T10:01:00.000Z', { + startedAt: '2026-03-18T10:01:30.000Z', + lastHeartbeatAt: '2026-03-18T10:01:45.000Z' + }), + makeJob('job-done-old', 'done', '2026-03-18T09:00:00.000Z', { + finishedAt: '2026-03-18T09:05:00.000Z' + }), + makeJob('job-done-new', 'done', '2026-03-18T09:30:00.000Z', { + finishedAt: '2026-03-18T09:45:00.000Z' + }), + makeJob('job-failed-old', 'failed', '2026-03-18T08:00:00.000Z', { + finishedAt: '2026-03-18T08:05:00.000Z', + lastError: 'old failure' + }), + makeJob('job-failed-new', 'failed', '2026-03-18T08:30:00.000Z', { + finishedAt: '2026-03-18T08:45:00.000Z', + lastError: 'new failure' + }) +]; + +const quarantineJobs = [ + makeJob('job-quarantine-old', 'failed', '2026-03-18T07:00:00.000Z', { + finishedAt: '2026-03-18T07:05:00.000Z', + quarantine: { + state: 'quarantined', + quarantinedAt: '2026-03-18T07:06:00.000Z', + reason: 'old quarantine', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }), + makeJob('job-quarantine-new', 'failed', '2026-03-18T07:30:00.000Z', { + finishedAt: '2026-03-18T07:35:00.000Z', + quarantine: { + state: 'quarantined', + quarantinedAt: '2026-03-18T07:36:00.000Z', + reason: 'new quarantine', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }), + makeJob('job-retried-old', 'failed', '2026-03-18T06:00:00.000Z', { + finishedAt: '2026-03-18T06:05:00.000Z', + quarantine: { + state: 'retried', + quarantinedAt: '2026-03-18T06:06:00.000Z', + releasedAt: '2026-03-18T06:10:00.000Z', + releaseReason: 'manual-retry', + retryJobId: 'retry-old', + reason: 'old retried', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }), + makeJob('job-retried-new', 'failed', '2026-03-18T06:30:00.000Z', { + finishedAt: '2026-03-18T06:35:00.000Z', + quarantine: { + state: 'retried', + quarantinedAt: '2026-03-18T06:36:00.000Z', + releasedAt: '2026-03-18T06:40:00.000Z', + releaseReason: 'manual-retry', + retryJobId: 'retry-new', + reason: 'new retried', + sourceStatus: 'failed', + sourceQueueName: 'index' + } + }) +]; + +await writeArtifactsFor([...queueJobs, ...quarantineJobs]); +await fsPromises.writeFile(path.join(logsDir, 'orphan.log'), 'orphan\n', 'utf8'); +await fsPromises.writeFile(path.join(reportsDir, 'orphan.json'), '{"orphan":true}', 'utf8'); + +await saveQueue(queueDir, { jobs: queueJobs }, 'index'); +await saveQuarantine(queueDir, { jobs: quarantineJobs }, 'index'); +await saveQuarantine(queueDir, { jobs: [] }, 'embeddings-stage3'); + +const otherQueueJob = makeJob('job-other-queue', 'done', '2026-03-18T11:00:00.000Z', { + finishedAt: '2026-03-18T11:05:00.000Z' +}); +delete otherQueueJob.logPath; +delete otherQueueJob.reportPath; +otherQueueJob.queueName = 'embeddings-stage3'; +await fsPromises.writeFile(path.join(logsDir, 'job-other-queue.log'), 'log:job-other-queue\n', 'utf8'); +await fsPromises.writeFile(path.join(reportsDir, 'job-other-queue.json'), JSON.stringify({ id: 'job-other-queue' }), 'utf8'); +await saveQueue(queueDir, { jobs: [otherQueueJob] }, 'embeddings-stage3'); + +const compacted = await compactQueueState(queueDir, 'index', { + retentionPolicy: { + doneJobs: 1, + failedJobs: 1, + quarantinedJobs: 1, + retriedQuarantinedJobs: 1, + cleanupLogs: true, + cleanupReports: true, + rewriteJournal: true + } +}); + +assert.equal(compacted.ok, true, 'expected compaction to succeed'); +assert.deepEqual(compacted.removedJobIds.queue.sort(), ['job-done-old', 'job-failed-old']); +assert.deepEqual(compacted.removedJobIds.quarantine.sort(), ['job-quarantine-old', 'job-retried-old']); +assert.equal(compacted.removed.logs, 5, 'expected removed job logs plus one orphan log'); +assert.equal(compacted.removed.reports, 5, 'expected removed job reports plus one orphan report'); + +const retainedQueue = await loadQueue(queueDir, 'index'); +assert.deepEqual( + retainedQueue.jobs.map((job) => job.id).sort(), + ['job-done-new', 'job-failed-new', 'job-queued', 'job-running'] +); + +const retainedQuarantine = await loadQuarantine(queueDir, 'index'); +assert.deepEqual( + retainedQuarantine.jobs.map((job) => job.id).sort(), + ['job-quarantine-new', 'job-retried-new'] +); + +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-done-old.log')), false); +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-failed-old.log')), false); +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-quarantine-old.log')), false); +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-retried-old.log')), false); +assert.equal(fsSync.existsSync(path.join(logsDir, 'orphan.log')), false); +assert.equal(fsSync.existsSync(path.join(reportsDir, 'orphan.json')), false); +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-running.log')), true); +assert.equal(fsSync.existsSync(path.join(reportsDir, 'job-retried-new.json')), true); +assert.equal(fsSync.existsSync(path.join(logsDir, 'job-other-queue.log')), true, 'expected compaction to preserve other queue log artifacts'); +assert.equal(fsSync.existsSync(path.join(reportsDir, 'job-other-queue.json')), true, 'expected compaction to preserve other queue report artifacts'); + +const journalEntries = await readQueueJournal(queueDir, 'index'); +assert.equal(journalEntries.length, 7, 'expected one compaction event plus retained snapshots'); +assert.equal(journalEntries[0]?.eventType, 'compaction'); + +const replayed = await replayQueueStateFromJournal(queueDir, 'index'); +assert.deepEqual( + replayed.queue.jobs.map((job) => job.id).sort(), + ['job-done-new', 'job-failed-new', 'job-queued', 'job-running'] +); +assert.deepEqual( + replayed.quarantine.jobs.map((job) => job.id).sort(), + ['job-quarantine-new', 'job-retried-new'] +); + +const { queuePath } = getQueuePaths(queueDir, 'index'); +const { quarantinePath } = getQuarantinePaths(queueDir, 'index'); +await fsPromises.rm(queuePath, { force: true }); +await fsPromises.rm(quarantinePath, { force: true }); +const replayedWithoutPrimaryFiles = await replayQueueStateFromJournal(queueDir, 'index'); +assert.equal(replayedWithoutPrimaryFiles.queue.jobs.length, 4); +assert.equal(replayedWithoutPrimaryFiles.quarantine.jobs.length, 2); + +const partialRoot = resolveTestCachePath(root, 'service-queue-compaction-partial-policy'); +const partialQueueDir = path.join(partialRoot, 'queue'); +const partialLogsDir = path.join(partialQueueDir, 'logs'); +const partialReportsDir = path.join(partialQueueDir, 'reports'); +await fsPromises.rm(partialRoot, { recursive: true, force: true }); +await ensureQueueDir(partialQueueDir); +await fsPromises.mkdir(partialLogsDir, { recursive: true }); +await fsPromises.mkdir(partialReportsDir, { recursive: true }); +const partialDoneJobs = [ + makeJob('job-partial-done-old', 'done', '2026-03-18T05:00:00.000Z', { + finishedAt: '2026-03-18T05:05:00.000Z' + }), + makeJob('job-partial-done-new', 'done', '2026-03-18T05:30:00.000Z', { + finishedAt: '2026-03-18T05:35:00.000Z' + }) +]; +for (const job of partialDoneJobs) { + job.logPath = path.join(partialLogsDir, `${job.id}.log`); + job.reportPath = path.join(partialReportsDir, `${job.id}.json`); +} +await writeArtifactsFor(partialDoneJobs); +await saveQueue(partialQueueDir, { jobs: partialDoneJobs }, 'index'); +await saveQuarantine(partialQueueDir, { jobs: [] }, 'index'); +const partialCompaction = await compactQueueState(partialQueueDir, 'index', { + retentionPolicy: { + doneJobs: 1, + cleanupLogs: true, + cleanupReports: true, + rewriteJournal: true + } +}); +assert.equal(partialCompaction.retentionPolicy.doneJobs, 1, 'expected explicit partial doneJobs override'); +assert.equal(partialCompaction.retentionPolicy.failedJobs, 50, 'expected missing retention counts to normalize to defaults'); +assert.equal(partialCompaction.removed.queue, 1, 'expected partial retention policy to still trim old done jobs'); + +console.log('service queue compaction test passed'); diff --git a/tests/services/queue/delivery-contract-report.test.js b/tests/services/queue/delivery-contract-report.test.js new file mode 100644 index 000000000..f247aa518 --- /dev/null +++ b/tests/services/queue/delivery-contract-report.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + completeJob, + ensureQueueDir, + enqueueJob, + loadQueue, + quarantineJob +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-delivery-contract-report'); +const queueDir = path.join(tempRoot, 'queue'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +await enqueueJob(queueDir, { + id: 'job-report-complete', + createdAt: new Date().toISOString(), + repo: '/tmp/repo-report-complete', + repoRoot: '/tmp/repo-report-complete', + mode: 'code', + stage: 'stage1', + reason: 'report-contract' +}, null, 'index'); + +const completed = await claimNextJob(queueDir, 'index', { + ownerId: 'worker-report', + leaseMs: 5000 +}); +await completeJob(queueDir, completed.id, 'done', { + exitCode: 0, + signal: null, + executionMode: 'subprocess', + executionClass: 'subprocess-isolated' +}, 'index', { + ownerId: 'worker-report', + expectedLeaseVersion: completed.lease?.version ?? null +}); + +const completedQueue = await loadQueue(queueDir, 'index'); +const completedJob = completedQueue.jobs.find((entry) => entry.id === 'job-report-complete'); +const completedReport = JSON.parse(await fs.readFile(completedJob.reportPath, 'utf8')); +assert.equal(completedReport.deliveryContract?.semantics, 'at-least-once'); +assert.equal(completedReport.deliveryContract?.idempotencyKey, completedJob.idempotencyKey); +assert.equal(completedReport.deliveryContract?.sideEffectFences?.reportWrite, 'atomic-report-path'); + +await enqueueJob(queueDir, { + id: 'job-report-quarantine', + createdAt: new Date().toISOString(), + repo: '/tmp/repo-report-quarantine', + repoRoot: '/tmp/repo-report-quarantine', + mode: 'code', + stage: 'stage2', + reason: 'report-contract' +}, null, 'index'); + +const quarantined = await claimNextJob(queueDir, 'index', { + ownerId: 'worker-report', + leaseMs: 5000 +}); +await quarantineJob(queueDir, quarantined.id, 'report-contract-quarantine', 'index', { + ownerId: 'worker-report', + expectedLeaseVersion: quarantined.lease?.version ?? null, + result: { error: 'report-contract-quarantine' } +}); + +const quarantinedReportPath = path.join(queueDir, 'reports', `${quarantined.id}.json`); +const quarantinedReport = JSON.parse(await fs.readFile(quarantinedReportPath, 'utf8')); +assert.equal(quarantinedReport.deliveryContract?.semantics, 'at-least-once'); +assert.equal(quarantinedReport.deliveryContract?.sideEffectFences?.quarantineStore, 'replace-by-job-id'); + +console.log('service queue delivery contract report test passed'); diff --git a/tests/services/queue/failure-injection.test.js b/tests/services/queue/failure-injection.test.js new file mode 100644 index 000000000..666e025da --- /dev/null +++ b/tests/services/queue/failure-injection.test.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { + claimNextJob, + completeJob, + enqueueJob, + ensureQueueDir, + getQueuePaths, + loadQueue, + quarantineSummary, + requeueStaleJobs +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-failure-injection'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo-failure-injection', + repoRoot: '/tmp/repo-failure-injection', + mode: 'code', + reason: 'test', + stage: 'stage1' +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-duplicate-a', buildId: 'dup-build' }, null, 'index'); +await enqueueJob(queueDir, { ...baseJob, id: 'job-duplicate-b', buildId: 'dup-build' }, null, 'index', { + forceDuplicate: true +}); +const duplicateClaim = await claimNextJob(queueDir, 'index', { ownerId: 'worker-duplicate' }); +assert.equal(duplicateClaim?.id, 'job-duplicate-a', 'expected original logical work to claim first'); +const duplicateQueue = await loadQueue(queueDir, 'index'); +const duplicateSuppressed = duplicateQueue.jobs.find((job) => job.id === 'job-duplicate-b'); +assert.equal(duplicateSuppressed?.status, 'failed', 'expected duplicate claim injection to suppress stale queued duplicate'); +assert.equal(duplicateSuppressed?.result?.reason, 'duplicate-claim-suppressed', 'expected operator-visible duplicate suppression reason'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-crash-before-complete', maxRetries: 1 }, null, 'index-crash'); +const crashed = await claimNextJob(queueDir, 'index-crash', { ownerId: 'worker-crash', leaseMs: 5 }); +const crashQueue = await loadQueue(queueDir, 'index-crash'); +const crashRunning = crashQueue.jobs.find((job) => job.id === crashed.id); +const expiredAt = new Date(Date.now() - 1000).toISOString(); +crashRunning.lease.expiresAt = expiredAt; +crashRunning.lastHeartbeatAt = expiredAt; +await fsPromises.writeFile( + getQueuePaths(queueDir, 'index-crash').queuePath, + JSON.stringify({ jobs: crashQueue.jobs }, null, 2) +); +const crashRecovery = await requeueStaleJobs(queueDir, 'index-crash', { maxRetries: 1 }); +assert.equal(crashRecovery.retried, 1, 'expected crash-before-complete injection to requeue deterministically'); +const recoveredCrash = (await loadQueue(queueDir, 'index-crash')).jobs.find((job) => job.id === crashed.id); +assert.equal(recoveredCrash?.status, 'queued', 'expected crash-before-complete recovery to return the job to queued'); +assert.match(String(recoveredCrash?.lastError || ''), /lease expired before completion/i, 'expected operator-visible crash recovery reason'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-heartbeat-loss', maxRetries: 0 }, null, 'index-heartbeat'); +const heartbeatLoss = await claimNextJob(queueDir, 'index-heartbeat', { ownerId: 'worker-heartbeat', leaseMs: 5 }); +const heartbeatQueue = await loadQueue(queueDir, 'index-heartbeat'); +const heartbeatRunning = heartbeatQueue.jobs.find((job) => job.id === heartbeatLoss.id); +heartbeatRunning.lease.expiresAt = expiredAt; +heartbeatRunning.lastHeartbeatAt = expiredAt; +await fsPromises.writeFile( + getQueuePaths(queueDir, 'index-heartbeat').queuePath, + JSON.stringify({ jobs: heartbeatQueue.jobs }, null, 2) +); +const heartbeatRecovery = await requeueStaleJobs(queueDir, 'index-heartbeat', { maxRetries: 0 }); +assert.equal(heartbeatRecovery.quarantined, 1, 'expected heartbeat-loss injection to quarantine exhausted work'); +const heartbeatQuarantine = await quarantineSummary(queueDir, 'index-heartbeat'); +assert.equal(heartbeatQuarantine.quarantined, 1, 'expected operator-visible quarantine summary after heartbeat loss'); +const heartbeatReport = JSON.parse(await fsPromises.readFile(heartbeatLoss.reportPath, 'utf8')); +assert.equal(heartbeatReport.quarantined, true, 'expected stale heartbeat recovery to rewrite the operator report as quarantined'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-crash-after-report', maxRetries: 0 }, null, 'index-after-report'); +const crashAfterReport = await claimNextJob(queueDir, 'index-after-report', { ownerId: 'worker-after-report', leaseMs: 5 }); +await fsPromises.mkdir(path.dirname(crashAfterReport.reportPath), { recursive: true }); +await fsPromises.writeFile(crashAfterReport.reportPath, JSON.stringify({ + updatedAt: new Date().toISOString(), + status: 'running', + job: { id: crashAfterReport.id } +}, null, 2)); +const afterReportQueue = await loadQueue(queueDir, 'index-after-report'); +const afterReportRunning = afterReportQueue.jobs.find((job) => job.id === crashAfterReport.id); +afterReportRunning.lease.expiresAt = expiredAt; +afterReportRunning.lastHeartbeatAt = expiredAt; +await fsPromises.writeFile( + getQueuePaths(queueDir, 'index-after-report').queuePath, + JSON.stringify({ jobs: afterReportQueue.jobs }, null, 2) +); +const afterReportRecovery = await requeueStaleJobs(queueDir, 'index-after-report', { maxRetries: 0 }); +assert.equal(afterReportRecovery.quarantined, 1, 'expected crash-after-report injection to quarantine deterministically'); +const repairedAfterReport = JSON.parse(await fsPromises.readFile(crashAfterReport.reportPath, 'utf8')); +assert.equal(repairedAfterReport.quarantined, true, 'expected stale sweep to replace the stale pre-crash report with quarantined state'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-partial-report' }, null, 'index-report'); +const partialReportJob = await claimNextJob(queueDir, 'index-report', { ownerId: 'worker-report' }); +await fsPromises.mkdir(path.dirname(partialReportJob.reportPath), { recursive: true }); +await fsPromises.writeFile(partialReportJob.reportPath, '{"updatedAt":"partial"', 'utf8'); +await completeJob(queueDir, partialReportJob.id, 'done', { exitCode: 0 }, 'index-report', { + ownerId: 'worker-report', + expectedLeaseVersion: partialReportJob.lease?.version ?? null +}); +const repairedReport = JSON.parse(await fsPromises.readFile(partialReportJob.reportPath, 'utf8')); +assert.equal(repairedReport.status, 'done', 'expected partial report injection to be overwritten by final completion report'); +assert.equal(repairedReport.job?.id, partialReportJob.id, 'expected repaired report to retain the job identity'); + +const lockPath = getQueuePaths(queueDir, 'index-lock').lockPath; +await ensureQueueDir(queueDir); +const lock = await acquireFileLock({ + lockPath, + waitMs: 100, + pollMs: 25, + staleMs: 30 * 60 * 1000, + metadata: { scope: 'failure-injection-test' }, + timeoutBehavior: 'throw', + timeoutMessage: 'failure-injection-holder-timeout' +}); +assert.ok(lock, 'expected to acquire the synthetic lock holder'); +let lockError = null; +try { + await enqueueJob(queueDir, { ...baseJob, id: 'job-lock-timeout' }, null, 'index-lock'); +} catch (error) { + lockError = error; +} +await lock.release(); +assert.ok(lockError, 'expected locked queue injection to raise an error'); +assert.equal(lockError?.code, 'QUEUE_LOCK_TIMEOUT', 'expected stable operator-visible lock timeout code'); +assert.match(String(lockError?.message || ''), /Queue lock timeout\./, 'expected stable operator-visible lock timeout message'); + +console.log('service queue failure injection test passed'); diff --git a/tests/services/queue/heartbeat-replay-state.test.js b/tests/services/queue/heartbeat-replay-state.test.js new file mode 100644 index 000000000..256f28c07 --- /dev/null +++ b/tests/services/queue/heartbeat-replay-state.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + enqueueJob, + ensureQueueDir, + loadQueue, + touchJobHeartbeat +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-heartbeat-replay-state'); +const queueDir = path.join(tempRoot, 'queue'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +await enqueueJob(queueDir, { + id: 'embed-job-1', + createdAt: new Date().toISOString(), + repo: '/tmp/embed-repo', + repoRoot: '/tmp/embed-repo', + buildRoot: '/tmp/embed-repo/builds/build-1', + indexDir: '/tmp/embed-repo/builds/build-1/index-code', + mode: 'code', + reason: 'test', + stage: 'stage3' +}, null, 'embeddings'); + +const claimed = await claimNextJob(queueDir, 'embeddings', { + ownerId: 'worker-replay', + leaseMs: 5000 +}); + +await touchJobHeartbeat(queueDir, claimed.id, 'embeddings', { + ownerId: 'worker-replay', + expectedLeaseVersion: claimed.lease?.version ?? null, + replayState: { + version: 1, + mode: 'code', + partialDurableState: true, + backendStage: { + path: '/tmp/embed-repo/builds/build-1/.embeddings-backend-staging/index-code', + exists: true + } + }, + progress: { + kind: 'renewal', + note: 'replay-snapshot' + } +}); + +const queue = await loadQueue(queueDir, 'embeddings'); +const runningJob = queue.jobs.find((job) => job.id === claimed.id); +assert.equal(runningJob?.replayState?.version, 1); +assert.equal(runningJob?.replayState?.partialDurableState, true); +assert.equal(runningJob?.replayState?.backendStage?.exists, true); +assert.ok(runningJob?.replayState?.updatedAt, 'expected heartbeat persistence to timestamp replay state'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('service queue heartbeat replay-state test passed'); diff --git a/tests/services/queue/idempotency.test.js b/tests/services/queue/idempotency.test.js new file mode 100644 index 000000000..8b7d1d265 --- /dev/null +++ b/tests/services/queue/idempotency.test.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + enqueueJob, + ensureQueueDir, + inspectJobReplayState, + listDuplicateJobGroups, + loadQueue, + queueSummary, + requeueStaleJobs, + saveQueue +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-idempotency'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo-idempotency', + repoRoot: '/tmp/repo-idempotency', + mode: 'code', + reason: 'test', + stage: 'stage1', + buildId: 'build-a' +}; + +const first = await enqueueJob(queueDir, { ...baseJob, id: 'job-a' }, null, 'index'); +assert.equal(first.ok, true, 'expected first enqueue to succeed'); +assert.equal(first.duplicate, undefined, 'expected first enqueue to be a real insert'); + +const duplicate = await enqueueJob(queueDir, { ...baseJob, id: 'job-b' }, null, 'index'); +assert.equal(duplicate.ok, true, 'expected duplicate enqueue to resolve cleanly'); +assert.equal(duplicate.duplicate, true, 'expected duplicate enqueue to be suppressed'); +assert.equal(duplicate.replaySuppressed, true, 'expected duplicate enqueue to report replay suppression'); +assert.equal(duplicate.job?.id, 'job-a', 'expected duplicate enqueue to point at the original job'); + +const afterDuplicateSummary = await queueSummary(queueDir, 'index'); +assert.equal(afterDuplicateSummary.total, 1, 'expected duplicate enqueue to avoid adding a second active job'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-c' }, null, 'index', { forceDuplicate: true }); +await enqueueJob(queueDir, { + ...baseJob, + id: 'job-unique', + buildId: 'build-b', + indexDir: '/tmp/repo-idempotency/index-b' +}, null, 'index'); + +const claimed = await claimNextJob(queueDir, 'index', { ownerId: 'worker-idempotent' }); +assert.equal(claimed?.id, 'job-a', 'expected first logical job to claim first'); +assert.equal(claimed?.delivery?.semantics, 'at-least-once'); +assert.equal(claimed?.attemptHistory?.length, 1, 'expected claim to create first attempt record'); +assert.equal(claimed?.attemptHistory?.[0]?.outcome, 'claimed'); + +const claimedQueue = await loadQueue(queueDir, 'index'); +const suppressed = claimedQueue.jobs.find((job) => job.id === 'job-c'); +assert.equal(suppressed?.status, 'failed', 'expected queued duplicate to be suppressed during claim'); +assert.match(String(suppressed?.lastError || ''), /duplicate logical job suppressed/i, 'expected duplicate suppression error'); +assert.equal(suppressed?.result?.duplicateOfId, 'job-a', 'expected duplicate metadata to reference claimed job'); + +const uniqueClaim = await claimNextJob(queueDir, 'index', { ownerId: 'worker-unique' }); +assert.equal(uniqueClaim?.id, 'job-unique', 'expected unique job to remain claimable after duplicate suppression'); + +const replayRoot = resolveTestCachePath(root, 'service-queue-idempotency-replay'); +const replayQueueDir = path.join(replayRoot, 'queue'); +await fsPromises.rm(replayRoot, { recursive: true, force: true }); +await ensureQueueDir(replayQueueDir); + +const replayBaseJob = { + ...baseJob, + repo: '/tmp/repo-replay', + repoRoot: '/tmp/repo-replay', + buildId: 'build-replay' +}; + +await enqueueJob(replayQueueDir, { ...replayBaseJob, id: 'job-replay' }, null, 'index'); +const replayClaim = await claimNextJob(replayQueueDir, 'index', { + ownerId: 'worker-replay', + leaseMs: 5 +}); +assert.equal(replayClaim?.status, 'running', 'expected replay job to start running'); + +const replayExpiredQueue = await loadQueue(replayQueueDir, 'index'); +const replayRunning = replayExpiredQueue.jobs.find((job) => job.id === 'job-replay'); +const expiredAt = new Date(Date.now() - 1000).toISOString(); +replayRunning.lease.expiresAt = expiredAt; +replayRunning.lastHeartbeatAt = expiredAt; +await saveQueue(replayQueueDir, replayExpiredQueue, 'index'); + +const staleResult = await requeueStaleJobs(replayQueueDir, 'index', { maxRetries: 2 }); +assert.equal(staleResult.retried, 1, 'expected expired replay lease to requeue'); + +const replayQueue = await loadQueue(replayQueueDir, 'index'); +const replayQueued = replayQueue.jobs.find((job) => job.id === 'job-replay'); +replayQueued.nextEligibleAt = new Date(Date.now() - 1000).toISOString(); +await saveQueue(replayQueueDir, replayQueue, 'index'); + +const replayDuplicate = await enqueueJob(replayQueueDir, { ...replayBaseJob, id: 'job-replay-2' }, null, 'index'); +assert.equal(replayDuplicate.duplicate, true, 'expected replay enqueue to suppress duplicate logical work'); +assert.equal(replayDuplicate.job?.id, 'job-replay', 'expected replay suppression to point at the requeued job'); +const replayInspection = await inspectJobReplayState(replayQueueDir, 'job-replay', 'index'); +assert.equal(replayInspection?.deliverySemantics, 'at-least-once'); +assert.equal(replayInspection?.job?.attempts?.length, 1, 'expected replay inspection to retain the claimed attempt'); +assert.equal(replayInspection?.job?.replayHistory?.[0]?.action, 'stale-requeue'); +const duplicateGroups = await listDuplicateJobGroups(queueDir, 'index'); +assert.equal(duplicateGroups.length, 1, 'expected duplicate inspection to group logical duplicates'); +assert.equal(duplicateGroups[0]?.jobs?.length, 2, 'expected duplicate group to include suppressed duplicate'); + +const runningDupRoot = resolveTestCachePath(root, 'service-queue-idempotency-running-duplicate'); +const runningDupQueueDir = path.join(runningDupRoot, 'queue'); +await fsPromises.rm(runningDupRoot, { recursive: true, force: true }); +await ensureQueueDir(runningDupQueueDir); +await enqueueJob(runningDupQueueDir, { + ...baseJob, + id: 'job-running-original', + buildId: 'build-running-dup' +}, null, 'index'); +const runningOriginal = await claimNextJob(runningDupQueueDir, 'index', { ownerId: 'worker-running-dup' }); +await enqueueJob(runningDupQueueDir, { + ...baseJob, + id: 'job-running-duplicate', + buildId: 'build-running-dup' +}, null, 'index', { forceDuplicate: true }); +const runningDuplicateQueue = await loadQueue(runningDupQueueDir, 'index'); +runningDuplicateQueue.jobs.sort((left, right) => String(left.id || '').localeCompare(String(right.id || ''))); +await saveQueue(runningDupQueueDir, runningDuplicateQueue, 'index'); +const suppressedOnlyClaim = await claimNextJob(runningDupQueueDir, 'index', { ownerId: 'worker-running-dup-2' }); +assert.equal(suppressedOnlyClaim, null, 'expected duplicate-only claim pass to suppress and return no work'); +const runningDuplicateAfterClaim = await loadQueue(runningDupQueueDir, 'index'); +const persistedSuppressed = runningDuplicateAfterClaim.jobs.find((job) => job.id === 'job-running-duplicate'); +assert.equal(persistedSuppressed?.status, 'failed', 'expected queued duplicate behind running work to persist as failed'); +assert.equal( + persistedSuppressed?.result?.reason, + 'duplicate-running-suppressed', + 'expected persisted duplicate suppression reason when no job is claimed' +); + +console.log('service queue idempotency test passed'); diff --git a/tests/services/queue/journal.test.js b/tests/services/queue/journal.test.js new file mode 100644 index 000000000..e491d8c1d --- /dev/null +++ b/tests/services/queue/journal.test.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + completeJob, + enqueueJob, + ensureQueueDir, + getQuarantinePaths, + getQueuePaths, + loadQuarantine, + loadQueue, + quarantineJob, + readQueueJournal, + replayQueueStateFromJournal, + touchJobHeartbeat +} from '../../../tools/service/queue.js'; +import { getQueueJournalPath } from '../../../tools/service/queue-journal.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-journal'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo-journal', + repoRoot: '/tmp/repo-journal', + mode: 'code', + reason: 'test', + stage: 'stage1', + observability: { + surface: 'service', + operation: 'queue_enqueue', + correlation: { + correlationId: 'queue-correlation-test', + requestId: 'queue-request-test' + }, + context: { + repoRoot: '/tmp/repo-journal' + } + } +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-retry' }, null, 'index'); +const claimedRetry = await claimNextJob(queueDir, 'index', { ownerId: 'worker-journal' }); +await touchJobHeartbeat(queueDir, claimedRetry.id, 'index', { + ownerId: 'worker-journal', + expectedLeaseVersion: claimedRetry.lease?.version ?? null +}); +await completeJob(queueDir, claimedRetry.id, 'queued', { + exitCode: 1, + retry: true, + attempts: 1, + error: 'retry requested' +}, 'index', { + ownerId: 'worker-journal', + expectedLeaseVersion: claimedRetry.lease?.version ?? null +}); +const retryQueue = await loadQueue(queueDir, 'index'); +const retriable = retryQueue.jobs.find((job) => job.id === 'job-retry'); +retriable.nextEligibleAt = new Date(Date.now() - 1000).toISOString(); +await fsPromises.writeFile( + getQueuePaths(queueDir, 'index').queuePath, + JSON.stringify({ jobs: retryQueue.jobs }, null, 2) +); +const reclaimedRetry = await claimNextJob(queueDir, 'index', { ownerId: 'worker-journal-2' }); +await completeJob(queueDir, reclaimedRetry.id, 'done', { exitCode: 0 }, 'index', { + ownerId: 'worker-journal-2', + expectedLeaseVersion: reclaimedRetry.lease?.version ?? null +}); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-poison', buildId: 'build-poison' }, null, 'index'); +const claimedPoison = await claimNextJob(queueDir, 'index', { ownerId: 'worker-poison' }); +await quarantineJob(queueDir, claimedPoison.id, 'poison-payload', 'index', { + ownerId: 'worker-poison', + expectedLeaseVersion: claimedPoison.lease?.version ?? null, + sourceStatus: 'running', + result: { + exitCode: 1, + error: 'poison payload detected' + } +}); + +const journal = await readQueueJournal(queueDir, 'index'); +assert.equal(journal.length >= 7, true, 'expected multiple journal entries to be recorded'); +assert.equal(journal.some((entry) => entry.eventType === 'enqueue'), true, 'expected enqueue events in the journal'); +assert.equal(journal.some((entry) => entry.eventType === 'claim'), true, 'expected claim events in the journal'); +assert.equal(journal.some((entry) => entry.eventType === 'heartbeat'), true, 'expected heartbeat events in the journal'); +assert.equal(journal.some((entry) => entry.eventType === 'retry-scheduled'), true, 'expected retry events in the journal'); +assert.equal(journal.some((entry) => entry.eventType === 'quarantine'), true, 'expected quarantine events in the journal'); +assert.equal( + journal.every((entry) => entry.observability?.correlation?.correlationId === 'queue-correlation-test'), + true, + 'expected queue journal entries to preserve observability correlation' +); + +await fsPromises.appendFile(getQueueJournalPath(queueDir, 'index'), '{not-json}\n', 'utf8'); +const journalWithMalformedLine = await readQueueJournal(queueDir, 'index'); +assert.equal( + journalWithMalformedLine.length, + journal.length, + 'expected malformed journal line to be ignored instead of discarding the full journal' +); + +const liveQueue = await loadQueue(queueDir, 'index'); +const liveQuarantine = await loadQuarantine(queueDir, 'index'); +const replayed = await replayQueueStateFromJournal(queueDir, 'index'); + +const replayRetry = replayed.queue.jobs.find((job) => job.id === 'job-retry'); +assert.equal(replayRetry?.status, 'done', 'expected replayed queue state to reconstruct completed jobs'); +const liveRetry = liveQueue.jobs.find((job) => job.id === 'job-retry'); +assert.equal(replayRetry?.transition?.to, liveRetry?.transition?.to, 'expected replayed completion state to match live queue state'); + +const replayPoison = replayed.quarantine.jobs.find((job) => job.id === 'job-poison'); +const livePoison = liveQuarantine.jobs.find((job) => job.id === 'job-poison'); +assert.equal(replayPoison?.quarantine?.reason, livePoison?.quarantine?.reason, 'expected replayed quarantine reason to match live quarantine state'); + +await fsPromises.rm(getQueuePaths(queueDir, 'index').queuePath, { force: true }); +await fsPromises.rm(getQuarantinePaths(queueDir, 'index').quarantinePath, { force: true }); +const recovered = await replayQueueStateFromJournal(queueDir, 'index'); +assert.equal(recovered.queue.jobs.find((job) => job.id === 'job-retry')?.status, 'done', 'expected journal replay to recover queue state without queue.json'); +assert.equal(recovered.quarantine.jobs.find((job) => job.id === 'job-poison')?.quarantine?.reason, 'poison-payload', 'expected journal replay to recover quarantine state without quarantine.json'); + +console.log('service queue journal test passed'); diff --git a/tests/services/queue/lease-policy.test.js b/tests/services/queue/lease-policy.test.js new file mode 100644 index 000000000..72cfc8edd --- /dev/null +++ b/tests/services/queue/lease-policy.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveLeaseWorkloadClass, resolveQueueLeasePolicy } from '../../../tools/service/lease-policy.js'; + +const balanced = resolveQueueLeasePolicy({ + job: { stage: 'stage1', mode: 'code' }, + queueName: 'index' +}); +assert.equal(resolveLeaseWorkloadClass({ job: { stage: 'stage1' }, queueName: 'index' }), 'balanced'); +assert.equal(balanced.workloadClass, 'balanced'); +assert.equal(balanced.leaseMs, 5 * 60 * 1000); +assert.equal(balanced.renewIntervalMs, 30 * 1000); + +const bursty = resolveQueueLeasePolicy({ + job: { stage: 'stage2', mode: 'both' }, + queueName: 'index-stage2' +}); +assert.equal(resolveLeaseWorkloadClass({ job: { stage: 'stage2', mode: 'both' }, queueName: 'index-stage2' }), 'bursty'); +assert.equal(bursty.workloadClass, 'bursty'); +assert.equal(bursty.leaseMs, 10 * 60 * 1000); +assert.equal(bursty.renewIntervalMs, 20 * 1000); + +const slow = resolveQueueLeasePolicy({ + job: { stage: 'stage3', reason: 'embeddings' }, + queueName: 'embeddings' +}); +assert.equal(resolveLeaseWorkloadClass({ job: { stage: 'stage3', reason: 'embeddings' }, queueName: 'embeddings' }), 'slow'); +assert.equal(slow.workloadClass, 'slow'); +assert.equal(slow.leaseMs, 15 * 60 * 1000); +assert.equal(slow.renewIntervalMs, 60 * 1000); +assert.equal(slow.renewIntervalMs < slow.leaseMs, true, 'expected renew interval to stay below lease'); +assert.equal(slow.progressIntervalMs <= slow.leaseMs, true, 'expected bounded progress interval'); + +console.log('service queue lease policy test passed'); diff --git a/tests/services/queue/lease-transitions.test.js b/tests/services/queue/lease-transitions.test.js new file mode 100644 index 000000000..2d12eb7bf --- /dev/null +++ b/tests/services/queue/lease-transitions.test.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + ensureQueueDir, + enqueueJob, + claimNextJob, + completeJob, + loadQuarantine, + loadQueue, + saveQueue, + queueSummary, + requeueStaleJobs, + touchJobHeartbeat +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-lease-transitions'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo', + repoRoot: '/tmp/repo', + mode: 'all', + reason: 'test' +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-complete' }, null, 'index'); +const claimed = await claimNextJob(queueDir, 'index', { + ownerId: 'worker-a', + leaseMs: 2000 +}); +assert.equal(claimed?.status, 'running', 'expected queued job to transition to running'); +assert.equal(claimed?.lease?.owner, 'worker-a', 'expected lease owner on claim'); +assert.equal(claimed?.lease?.version, 1, 'expected first claim to use lease version 1'); +assert.ok(claimed?.lease?.expiresAt, 'expected lease expiry metadata on claim'); + +await assert.rejects( + () => completeJob(queueDir, claimed.id, 'done', { exitCode: 0 }, 'index', { + ownerId: 'worker-b', + expectedLeaseVersion: claimed.lease?.version ?? null + }), + /lease owned/i, + 'expected mismatched owner to be rejected' +); + +await touchJobHeartbeat(queueDir, claimed.id, 'index', { + ownerId: 'worker-a', + expectedLeaseVersion: claimed.lease?.version ?? null, + leaseMs: 4000 +}); +const afterHeartbeat = (await loadQueue(queueDir, 'index')).jobs.find((job) => job.id === claimed.id); +assert.equal(afterHeartbeat?.lease?.owner, 'worker-a', 'expected heartbeat to preserve lease owner'); +assert.equal(afterHeartbeat?.lease?.version, 1, 'expected heartbeat to keep lease version stable'); +assert.ok(Date.parse(afterHeartbeat?.lease?.expiresAt || '') > Date.parse(claimed?.lease?.expiresAt || ''), 'expected heartbeat to extend lease expiry'); + +await completeJob(queueDir, claimed.id, 'done', { exitCode: 0 }, 'index', { + ownerId: 'worker-a', + expectedLeaseVersion: claimed.lease?.version ?? null +}); +const completed = (await loadQueue(queueDir, 'index')).jobs.find((job) => job.id === claimed.id); +assert.equal(completed?.status, 'done', 'expected running job to complete successfully'); +assert.equal(completed?.lease?.owner, null, 'expected completed job to release active lease'); +assert.equal(completed?.lease?.lastOwner, 'worker-a', 'expected completed job to record prior lease owner'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-retry' }, null, 'index'); +const retried = await claimNextJob(queueDir, 'index', { + ownerId: 'worker-retry', + leaseMs: 2000 +}); +await completeJob(queueDir, retried.id, 'queued', { exitCode: 1, retry: true, attempts: 1 }, 'index', { + ownerId: 'worker-retry', + expectedLeaseVersion: retried.lease?.version ?? null +}); +const queuedRetry = (await loadQueue(queueDir, 'index')).jobs.find((job) => job.id === retried.id); +assert.equal(queuedRetry?.status, 'queued', 'expected retry path to return job to queued'); +assert.equal(queuedRetry?.attempts, 1, 'expected retry path to increment attempts'); +assert.ok(queuedRetry?.nextEligibleAt, 'expected retry path to set next eligible timestamp'); +assert.equal(queuedRetry?.lease?.owner, null, 'expected retry path to clear active lease'); + +await enqueueJob(queueDir, { ...baseJob, id: 'job-stale', stage: 'stage2', maxRetries: 1 }, null, 'index-stale'); +const staleJob = await claimNextJob(queueDir, 'index-stale', { + ownerId: 'worker-stale', + leaseMs: 5 +}); +await new Promise((resolve) => setTimeout(resolve, 1200)); +const staleResult = await requeueStaleJobs(queueDir, 'index-stale', { maxRetries: 1 }); +assert.equal(staleResult.stale, 1, 'expected stale sweep to detect expired lease'); +assert.equal(staleResult.retried, 1, 'expected stale sweep to retry first expired lease'); +const staleQueued = (await loadQueue(queueDir, 'index-stale')).jobs.find((job) => job.id === staleJob.id); +assert.equal(staleQueued?.status, 'queued', 'expected expired lease to requeue job'); +assert.equal(staleQueued?.attempts, 1, 'expected stale retry to increment attempts'); +assert.equal(staleQueued?.lease?.owner, null, 'expected stale retry to clear lease owner'); +assert.ok(staleQueued?.nextEligibleAt, 'expected stale retry to back off before reclaim'); + +const staleQueuePayload = await loadQueue(queueDir, 'index-stale'); +const staleRetryJob = staleQueuePayload.jobs.find((job) => job.id === staleJob.id); +staleRetryJob.nextEligibleAt = new Date(Date.now() - 1000).toISOString(); +await saveQueue(queueDir, staleQueuePayload, 'index-stale'); + +const reclaimed = await claimNextJob(queueDir, 'index-stale', { + ownerId: 'worker-stale-2', + leaseMs: 5 +}); +assert.equal(reclaimed?.id, staleJob.id, 'expected reclaimed job to be the retried stale job'); +await new Promise((resolve) => setTimeout(resolve, 1200)); +const staleFailure = await requeueStaleJobs(queueDir, 'index-stale', { maxRetries: 1 }); +assert.equal(staleFailure.stale, 1, 'expected second stale sweep to detect expired lease again'); +assert.equal(staleFailure.failed, 1, 'expected second stale sweep to fail expired job after retries exhausted'); +assert.equal(staleFailure.quarantined, 1, 'expected exhausted stale job to move into quarantine'); +const failedStale = (await loadQuarantine(queueDir, 'index-stale')).jobs.find((job) => job.id === staleJob.id); +assert.equal(failedStale?.quarantine?.reason, 'lease-expired-fail', 'expected expired lease to quarantine after retry budget exhausted'); +assert.match(String(failedStale?.result?.error || ''), /lease expired/i, 'expected lease-expired failure reason'); + +const summaryIndex = await queueSummary(queueDir, 'index'); +const summaryStale = await queueSummary(queueDir, 'index-stale'); +assert.equal(summaryIndex.done, 1, 'expected one successful completion in the primary queue'); +assert.equal(summaryIndex.queued >= 1, true, 'expected retried queued job to remain queued in the primary queue'); +assert.equal(summaryStale.failed, 0, 'expected exhausted stale jobs to leave the hot queue'); + +console.log('service queue lease transitions test passed'); diff --git a/tests/services/queue/load-shedding.test.js b/tests/services/queue/load-shedding.test.js new file mode 100644 index 000000000..b7dc3a1e6 --- /dev/null +++ b/tests/services/queue/load-shedding.test.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + enqueueJob, + loadQueue, + saveQueue +} from '../../../tools/service/queue.js'; +import { + resolveQueueAdmissionPolicy, + resolveQueueSloPolicy +} from '../../../tools/service/admission-policy.js'; + +const root = process.cwd(); +const queueDir = resolveTestCachePath(root, 'service-queue-load-shedding'); +await fs.rm(queueDir, { recursive: true, force: true }); +await fs.mkdir(queueDir, { recursive: true }); + +const admissionPolicy = resolveQueueAdmissionPolicy({ + queueName: 'index', + queueConfig: { + maxQueued: 10, + maxRunning: 10, + maxTotal: 20, + resourceBudgetUnits: 40 + }, + workerConfig: { + concurrency: 2 + } +}); +const sloPolicy = resolveQueueSloPolicy({ + queueName: 'index', + queueConfig: { + slo: { + maxQueueAgeMs: { + degraded: 1000, + overloaded: 3000 + }, + maxRunLatencyMs: { + degraded: 1000, + overloaded: 3000 + }, + maxRetryRate: { + degraded: 0.25, + overloaded: 0.5 + }, + maxSaturationRatio: { + degraded: 0.5, + overloaded: 0.9 + }, + deferDelayMs: { + degraded: 2000, + overloaded: 5000 + } + } + } +}); + +await saveQueue(queueDir, { + jobs: [ + { + id: 'existing-aged', + status: 'queued', + queueName: 'index', + repo: '/tmp/existing', + mode: 'code', + stage: 'stage1', + createdAt: new Date(Date.now() - 2000).toISOString(), + attempts: 0 + } + ] +}, 'index'); + +const deferred = await enqueueJob(queueDir, { + id: 'job-heavy-deferred', + createdAt: new Date().toISOString(), + repo: '/tmp/heavy', + mode: 'both', + stage: 'stage3' +}, null, 'index', { + admissionPolicy, + sloPolicy +}); +assert.equal(deferred.ok, true, 'expected heavy enqueue during degraded mode to succeed'); +assert.equal(deferred.deferred, true, 'expected heavy enqueue to defer under degraded mode'); +assert.equal(deferred.backpressure?.action, 'defer', 'expected enqueue response to expose defer action'); +assert.equal(typeof deferred.backpressure?.deferredUntil, 'string', 'expected deferred enqueue to include next eligibility timestamp'); + +const deferredQueue = await loadQueue(queueDir, 'index'); +const deferredJob = deferredQueue.jobs.find((entry) => entry.id === 'job-heavy-deferred'); +assert.equal(typeof deferredJob?.nextEligibleAt, 'string', 'expected deferred job to persist delayed eligibility'); +assert.equal(deferredJob?.progress?.kind, 'defer', 'expected deferred job to record defer progress kind'); + +await saveQueue(queueDir, { + jobs: [ + { + id: 'existing-overloaded-a', + status: 'queued', + queueName: 'index', + repo: '/tmp/overloaded-a', + mode: 'code', + stage: 'stage1', + createdAt: new Date(Date.now() - 10000).toISOString(), + attempts: 1 + }, + { + id: 'existing-overloaded-b', + status: 'running', + queueName: 'index', + repo: '/tmp/overloaded-b', + mode: 'both', + stage: 'stage2', + createdAt: new Date(Date.now() - 10000).toISOString(), + startedAt: new Date(Date.now() - 10000).toISOString(), + attempts: 1 + } + ] +}, 'index'); + +const rejected = await enqueueJob(queueDir, { + id: 'job-heavy-rejected', + createdAt: new Date().toISOString(), + repo: '/tmp/heavy-rejected', + mode: 'both', + stage: 'stage3' +}, null, 'index', { + admissionPolicy, + sloPolicy +}); +assert.equal(rejected.ok, false, 'expected overloaded heavy enqueue to reject'); +assert.equal(rejected.code, 'QUEUE_SLO_OVERLOADED', 'expected overloaded rejection code to remain stable'); +assert.equal(rejected.backpressure?.action, 'reject', 'expected rejection payload to include action'); + +console.log('service queue load shedding test passed'); diff --git a/tests/services/queue/quarantine.test.js b/tests/services/queue/quarantine.test.js new file mode 100644 index 000000000..91d451ac7 --- /dev/null +++ b/tests/services/queue/quarantine.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + enqueueJob, + ensureQueueDir, + loadQuarantine, + loadQueue, + purgeQuarantinedJobs, + quarantineJob, + quarantineSummary, + requeueStaleJobs, + retryQuarantinedJob, + saveQueue +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-quarantine'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo-quarantine', + repoRoot: '/tmp/repo-quarantine', + mode: 'code', + reason: 'test', + stage: 'stage1' +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-poison', buildId: 'build-poison' }, null, 'index'); +const claimed = await claimNextJob(queueDir, 'index', { ownerId: 'worker-poison' }); +assert.equal(claimed?.status, 'running', 'expected poison job to be claimed'); + +const quarantined = await quarantineJob(queueDir, claimed.id, 'poison-payload', 'index', { + ownerId: 'worker-poison', + expectedLeaseVersion: claimed.lease?.version ?? null, + sourceStatus: 'running', + result: { + exitCode: 1, + error: 'poison payload detected' + } +}); +assert.equal(quarantined?.quarantine?.reason, 'poison-payload', 'expected quarantine reason to be recorded'); + +const primaryQueue = await loadQueue(queueDir, 'index'); +assert.equal(primaryQueue.jobs.length, 0, 'expected poisoned job to be removed from the hot queue'); + +const quarantineStore = await loadQuarantine(queueDir, 'index'); +const poisonEntry = quarantineStore.jobs.find((job) => job.id === claimed.id); +assert.equal(poisonEntry?.quarantine?.state, 'quarantined', 'expected poison job to remain active in quarantine'); +assert.ok(poisonEntry?.logPath, 'expected quarantine record to preserve log path'); +assert.ok(poisonEntry?.reportPath, 'expected quarantine record to preserve report path'); + +const quarantineCounts = await quarantineSummary(queueDir, 'index'); +assert.equal(quarantineCounts.quarantined, 1, 'expected one active quarantined job'); + +const retryResult = await retryQuarantinedJob(queueDir, claimed.id, 'index'); +assert.equal(retryResult?.ok, true, 'expected quarantined job retry to succeed'); +assert.notEqual(retryResult?.job?.id, claimed.id, 'expected retried job to use a fresh queue id'); + +const afterRetryQueue = await loadQueue(queueDir, 'index'); +assert.equal(afterRetryQueue.jobs.length, 1, 'expected retried job to re-enter the hot queue'); +assert.equal(afterRetryQueue.jobs[0].status, 'queued', 'expected retried job to enqueue as queued'); + +const afterRetryQuarantine = await loadQuarantine(queueDir, 'index'); +const retriedEntry = afterRetryQuarantine.jobs.find((job) => job.id === claimed.id); +assert.equal(retriedEntry?.quarantine?.state, 'retried', 'expected quarantine record to retain retry lineage'); +assert.equal(retriedEntry?.quarantine?.retryJobId, retryResult?.job?.id, 'expected quarantine record to reference the new queue job'); + +const purgeResult = await purgeQuarantinedJobs(queueDir, 'index', { jobId: claimed.id }); +assert.equal(purgeResult.removed, 1, 'expected targeted purge to remove retried quarantine record'); +assert.equal((await loadQuarantine(queueDir, 'index')).jobs.length, 0, 'expected quarantine store to be empty after purge'); + +const staleRoot = resolveTestCachePath(root, 'service-queue-quarantine-stale'); +const staleQueueDir = path.join(staleRoot, 'queue'); +await fsPromises.rm(staleRoot, { recursive: true, force: true }); +await ensureQueueDir(staleQueueDir); + +await enqueueJob(staleQueueDir, { + ...baseJob, + id: 'job-stale', + buildId: 'build-stale', + maxRetries: 0 +}, null, 'index'); +const staleClaim = await claimNextJob(staleQueueDir, 'index', { + ownerId: 'worker-stale', + leaseMs: 5 +}); +assert.equal(staleClaim?.status, 'running', 'expected stale job to start running'); + +const staleQueue = await loadQueue(staleQueueDir, 'index'); +const staleRunning = staleQueue.jobs.find((job) => job.id === 'job-stale'); +const expiredAt = new Date(Date.now() - 1000).toISOString(); +staleRunning.lease.expiresAt = expiredAt; +staleRunning.lastHeartbeatAt = expiredAt; +await saveQueue(staleQueueDir, staleQueue, 'index'); + +const staleSweep = await requeueStaleJobs(staleQueueDir, 'index', { maxRetries: 0 }); +assert.equal(staleSweep.failed, 1, 'expected stale exhaustion to count as failed'); +assert.equal(staleSweep.quarantined, 1, 'expected stale exhaustion to move the job into quarantine'); +assert.equal((await loadQueue(staleQueueDir, 'index')).jobs.length, 0, 'expected exhausted stale job to leave the hot queue'); + +const staleQuarantine = await loadQuarantine(staleQueueDir, 'index'); +const staleEntry = staleQuarantine.jobs.find((job) => job.id === 'job-stale'); +assert.equal(staleEntry?.quarantine?.reason, 'lease-expired-fail', 'expected stale quarantine reason to be recorded'); + +console.log('service queue quarantine test passed'); diff --git a/tests/services/queue/replay-inspection.test.js b/tests/services/queue/replay-inspection.test.js new file mode 100644 index 000000000..d3de71fe9 --- /dev/null +++ b/tests/services/queue/replay-inspection.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { + claimNextJob, + enqueueJob, + ensureQueueDir, + inspectJobReplayState, + loadQueue, + requeueStaleJobs, + retryQuarantinedJob, + saveQueue +} from '../../../tools/service/queue.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue-replay-inspection'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo-replay-inspection', + repoRoot: '/tmp/repo-replay-inspection', + mode: 'code', + reason: 'test', + stage: 'stage1', + buildId: 'build-inspect' +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-original', maxRetries: 0 }, null, 'index'); +const claimed = await claimNextJob(queueDir, 'index', { + ownerId: 'worker-inspect', + leaseMs: 5 +}); +assert.equal(claimed?.status, 'running'); + +const queue = await loadQueue(queueDir, 'index'); +const running = queue.jobs.find((job) => job.id === 'job-original'); +const expiredAt = new Date(Date.now() - 1000).toISOString(); +running.lease.expiresAt = expiredAt; +running.lastHeartbeatAt = expiredAt; +await saveQueue(queueDir, queue, 'index'); + +const stale = await requeueStaleJobs(queueDir, 'index', { maxRetries: 0 }); +assert.equal(stale.quarantined, 1, 'expected exhausted job to move into quarantine'); + +const retried = await retryQuarantinedJob(queueDir, 'job-original', 'index'); +assert.equal(retried?.ok, true); +assert.ok(retried?.job?.id, 'expected manual retry to create a fresh queue job'); + +const inspection = await inspectJobReplayState(queueDir, retried.job.id, 'index'); +assert.equal(inspection?.deliverySemantics, 'at-least-once'); +assert.equal(inspection?.deliveryContract?.semantics, 'at-least-once'); +assert.equal(inspection?.deliveryContract?.idempotencyKey, inspection?.job?.idempotencyKey || null); +assert.equal( + inspection?.deliveryContract?.sideEffectFences?.reportWrite, + 'atomic-report-path', + 'expected replay inspection to expose report-write idempotency fence' +); +assert.equal(inspection?.job?.delivery?.replayOfJobId, 'job-original'); +assert.equal(inspection?.job?.replayHistory?.some((entry) => entry?.action === 'manual-retry-created'), true); +assert.equal(inspection?.relatedJobs?.some((entry) => entry?.id === 'job-original'), true); + +console.log('service queue replay inspection test passed'); diff --git a/tests/services/queue/service-queue.test.js b/tests/services/queue/service-queue.test.js deleted file mode 100644 index e97204990..000000000 --- a/tests/services/queue/service-queue.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { - ensureQueueDir, - enqueueJob, - claimNextJob, - completeJob, - queueSummary -} from '../../../tools/service/queue.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'service-queue'); -const queueDir = path.join(tempRoot, 'queue'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await ensureQueueDir(queueDir); - -const baseJob = { - createdAt: new Date().toISOString(), - repo: '/tmp/repo', - mode: 'all', - reason: 'test' -}; - -await enqueueJob(queueDir, { ...baseJob, id: 'job-index' }, null, 'index'); -await enqueueJob(queueDir, { ...baseJob, id: 'job-embed' }, null, 'embeddings'); - -const summaryIndex = await queueSummary(queueDir, 'index'); -const summaryEmbed = await queueSummary(queueDir, 'embeddings'); -if (summaryIndex.total !== 1 || summaryEmbed.total !== 1) { - console.error('Queue summary counts mismatch'); - process.exit(1); -} - -const job = await claimNextJob(queueDir, 'index'); -if (!job || job.status !== 'running') { - console.error('Expected queued job to transition to running'); - process.exit(1); -} -await completeJob(queueDir, job.id, 'failed', { exitCode: 1 }, 'index'); - -const summaryAfter = await queueSummary(queueDir, 'index'); -if (summaryAfter.failed !== 1) { - console.error('Expected failed job count to be 1'); - process.exit(1); -} - -console.log('service queue test passed'); - diff --git a/tests/services/queue/service.test.js b/tests/services/queue/service.test.js new file mode 100644 index 000000000..9aa6c321d --- /dev/null +++ b/tests/services/queue/service.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + ensureQueueDir, + enqueueJob, + claimNextJob, + completeJob, + queueSummary +} from '../../../tools/service/queue.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-queue'); +const queueDir = path.join(tempRoot, 'queue'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await ensureQueueDir(queueDir); + +const baseJob = { + createdAt: new Date().toISOString(), + repo: '/tmp/repo', + mode: 'all', + reason: 'test' +}; + +await enqueueJob(queueDir, { ...baseJob, id: 'job-index' }, null, 'index'); +await enqueueJob(queueDir, { ...baseJob, id: 'job-embed' }, null, 'embeddings'); + +const summaryIndex = await queueSummary(queueDir, 'index'); +const summaryEmbed = await queueSummary(queueDir, 'embeddings'); +if (summaryIndex.total !== 1 || summaryEmbed.total !== 1) { + console.error('Queue summary counts mismatch'); + process.exit(1); +} + +const job = await claimNextJob(queueDir, 'index'); +if (!job || job.status !== 'running') { + console.error('Expected queued job to transition to running'); + process.exit(1); +} +await completeJob( + queueDir, + job.id, + 'failed', + { exitCode: 1 }, + 'index', + { + ownerId: job.lease?.owner || null, + expectedLeaseVersion: job.lease?.version ?? null + } +); + +const summaryAfter = await queueSummary(queueDir, 'index'); +if (summaryAfter.failed !== 1) { + console.error('Expected failed job count to be 1'); + process.exit(1); +} + +console.log('service queue test passed'); + diff --git a/tests/services/queue/worker-backpressure-metrics.test.js b/tests/services/queue/worker-backpressure-metrics.test.js new file mode 100644 index 000000000..3acf008b5 --- /dev/null +++ b/tests/services/queue/worker-backpressure-metrics.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createQueueWorker } from '../../../tools/service/indexer-service/queue-worker.js'; + +const payloads = []; +let claimed = false; +const worker = createQueueWorker({ + queueDir: 'queue-dir', + resolvedQueueName: 'index', + staleQueueMaxRetries: 1, + monitorBuildProgress: false, + startBuildProgressMonitor: () => async () => {}, + touchJobHeartbeat: async () => ({ ok: true }), + requeueStaleJobs: async () => ({ stale: 0, retried: 0, failed: 0, quarantined: 0 }), + claimNextJob: async () => { + if (claimed) return null; + claimed = true; + return { + id: 'job-a', + repo: '/tmp/repo', + stage: 'stage1', + lease: { + owner: 'worker-metrics', + version: 1 + } + }; + }, + ensureQueueDir: async () => {}, + executeClaimedJob: async () => ({ + handled: false, + runResult: { + exitCode: 0, + signal: null, + executionMode: 'subprocess', + daemon: null + } + }), + finalizeJobRun: async ({ metrics }) => { + metrics.succeeded += 1; + }, + buildDefaultRunResult: () => ({ + exitCode: 1, + executionMode: 'subprocess', + daemon: null + }), + printPayload: (payload) => { + payloads.push(payload); + }, + summarizeBackpressure: async () => ({ + state: 'congested', + reasons: ['max_running'], + slo: { + state: 'degraded', + actions: { + enqueue: 'defer-heavy', + workerMode: 'priority-only' + } + } + }), + resolveLeasePolicy: () => ({ + leaseMs: 1000, + renewIntervalMs: 250, + progressIntervalMs: 250, + workloadClass: 'balanced', + maxRenewalGapMs: 750, + maxConsecutiveRenewalFailures: 3 + }), + jobHeartbeatIntervalMs: 250 +}); + +await worker.runBatch(1); +assert.equal(payloads.length, 1, 'expected one worker batch payload'); +assert.equal(payloads[0].backpressure?.state, 'congested', 'expected worker metrics payload to include backpressure state'); +assert.equal(payloads[0].backpressure?.reasons.includes('max_running'), true, 'expected worker metrics payload to include reasons'); +assert.equal(payloads[0].backpressure?.slo?.state, 'degraded', 'expected worker metrics payload to include SLO state'); +assert.equal(payloads[0].backpressure?.slo?.actions?.workerMode, 'priority-only', 'expected worker metrics payload to expose priority-only mode'); + +console.log('service queue worker backpressure metrics test passed'); diff --git a/tests/services/queue/worker-renewal.test.js b/tests/services/queue/worker-renewal.test.js new file mode 100644 index 000000000..eae13b189 --- /dev/null +++ b/tests/services/queue/worker-renewal.test.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createQueueWorker } from '../../../tools/service/indexer-service/queue-worker.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const buildWorker = ({ + claimedJob, + executeMs, + policy, + touchImpl = async () => ({ ok: true }) +}) => { + const touched = []; + let claimed = false; + let finalized = 0; + const worker = createQueueWorker({ + queueDir: 'queue-dir', + resolvedQueueName: 'index', + staleQueueMaxRetries: 1, + monitorBuildProgress: false, + startBuildProgressMonitor: () => async () => {}, + touchJobHeartbeat: async (...args) => { + touched.push(args); + return touchImpl(...args); + }, + requeueStaleJobs: async () => ({ stale: 0, retried: 0, failed: 0 }), + claimNextJob: async () => { + if (claimed) return null; + claimed = true; + return { + ...claimedJob, + lease: { + owner: 'queue-worker:test', + version: 1 + } + }; + }, + ensureQueueDir: async () => {}, + executeClaimedJob: async () => { + await sleep(executeMs); + return { + handled: false, + runResult: { + exitCode: 0, + signal: null, + executionMode: 'subprocess', + daemon: null + } + }; + }, + finalizeJobRun: async () => { + finalized += 1; + }, + buildDefaultRunResult: () => ({ + exitCode: 1, + executionMode: 'subprocess', + daemon: null + }), + printPayload: () => {}, + resolveLeasePolicy: () => policy, + jobHeartbeatIntervalMs: 5 + }); + return { worker, touched, getFinalized: () => finalized }; +}; + +const slowCase = buildWorker({ + claimedJob: { id: 'slow-job', repo: '/tmp/repo', stage: 'stage3' }, + executeMs: 650, + policy: { + leaseMs: 1200, + renewIntervalMs: 250, + progressIntervalMs: 250, + workloadClass: 'slow', + maxRenewalGapMs: 750, + maxConsecutiveRenewalFailures: 3 + } +}); +await slowCase.worker.processQueueOnce({ processed: 0, succeeded: 0, failed: 0, retried: 0 }); +assert.equal(slowCase.getFinalized(), 1, 'expected slow job to finalize'); +assert.equal(slowCase.touched.length >= 2, true, 'expected slow job to renew multiple times'); + +const burstyCase = buildWorker({ + claimedJob: { id: 'bursty-job', repo: '/tmp/repo', stage: 'stage2' }, + executeMs: 260, + policy: { + leaseMs: 1200, + renewIntervalMs: 300, + progressIntervalMs: 250, + workloadClass: 'bursty', + maxRenewalGapMs: 900, + maxConsecutiveRenewalFailures: 3 + } +}); +await burstyCase.worker.processQueueOnce({ processed: 0, succeeded: 0, failed: 0, retried: 0 }); +assert.equal(burstyCase.getFinalized(), 1, 'expected bursty job to finalize'); +assert.equal(burstyCase.touched.length <= 1, true, 'expected bounded renewal writes for bursty job'); + +let renewalFailures = 0; +const renewalLossCase = buildWorker({ + claimedJob: { id: 'loss-job', repo: '/tmp/repo', stage: 'stage3' }, + executeMs: 350, + policy: { + leaseMs: 1200, + renewIntervalMs: 250, + progressIntervalMs: 250, + workloadClass: 'slow', + maxRenewalGapMs: 750, + maxConsecutiveRenewalFailures: 2 + }, + touchImpl: async () => { + renewalFailures += 1; + throw new Error('synthetic renewal loss'); + } +}); +await renewalLossCase.worker.processQueueOnce({ processed: 0, succeeded: 0, failed: 0, retried: 0 }); +assert.equal(renewalLossCase.getFinalized(), 1, 'expected renewal loss to avoid crashing job finalization'); +assert.equal(renewalFailures >= 1, true, 'expected renewal loss path to attempt renewals'); + +console.log('service queue worker renewal test passed'); diff --git a/tests/services/risk-explain-adapter-matrix.test.js b/tests/services/risk-explain-adapter-matrix.test.js new file mode 100644 index 000000000..73db93784 --- /dev/null +++ b/tests/services/risk-explain-adapter-matrix.test.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import http from 'node:http'; +import path from 'node:path'; + +import { applyTestEnv, withTemporaryEnv } from '../helpers/test-env.js'; +import { ensureFixtureIndex } from '../helpers/fixture-index.js'; +import { runNode } from '../helpers/run-node.js'; +import { loadJsonArrayArtifact } from '../../src/shared/artifact-io.js'; +import { createApiRouter } from '../../tools/api/router.js'; +import { handleToolCall } from '../../tools/mcp/tools.js'; +import { getCombinedOutput } from '../helpers/stdio.js'; + +applyTestEnv(); + +const { root, fixtureRoot, codeDir, env } = await ensureFixtureIndex({ + fixtureName: 'risk-interprocedural/js-simple', + cacheName: 'risk-interprocedural-js-simple-adapter-risk-explain', + requireRiskTags: true, + cacheScope: 'isolated', + requiredModes: ['code'] +}); + +const flows = await loadJsonArrayArtifact(codeDir, 'risk_flows', { strict: false }).catch(() => []); +const partialFlows = await loadJsonArrayArtifact(codeDir, 'risk_partial_flows', { strict: false }).catch(() => []); +if ((!Array.isArray(flows) || flows.length === 0) && (!Array.isArray(partialFlows) || partialFlows.length === 0)) { + console.log('risk flows unavailable; skipping risk explain adapter matrix.'); + process.exit(0); +} + +const flow = Array.isArray(flows) && flows.length ? flows[0] : null; +const partialFlow = Array.isArray(partialFlows) && partialFlows.length ? partialFlows[0] : null; +const chunkUid = flow?.source?.chunkUid || flow?.sink?.chunkUid || partialFlow?.source?.chunkUid || partialFlow?.frontier?.chunkUid; +assert.ok(chunkUid, 'expected flow to include a chunkUid'); + +const expectedFilters = { + rule: [], + category: [], + severity: [], + tag: [], + source: [], + sink: [], + sourceRule: flow?.source?.ruleId ? [flow.source.ruleId] : [], + sinkRule: flow?.sink?.ruleId ? [flow.sink.ruleId] : [], + flowId: flow?.flowId ? [flow.flowId] : [] +}; + +await withTemporaryEnv(env, async () => { + { + const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: fixtureRoot, + defaultOutput: 'json', + metricsRegistry: null + }); + const server = http.createServer((req, res) => router.handleRequest(req, res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/analysis/risk-explain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + repoPath: fixtureRoot, + chunk: chunkUid, + max: 5, + includePartialFlows: true, + maxPartialFlows: 2, + filters: { + flowId: flow?.flowId, + sourceRule: flow?.source?.ruleId, + sinkRule: flow?.sink?.ruleId + } + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.ok, true); + assert.deepEqual(payload.result?.flows?.map((entry) => entry.flowId), flow ? [flow.flowId] : []); + assert.ok(payload.result?.flows?.[0]?.path?.watchByStep?.[0]); + assert.deepEqual(payload.result?.partialFlows, []); + assert.deepEqual(payload.result?.filters, expectedFilters); + + const invalidResponse = await fetch(`http://127.0.0.1:${port}/analysis/risk-explain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + repoPath: fixtureRoot, + chunk: chunkUid, + includePartialFlows: true, + filters: { severity: 'urgent' } + }) + }); + assert.equal(invalidResponse.status, 400); + const invalidPayload = await invalidResponse.json(); + assert.equal(invalidPayload.ok, false); + } finally { + server.close(); + if (typeof router.close === 'function') router.close(); + } + } + + { + const result = await handleToolCall('risk_explain', { + repoPath: fixtureRoot, + chunk: chunkUid, + max: 5, + includePartialFlows: true, + maxPartialFlows: 2, + filters: { + flowId: flow?.flowId, + sourceRule: flow?.source?.ruleId, + sinkRule: flow?.sink?.ruleId + } + }); + assert.deepEqual(result.flows?.map((entry) => entry.flowId), flow ? [flow.flowId] : []); + assert.ok(result.flows?.[0]?.path?.watchByStep?.[0]); + assert.deepEqual(result.partialFlows, []); + assert.deepEqual(result.filters, expectedFilters); + + let invalidChunkError = null; + try { + await handleToolCall('risk_explain', { + repoPath: fixtureRoot, + chunk: 'chunk:missing-risk-explain-test' + }); + } catch (err) { + invalidChunkError = err; + } + + assert.ok(invalidChunkError); + assert.equal(invalidChunkError.code, 'INVALID_REQUEST'); + assert.equal(invalidChunkError.reason, 'unknown_chunk_uid'); + } + + { + const binPath = path.join(root, 'bin', 'pairofcleats.js'); + const result = runNode( + [binPath, 'risk', 'explain', '--index', codeDir, '--chunk', chunkUid, '--max', '1'], + 'risk explain CLI text output', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.equal(result.status, 0); + const output = getCombinedOutput(result, { trim: true }); + assert.ok(output.includes(flow.flowId)); + assert.ok(output.includes('src/index.js')); + + const filteredResult = runNode( + [binPath, 'risk', 'explain', '--index', codeDir, '--chunk', chunkUid, '--max', '5', '--flow-id', flow.flowId], + 'risk explain CLI filtered output', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.equal(filteredResult.status, 0); + assert.ok(getCombinedOutput(filteredResult, { trim: true }).includes(flow.flowId)); + + const jsonResult = runNode( + [binPath, 'risk', 'explain', '--index', codeDir, '--chunk', chunkUid, '--max', '1', '--json', '--includePartialFlows', '--maxPartialFlows', '2'], + 'risk explain CLI JSON output', + root, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.equal(jsonResult.status, 0); + const jsonPayload = JSON.parse(getCombinedOutput(jsonResult, { trim: true })); + assert.equal(jsonPayload.rendered.flowSelection.totalFlows, 1); + assert.equal(jsonPayload.rendered.partialFlowSelection.totalPartialFlows, partialFlow ? 1 : 0); + if (flow) { + assert.ok(jsonPayload.rendered.flows?.[0]?.steps?.[0]?.watchWindow); + assert.ok(jsonPayload.flows?.[0]?.path?.watchByStep?.[0]); + } + } +}); + +console.log('risk explain adapter matrix test passed'); diff --git a/tests/services/service-mode-smoke.test.js b/tests/services/service-mode-smoke.test.js index e3459b4eb..c9b1904a3 100644 --- a/tests/services/service-mode-smoke.test.js +++ b/tests/services/service-mode-smoke.test.js @@ -1,22 +1,21 @@ #!/usr/bin/env node import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; const root = process.cwd(); -const run = spawnSync( - process.execPath, +const run = runNode( [path.join(root, 'tools', 'service', 'indexer-service.js'), 'smoke', '--json'], - { - cwd: root, - encoding: 'utf8', - env: { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: resolveTestCachePath(root, 'service-mode-smoke') - } - } + 'indexer service smoke', + root, + applyTestEnv({ + cacheRoot: resolveTestCachePath(root, 'service-mode-smoke'), + syncProcess: false + }), + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (run.status !== 0) { diff --git a/tests/services/snapshot-core-contract-matrix.test.js b/tests/services/snapshot-core-contract-matrix.test.js new file mode 100644 index 000000000..0d9da008a --- /dev/null +++ b/tests/services/snapshot-core-contract-matrix.test.js @@ -0,0 +1,980 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { acquireIndexLock } from '../../src/index/build/lock.js'; +import { getRepoCacheRoot } from '../../src/shared/dict-utils.js'; +import { + createPointerSnapshot, + listSnapshots, + pruneSnapshots, + removeSnapshot, + showSnapshot +} from '../../src/index/snapshots/create.js'; +import { freezeSnapshot } from '../../src/index/snapshots/freeze.js'; +import { resolveIndexRef } from '../../src/index/index-ref.js'; +import { loadFrozen, loadSnapshot, loadSnapshotsManifest } from '../../src/index/snapshots/registry.js'; +import { computeIndexDiff, pruneDiffs, showDiff } from '../../src/index/diffs/compute.js'; +import { loadDiffsManifest, writeDiffsManifest } from '../../src/index/diffs/registry.js'; +import { replaceDir } from '../../src/shared/json-stream/atomic.js'; + +import { createBaseIndex } from '../indexing/validate/helpers.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const writeJson = async (filePath, value) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +}; + +const createSnapshotServiceFixture = async (name) => { + applyTestEnv(); + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, name); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const userConfig = { + cache: { root: cacheRoot }, + sqlite: { use: false }, + lmdb: { use: false } + }; + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(repoRoot, { recursive: true }); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); + return { tempRoot, repoRoot, userConfig, repoCacheRoot }; +}; + +const writeBuildState = async ({ + repoCacheRoot, + buildId, + validationOk = true, + includeValidation = true +}) => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + const payload = { + schemaVersion: 1, + buildId, + configHash: `cfg-${buildId}`, + tool: { version: '1.0.0' }, + repo: { provider: 'git', branch: 'main', commit: 'abc123', dirty: false } + }; + if (includeValidation) { + payload.validation = { ok: validationOk, issueCount: 0, warningCount: 0, issues: [] }; + } + await writeJson(path.join(buildRoot, 'build_state.json'), payload); + return buildRoot; +}; + +const sha1File = async (filePath) => { + const data = await fs.readFile(filePath); + return crypto.createHash('sha1').update(data).digest('hex'); +}; + +const enrichPiecesManifestChecksums = async (indexDir, { corruptFirst = false } = {}) => { + const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const pieces = Array.isArray(manifest.pieces) ? manifest.pieces : []; + for (let i = 0; i < pieces.length; i += 1) { + const piece = pieces[i]; + const filePath = path.join(indexDir, piece.path); + const stat = await fs.stat(filePath); + piece.bytes = Number(stat.size || 0); + const hash = await sha1File(filePath); + piece.checksum = `sha1:${hash}`; + } + if (corruptFirst && pieces.length) { + pieces[0].checksum = 'sha1:0000000000000000000000000000000000000000'; + } + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +}; + +const seedFreezeBuildRoot = async ({ + repoCacheRoot, + buildId, + corruptManifest = false +}) => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + await fs.mkdir(buildRoot, { recursive: true }); + const { indexDir } = await createBaseIndex({ rootDir: buildRoot }); + const modeDir = path.join(buildRoot, 'index-code'); + await fs.mkdir(path.dirname(modeDir), { recursive: true }); + await replaceDir(indexDir, modeDir); + await fs.rm(path.join(buildRoot, '.index-root'), { recursive: true, force: true }); + await enrichPiecesManifestChecksums(modeDir, { corruptFirst: corruptManifest }); + await writeJson(path.join(buildRoot, 'build_state.json'), { + schemaVersion: 1, + buildId, + configHash: `cfg-${buildId}`, + tool: { version: '1.0.0' }, + validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } + }); + return buildRoot; +}; + +const sha1Value = (value) => crypto.createHash('sha1').update(String(value)).digest('hex'); + +const writePiecesManifest = async (indexDir, files) => { + const entries = []; + for (const file of files) { + const absolute = path.join(indexDir, file.path); + const stat = await fs.stat(absolute); + entries.push({ + type: file.type, + name: file.name, + format: 'json', + path: file.path, + bytes: Number(stat.size || 0), + checksum: `sha1:${await sha1File(absolute)}` + }); + } + await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { + version: 2, + artifactSurfaceVersion: '0.2.0', + pieces: entries + }); +}; + +const seedDiffBuild = async ({ + repoCacheRoot, + buildId, + files, + chunkSignature, + configHash, + toolVersion, + fileMetaRows = null, + chunkMetaRows = null +}) => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + const indexDir = path.join(buildRoot, 'index-code'); + await fs.mkdir(indexDir, { recursive: true }); + + const fileMeta = Array.isArray(fileMetaRows) && fileMetaRows.length + ? fileMetaRows + : files.map((entry, index) => ({ + id: index + 1, + file: entry.file, + hash: sha1Value(entry.content), + size: entry.content.length, + ext: 'js' + })); + await writeJson(path.join(indexDir, 'file_meta.json'), fileMeta); + + const chunkMeta = Array.isArray(chunkMetaRows) && chunkMetaRows.length + ? chunkMetaRows + : files.map((entry, index) => ({ + id: index, + fileId: index + 1, + file: entry.file, + start: 0, + end: entry.content.length, + startLine: 1, + endLine: 1, + kind: 'function', + name: entry.file, + chunkId: entry.chunkId, + metaV2: { + chunkId: entry.chunkId, + chunkUid: `ck:${buildId}:${entry.chunkId}`, + signature: chunkSignature[entry.file], + virtualPath: entry.file, + file: entry.file + } + })); + await writeJson(path.join(indexDir, 'chunk_meta.json'), chunkMeta); + + await writeJson(path.join(indexDir, 'index_state.json'), { + generatedAt: new Date().toISOString(), + mode: 'code', + artifactSurfaceVersion: '0.2.0', + buildId, + configHash, + tool: { version: toolVersion } + }); + + await writePiecesManifest(indexDir, [ + { type: 'meta', name: 'file_meta', path: 'file_meta.json' }, + { type: 'chunks', name: 'chunk_meta', path: 'chunk_meta.json' }, + { type: 'stats', name: 'index_state', path: 'index_state.json' } + ]); + + await writeJson(path.join(buildRoot, 'build_state.json'), { + schemaVersion: 1, + buildId, + configHash, + tool: { version: toolVersion }, + validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } + }); + return buildRoot; +}; + +const writeCurrentBuildPointer = (repoCacheRoot, buildId) => writeJson( + path.join(repoCacheRoot, 'builds', 'current.json'), + { + buildId, + buildRoot: `builds/${buildId}`, + buildRoots: { code: `builds/${buildId}` } + } +); + +const seedDiffBuildSnapshot = async ({ + repoRoot, + userConfig, + repoCacheRoot, + buildId, + snapshotId, + files, + chunkSignature, + configHash, + toolVersion +}) => { + await seedDiffBuild({ + repoCacheRoot, + buildId, + files, + chunkSignature, + configHash, + toolVersion + }); + await writeCurrentBuildPointer(repoCacheRoot, buildId); + return createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId + }); +}; + +const runSnapshotCreateCase = async () => { + applyTestEnv(); + + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'snapshot-create-service'); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const userConfig = { cache: { root: cacheRoot } }; + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(repoRoot, { recursive: true }); + + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const buildsRoot = path.join(repoCacheRoot, 'builds'); + await fs.mkdir(buildsRoot, { recursive: true }); + + const buildCodeRoot = await writeBuildState({ + repoCacheRoot, + buildId: 'build-code', + validationOk: true + }); + const buildProseRoot = await writeBuildState({ + repoCacheRoot, + buildId: 'build-prose', + validationOk: true + }); + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'build-code', + buildRoot: 'builds/build-code', + buildRoots: { + code: 'builds/build-code', + prose: 'builds/build-prose' + } + }); + + const firstSnapshot = await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + tags: ['release'], + snapshotId: 'snap-20260212000000-aa0001' + }); + assert.equal(firstSnapshot.snapshotId, 'snap-20260212000000-aa0001'); + assert.deepEqual(firstSnapshot.modes, ['code']); + assert.equal(firstSnapshot.retention?.tier, 'pinned'); + + const manifestAfterFirst = loadSnapshotsManifest(repoCacheRoot); + assert.ok(manifestAfterFirst.snapshots[firstSnapshot.snapshotId]); + const firstSnapshotJson = loadSnapshot(repoCacheRoot, firstSnapshot.snapshotId); + assert.deepEqual(Object.keys(firstSnapshotJson.pointer.buildRootsByMode), ['code']); + assert.equal(firstSnapshotJson.pointer.buildRootsByMode.code, 'builds/build-code'); + + const activeBuildLock = await acquireIndexLock({ + repoCacheRoot, + waitMs: 0, + metadata: { + owner: 'build-index', + operation: 'stage4-promote' + } + }); + assert.ok(activeBuildLock); + try { + const concurrentSnapshot = await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-aa0002', + waitMs: 0 + }); + assert.equal(concurrentSnapshot.snapshotId, 'snap-20260212000000-aa0002'); + } finally { + await activeBuildLock.release(); + } + + const escapeTargetRoot = path.join(tempRoot, 'snapshot-escape-target'); + const escapeLinkRoot = path.join(buildsRoot, 'build-escape-link'); + let escapeLinkCreated = false; + try { + await fs.mkdir(escapeTargetRoot, { recursive: true }); + await writeJson(path.join(escapeTargetRoot, 'build_state.json'), { + schemaVersion: 1, + buildId: 'escape-build', + configHash: 'cfg-escape-build', + tool: { version: '1.0.0' }, + validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } + }); + await fs.symlink(escapeTargetRoot, escapeLinkRoot, process.platform === 'win32' ? 'junction' : 'dir'); + escapeLinkCreated = true; + } catch {} + if (escapeLinkCreated) { + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'escape-build', + buildRoot: 'builds/build-escape-link', + buildRoots: { code: 'builds/build-escape-link' } + }); + await assert.rejects( + () => createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-aa0003' + }), + /escapes repo cache root/ + ); + } + + await writeBuildState({ + repoCacheRoot, + buildId: 'build-invalid', + validationOk: false + }); + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'build-invalid', + buildRoot: 'builds/build-invalid', + buildRoots: { code: 'builds/build-invalid' } + }); + await assert.rejects( + () => createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-aa0004' + }), + /validation\.ok === true/ + ); + + await writeBuildState({ + repoCacheRoot, + buildId: 'build-missing-validation', + includeValidation: false + }); + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'build-missing-validation', + buildRoot: 'builds/build-missing-validation', + buildRoots: { code: 'builds/build-missing-validation' } + }); + await assert.rejects( + () => createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-aa0005' + }), + /validation\.ok === true/ + ); + + await writeJson(path.join(buildsRoot, 'current.json'), { + buildId: 'build-code', + buildRoot: 'builds/build-code', + buildRoots: { code: 'builds/build-code' } + }); + const retentionIds = [ + 'snap-20260212000000-aa0006', + 'snap-20260212000000-aa0007', + 'snap-20260212000000-aa0008' + ]; + for (const snapshotId of retentionIds) { + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId, + maxPointerSnapshots: 2 + }); + } + + const manifestAfterRetention = loadSnapshotsManifest(repoCacheRoot); + const allEntries = Object.values(manifestAfterRetention.snapshots || {}); + const untaggedPointers = allEntries.filter((entry) => ( + entry.kind === 'pointer' && (!Array.isArray(entry.tags) || entry.tags.length === 0) + )); + assert.equal(untaggedPointers.length, 2); + assert.ok(manifestAfterRetention.snapshots['snap-20260212000000-aa0001']); + assert.ok(!manifestAfterRetention.snapshots['snap-20260212000000-aa0006']); + await assert.rejects(() => fs.stat(path.join(repoCacheRoot, 'snapshots', 'snap-20260212000000-aa0006'))); + const listedSnapshots = listSnapshots({ repoRoot, userConfig }); + assert.ok(listedSnapshots.length >= 3); + const shownSnapshot = showSnapshot({ + repoRoot, + userConfig, + snapshotId: 'snap-20260212000000-aa0001' + }); + assert.ok(shownSnapshot?.entry); + + const dryRunPrune = await pruneSnapshots({ + repoRoot, + userConfig, + maxPointerSnapshots: 1, + dryRun: true + }); + assert.ok(Array.isArray(dryRunPrune.removed)); + assert.ok(dryRunPrune.decisions.some((entry) => entry.reason === 'tagged' || entry.reason === 'pointer_budget')); + + const removableId = untaggedPointers.find((entry) => entry.snapshotId)?.snapshotId; + assert.ok(removableId); + await removeSnapshot({ + repoRoot, + userConfig, + snapshotId: removableId, + force: true + }); + const afterRemoveManifest = loadSnapshotsManifest(repoCacheRoot); + assert.ok(!afterRemoveManifest.snapshots[removableId]); + + assert.ok(buildCodeRoot); + assert.ok(buildProseRoot); +}; + +const runSnapshotFreezeCase = async () => { + const { tempRoot, repoRoot, userConfig, repoCacheRoot } = await createSnapshotServiceFixture('snapshot-freeze-service'); + + const goodBuildRoot = await seedFreezeBuildRoot({ + repoCacheRoot, + buildId: 'build-freeze-good', + corruptManifest: false + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-freeze-good', + buildRoot: 'builds/build-freeze-good', + buildRoots: { code: 'builds/build-freeze-good' } + }); + + const pointerSnapshot = await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-frz001' + }); + assert.equal(pointerSnapshot.snapshotId, 'snap-20260212000000-frz001'); + + const activeBuildLock = await acquireIndexLock({ + repoCacheRoot, + waitMs: 0, + metadata: { + owner: 'build-index', + operation: 'stage3-embeddings' + } + }); + assert.ok(activeBuildLock); + try { + const concurrentFreeze = await freezeSnapshot({ + repoRoot, + userConfig, + snapshotId: pointerSnapshot.snapshotId, + modes: ['code'], + method: 'hardlink', + verify: true, + waitMs: 0 + }); + assert.equal(concurrentFreeze.snapshotId, pointerSnapshot.snapshotId); + } finally { + await activeBuildLock.release(); + } + + const freezeResult = await freezeSnapshot({ + repoRoot, + userConfig, + snapshotId: pointerSnapshot.snapshotId, + modes: ['code'], + method: 'hardlink', + verify: true + }); + assert.equal(freezeResult.alreadyFrozen, true); + assert.equal(freezeResult.retention?.tier ?? 'forensic', 'forensic'); + const frozenManifestAfterConcurrentFreeze = loadSnapshotsManifest(repoCacheRoot); + assert.equal( + frozenManifestAfterConcurrentFreeze.snapshots[pointerSnapshot.snapshotId]?.retention?.tier, + 'forensic' + ); + + const frozenMeta = loadFrozen(repoCacheRoot, pointerSnapshot.snapshotId); + assert.equal(frozenMeta?.verification?.ok, true); + const manifestAfterFreeze = loadSnapshotsManifest(repoCacheRoot); + assert.equal(manifestAfterFreeze.snapshots[pointerSnapshot.snapshotId]?.hasFrozen, true); + await fs.access(path.join(repoCacheRoot, 'snapshots', pointerSnapshot.snapshotId, 'frozen', 'index-code', 'chunk_meta.json')); + + const idempotent = await freezeSnapshot({ + repoRoot, + userConfig, + snapshotId: pointerSnapshot.snapshotId + }); + assert.equal(idempotent.alreadyFrozen, true); + + await fs.rm(goodBuildRoot, { recursive: true, force: true }); + const resolvedFrozen = resolveIndexRef({ + ref: `snap:${pointerSnapshot.snapshotId}`, + repoRoot, + userConfig, + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + assert.ok( + resolvedFrozen.indexDirByMode.code.includes(path.join('snapshots', pointerSnapshot.snapshotId, 'frozen', 'index-code')) + ); + + await seedFreezeBuildRoot({ + repoCacheRoot, + buildId: 'build-freeze-bad', + corruptManifest: true + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-freeze-bad', + buildRoot: 'builds/build-freeze-bad', + buildRoots: { code: 'builds/build-freeze-bad' } + }); + const badSnapshot = await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-frz002' + }); + + const escapeFreezeTarget = path.join(tempRoot, 'freeze-escape-target'); + const escapeFreezeLink = path.join(repoCacheRoot, 'builds', 'build-freeze-escape-link'); + let freezeEscapeLinkCreated = false; + try { + await fs.mkdir(path.join(escapeFreezeTarget, 'index-code'), { recursive: true }); + await fs.symlink(escapeFreezeTarget, escapeFreezeLink, process.platform === 'win32' ? 'junction' : 'dir'); + freezeEscapeLinkCreated = true; + } catch {} + if (freezeEscapeLinkCreated) { + const escapeSnapshot = await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-frz003' + }); + const escapeSnapshotPath = path.join(repoCacheRoot, 'snapshots', escapeSnapshot.snapshotId, 'snapshot.json'); + const escapeSnapshotJson = JSON.parse(await fs.readFile(escapeSnapshotPath, 'utf8')); + escapeSnapshotJson.pointer = { + ...(escapeSnapshotJson.pointer || {}), + buildRootsByMode: { + ...(escapeSnapshotJson.pointer?.buildRootsByMode || {}), + code: 'builds/build-freeze-escape-link' + } + }; + await fs.writeFile(escapeSnapshotPath, `${JSON.stringify(escapeSnapshotJson, null, 2)}\n`, 'utf8'); + + await assert.rejects( + () => freezeSnapshot({ + repoRoot, + userConfig, + snapshotId: escapeSnapshot.snapshotId, + modes: ['code'], + method: 'copy' + }), + /escapes repo cache root/ + ); + } + + await assert.rejects( + () => freezeSnapshot({ + repoRoot, + userConfig, + snapshotId: badSnapshot.snapshotId, + modes: ['code'], + method: 'copy', + verify: true + }), + /Checksum mismatch/ + ); + await assert.rejects(() => fs.stat(path.join(repoCacheRoot, 'snapshots', badSnapshot.snapshotId, 'frozen'))); + assert.equal(loadSnapshotsManifest(repoCacheRoot).snapshots[badSnapshot.snapshotId]?.hasFrozen, false); +}; + +const runIndexDiffCase = async () => { + const { repoRoot, userConfig, repoCacheRoot } = await createSnapshotServiceFixture('index-diff-service'); + + await seedDiffBuildSnapshot({ + repoRoot, + userConfig, + repoCacheRoot, + buildId: 'build-a', + snapshotId: 'snap-20260212000000-diffa', + files: [ + { file: 'src/a.js', content: 'export const a = 1;', chunkId: 'chunk-a' } + ], + chunkSignature: { 'src/a.js': 'sig-a' }, + configHash: 'cfg-shared', + toolVersion: '1.0.0' + }); + + await seedDiffBuildSnapshot({ + repoRoot, + userConfig, + repoCacheRoot, + buildId: 'build-b', + snapshotId: 'snap-20260212000000-diffb', + files: [ + { file: 'src/a.js', content: 'export const a = 2;', chunkId: 'chunk-a' }, + { file: 'src/b.js', content: 'export const b = 1;', chunkId: 'chunk-b' } + ], + chunkSignature: { 'src/a.js': 'sig-b', 'src/b.js': 'sig-new' }, + configHash: 'cfg-shared', + toolVersion: '1.0.0' + }); + + const first = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffb', + modes: ['code'], + includeRelations: false, + persist: true + }); + assert.equal(first.persisted, true); + assert.ok(first.diffId.startsWith('diff_')); + assert.equal(first.retention?.tier, 'forensic'); + assert.ok(Number(first.summary?.totals?.byKind?.['file.modified'] || 0) >= 1); + + const shown = showDiff({ + repoRoot, + userConfig, + diffId: first.diffId, + format: 'jsonl' + }); + assert.ok(Array.isArray(shown.events) && shown.events.length > 0); + const chunkModified = shown.events.find((event) => event.kind === 'chunk.modified'); + assert.ok(chunkModified); + assert.equal(chunkModified.chunkId, 'chunk-a'); + + const second = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffb', + modes: ['code'], + includeRelations: false, + persist: true + }); + assert.equal(second.diffId, first.diffId); + assert.equal(second.reused, true); + + const activeBuildLock = await acquireIndexLock({ + repoCacheRoot, + waitMs: 0, + metadata: { + owner: 'build-index', + operation: 'stage4-promote' + } + }); + assert.ok(activeBuildLock); + try { + const underIndexLock = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffb', + modes: ['code'], + includeRelations: false, + persist: true + }); + assert.equal(underIndexLock.diffId, first.diffId); + } finally { + await activeBuildLock.release(); + } + + const truncated = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffb', + modes: ['code'], + includeRelations: false, + persist: false, + maxEvents: 1 + }); + assert.equal(truncated.summary.truncated, true); + assert.equal(Array.isArray(truncated.events) ? truncated.events.length : 0, 1); + + await seedDiffBuild({ + repoCacheRoot, + buildId: 'build-c', + files: [ + { file: 'src/a.js', content: 'export const a = 3;', chunkId: 'chunk-a' } + ], + chunkSignature: { 'src/a.js': 'sig-c' }, + configHash: 'cfg-different', + toolVersion: '1.0.0' + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-c', + buildRoot: 'builds/build-c', + buildRoots: { code: 'builds/build-c' } + }); + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-diffc' + }); + + await assert.rejects( + () => computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffc', + modes: ['code'], + persist: false + }), + /configHash mismatch/ + ); + + const mismatchAllowed = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffc', + modes: ['code'], + allowMismatch: true, + persist: false + }); + assert.equal(mismatchAllowed.summary.compat.configHashMismatch, true); + + await seedDiffBuild({ + repoCacheRoot, + buildId: 'build-d', + files: [ + { file: 'src/a.js', content: 'export const a = 4;', chunkId: 'chunk-a' } + ], + chunkSignature: { 'src/a.js': 'sig-d' }, + configHash: 'cfg-shared', + toolVersion: '9.9.9' + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-d', + buildRoot: 'builds/build-d', + buildRoots: { code: 'builds/build-d' } + }); + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-diffd' + }); + + const toolMismatch = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffd', + modes: ['code'], + persist: false + }); + assert.equal(toolMismatch.summary.compat.toolVersionMismatch, true); + + const pinnedDiff = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffd', + modes: ['code'], + allowMismatch: true, + persist: true, + retentionTier: 'pinned' + }); + assert.equal(pinnedDiff.retention?.tier, 'pinned'); + + const cacheDiff = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffa', + to: 'snap:snap-20260212000000-diffd', + modes: ['code'], + allowMismatch: true, + persist: true, + retentionTier: 'cache', + includeRelations: false + }); + assert.equal(cacheDiff.retention?.tier, 'cache'); + + const agedDiffManifest = loadDiffsManifest(repoCacheRoot); + agedDiffManifest.diffs[cacheDiff.diffId] = { + ...agedDiffManifest.diffs[cacheDiff.diffId], + createdAt: '2025-01-01T00:00:00.000Z' + }; + agedDiffManifest.updatedAt = new Date().toISOString(); + await writeDiffsManifest(repoCacheRoot, agedDiffManifest); + + const pruneResult = await pruneDiffs({ + repoRoot, + userConfig, + maxDiffs: 0, + retainDays: 30, + dryRun: true + }); + assert.ok(pruneResult.decisions.some((entry) => entry.diffId === first.diffId && entry.reason === 'max_age')); + assert.ok(pruneResult.decisions.some((entry) => entry.diffId === pinnedDiff.diffId && entry.reason === 'pinned')); + assert.ok(pruneResult.decisions.some((entry) => entry.diffId === cacheDiff.diffId && entry.action === 'remove')); + + await seedDiffBuild({ + repoCacheRoot, + buildId: 'build-e', + files: [], + chunkSignature: {}, + configHash: 'cfg-shared', + toolVersion: '1.0.0', + fileMetaRows: [ + { + id: 1, + file: 'src/many.js', + hash: sha1Value('export const many = 1;'), + size: 'export const many = 1;'.length, + ext: 'js' + } + ], + chunkMetaRows: [ + { + id: 0, + fileId: 1, + file: 'src/many.js', + start: 0, + end: 22, + startLine: 1, + endLine: 1, + kind: 'function', + name: 'many-a', + chunkId: 'many-chunk-a', + metaV2: { + chunkId: 'many-chunk-a', + chunkUid: 'ck:build-e:many-chunk-a', + signature: 'sig-many-e-a', + virtualPath: 'src/many.js', + file: 'src/many.js' + } + } + ] + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-e', + buildRoot: 'builds/build-e', + buildRoots: { code: 'builds/build-e' } + }); + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-diffe' + }); + + await seedDiffBuild({ + repoCacheRoot, + buildId: 'build-f', + files: [], + chunkSignature: {}, + configHash: 'cfg-shared', + toolVersion: '1.0.0', + fileMetaRows: [ + { + id: 1, + file: 'src/many.js', + hash: sha1Value('export const many = 2;'), + size: 'export const many = 2;'.length, + ext: 'js' + } + ], + chunkMetaRows: [ + { + id: 0, + fileId: 1, + file: 'src/many.js', + start: 0, + end: 11, + startLine: 1, + endLine: 1, + kind: 'function', + name: 'many-a', + chunkId: 'many-chunk-a', + metaV2: { + chunkId: 'many-chunk-a', + chunkUid: 'ck:build-f:many-chunk-a', + signature: 'sig-many-f-a', + virtualPath: 'src/many.js', + file: 'src/many.js' + } + }, + { + id: 1, + fileId: 1, + file: 'src/many.js', + start: 11, + end: 22, + startLine: 1, + endLine: 1, + kind: 'function', + name: 'many-b', + chunkId: 'many-chunk-b', + metaV2: { + chunkId: 'many-chunk-b', + chunkUid: 'ck:build-f:many-chunk-b', + signature: 'sig-many-f-b', + virtualPath: 'src/many.js', + file: 'src/many.js' + } + } + ] + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId: 'build-f', + buildRoot: 'builds/build-f', + buildRoots: { code: 'builds/build-f' } + }); + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: 'snap-20260212000000-difff' + }); + + const chunkLimited = await computeIndexDiff({ + repoRoot, + userConfig, + from: 'snap:snap-20260212000000-diffe', + to: 'snap:snap-20260212000000-difff', + modes: ['code'], + includeRelations: false, + maxChunksPerFile: 1, + persist: false + }); + assert.equal(chunkLimited.summary?.modesSummary?.code?.limits?.chunkDiffSkipped, true); + assert.equal(chunkLimited.summary?.modesSummary?.code?.limits?.reason, 'max-chunks-per-file'); +}; + +await runSnapshotCreateCase(); +await runSnapshotFreezeCase(); +await runIndexDiffCase(); + +console.log('snapshot core contract matrix test passed'); diff --git a/tests/services/snapshot-create.test.js b/tests/services/snapshot-create.test.js deleted file mode 100644 index 234f62703..000000000 --- a/tests/services/snapshot-create.test.js +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../src/shared/dict-utils.js'; -import { - createPointerSnapshot, - listSnapshots, - pruneSnapshots, - removeSnapshot, - showSnapshot -} from '../../src/index/snapshots/create.js'; -import { loadSnapshot, loadSnapshotsManifest } from '../../src/index/snapshots/registry.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'snapshot-create-service'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const userConfig = { cache: { root: cacheRoot } }; - -const writeJson = async (filePath, value) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); -}; - -const writeBuildState = async ({ - repoCacheRoot, - buildId, - validationOk = true, - includeValidation = true -}) => { - const buildRoot = path.join(repoCacheRoot, 'builds', buildId); - const payload = { - schemaVersion: 1, - buildId, - configHash: `cfg-${buildId}`, - tool: { version: '1.0.0' }, - repo: { provider: 'git', branch: 'main', commit: 'abc123', dirty: false } - }; - if (includeValidation) { - payload.validation = { ok: validationOk, issueCount: 0, warningCount: 0, issues: [] }; - } - await writeJson(path.join(buildRoot, 'build_state.json'), payload); - return buildRoot; -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const buildsRoot = path.join(repoCacheRoot, 'builds'); -await fs.mkdir(buildsRoot, { recursive: true }); - -const buildCodeRoot = await writeBuildState({ - repoCacheRoot, - buildId: 'build-code', - validationOk: true -}); -const buildProseRoot = await writeBuildState({ - repoCacheRoot, - buildId: 'build-prose', - validationOk: true -}); -await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'build-code', - buildRoot: 'builds/build-code', - buildRoots: { - code: 'builds/build-code', - prose: 'builds/build-prose' - } -}); - -const firstSnapshot = await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - tags: ['release'], - snapshotId: 'snap-20260212000000-aa0001' -}); -assert.equal(firstSnapshot.snapshotId, 'snap-20260212000000-aa0001'); -assert.deepEqual(firstSnapshot.modes, ['code']); - -const manifestAfterFirst = loadSnapshotsManifest(repoCacheRoot); -assert.ok(manifestAfterFirst.snapshots[firstSnapshot.snapshotId], 'snapshot entry should be in manifest'); -const firstSnapshotJson = loadSnapshot(repoCacheRoot, firstSnapshot.snapshotId); -assert.deepEqual(Object.keys(firstSnapshotJson.pointer.buildRootsByMode), ['code']); -assert.equal(firstSnapshotJson.pointer.buildRootsByMode.code, 'builds/build-code'); - -const escapeTargetRoot = path.join(tempRoot, 'snapshot-escape-target'); -const escapeLinkRoot = path.join(buildsRoot, 'build-escape-link'); -let escapeLinkCreated = false; -try { - await fs.mkdir(escapeTargetRoot, { recursive: true }); - await writeJson(path.join(escapeTargetRoot, 'build_state.json'), { - schemaVersion: 1, - buildId: 'escape-build', - configHash: 'cfg-escape-build', - tool: { version: '1.0.0' }, - validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } - }); - await fs.symlink(escapeTargetRoot, escapeLinkRoot, process.platform === 'win32' ? 'junction' : 'dir'); - escapeLinkCreated = true; -} catch {} -if (escapeLinkCreated) { - await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'escape-build', - buildRoot: 'builds/build-escape-link', - buildRoots: { code: 'builds/build-escape-link' } - }); - await assert.rejects( - () => createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-aa0002' - }), - /escapes repo cache root/, - 'snapshot create should reject symlinked build roots that escape repo cache' - ); -} - -const badBuildRoot = await writeBuildState({ - repoCacheRoot, - buildId: 'build-invalid', - validationOk: false -}); -await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'build-invalid', - buildRoot: 'builds/build-invalid', - buildRoots: { code: 'builds/build-invalid' } -}); -assert.ok(badBuildRoot); -await assert.rejects( - () => createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-aa0002' - }), - /validation\.ok === true/, - 'snapshot create should fail when validation.ok is false' -); - -const missingValidationRoot = await writeBuildState({ - repoCacheRoot, - buildId: 'build-missing-validation', - includeValidation: false -}); -await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'build-missing-validation', - buildRoot: 'builds/build-missing-validation', - buildRoots: { code: 'builds/build-missing-validation' } -}); -assert.ok(missingValidationRoot); -await assert.rejects( - () => createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-aa0003' - }), - /validation\.ok === true/, - 'snapshot create should fail when validation block is missing' -); - -await writeJson(path.join(buildsRoot, 'current.json'), { - buildId: 'build-code', - buildRoot: 'builds/build-code', - buildRoots: { code: 'builds/build-code' } -}); -const retentionIds = [ - 'snap-20260212000000-aa0004', - 'snap-20260212000000-aa0005', - 'snap-20260212000000-aa0006' -]; -for (const snapshotId of retentionIds) { - await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId, - maxPointerSnapshots: 2 - }); -} - -const manifestAfterRetention = loadSnapshotsManifest(repoCacheRoot); -const allEntries = Object.values(manifestAfterRetention.snapshots || {}); -const untaggedPointers = allEntries.filter((entry) => ( - entry.kind === 'pointer' && (!Array.isArray(entry.tags) || entry.tags.length === 0) -)); -assert.equal(untaggedPointers.length, 2, 'retention should keep only two untagged pointer snapshots'); -assert.ok( - manifestAfterRetention.snapshots['snap-20260212000000-aa0001'], - 'retention should keep tagged snapshots' -); -assert.ok( - !manifestAfterRetention.snapshots['snap-20260212000000-aa0004'], - 'retention should prune oldest untagged snapshot' -); -await assert.rejects( - () => fs.stat(path.join(repoCacheRoot, 'snapshots', 'snap-20260212000000-aa0004')), - 'pruned snapshot directory should be deleted' -); -const listedSnapshots = listSnapshots({ repoRoot, userConfig }); -assert.ok(listedSnapshots.length >= 3, 'list should include retained snapshots'); -const shownSnapshot = showSnapshot({ - repoRoot, - userConfig, - snapshotId: 'snap-20260212000000-aa0001' -}); -assert.ok(shownSnapshot?.entry, 'show should return manifest entry'); - -const dryRunPrune = await pruneSnapshots({ - repoRoot, - userConfig, - maxPointerSnapshots: 1, - dryRun: true -}); -assert.ok(Array.isArray(dryRunPrune.removed), 'prune should return removed ids'); - -const removableId = untaggedPointers.find((entry) => entry.snapshotId)?.snapshotId; -assert.ok(removableId, 'expected at least one removable untagged snapshot'); -await removeSnapshot({ - repoRoot, - userConfig, - snapshotId: removableId, - force: true -}); -const afterRemoveManifest = loadSnapshotsManifest(repoCacheRoot); -assert.ok(!afterRemoveManifest.snapshots[removableId], 'rm should remove snapshot from manifest'); - -assert.ok(buildCodeRoot); -assert.ok(buildProseRoot); - -console.log('snapshot create service test passed'); diff --git a/tests/services/snapshot-freeze.test.js b/tests/services/snapshot-freeze.test.js deleted file mode 100644 index e015f0c0a..000000000 --- a/tests/services/snapshot-freeze.test.js +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../src/shared/dict-utils.js'; -import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; -import { freezeSnapshot } from '../../src/index/snapshots/freeze.js'; -import { resolveIndexRef } from '../../src/index/index-ref.js'; -import { loadFrozen, loadSnapshotsManifest } from '../../src/index/snapshots/registry.js'; -import { replaceDir } from '../../src/shared/json-stream/atomic.js'; -import { createBaseIndex } from '../indexing/validate/helpers.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'snapshot-freeze-service'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const userConfig = { - cache: { root: cacheRoot }, - sqlite: { use: false }, - lmdb: { use: false } -}; - -const writeJson = async (filePath, value) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); -}; - -const sha1File = async (filePath) => { - const data = await fs.readFile(filePath); - return crypto.createHash('sha1').update(data).digest('hex'); -}; - -const enrichPiecesManifestChecksums = async (indexDir, { corruptFirst = false } = {}) => { - const manifestPath = path.join(indexDir, 'pieces', 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); - const pieces = Array.isArray(manifest.pieces) ? manifest.pieces : []; - for (let i = 0; i < pieces.length; i += 1) { - const piece = pieces[i]; - const filePath = path.join(indexDir, piece.path); - const stat = await fs.stat(filePath); - piece.bytes = Number(stat.size || 0); - const hash = await sha1File(filePath); - piece.checksum = `sha1:${hash}`; - } - if (corruptFirst && pieces.length) { - pieces[0].checksum = 'sha1:0000000000000000000000000000000000000000'; - } - await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); -}; - -const seedBuildRoot = async ({ - repoCacheRoot, - buildId, - corruptManifest = false -}) => { - const buildRoot = path.join(repoCacheRoot, 'builds', buildId); - await fs.mkdir(buildRoot, { recursive: true }); - const { indexDir } = await createBaseIndex({ rootDir: buildRoot }); - const modeDir = path.join(buildRoot, 'index-code'); - await fs.mkdir(path.dirname(modeDir), { recursive: true }); - await replaceDir(indexDir, modeDir); - await fs.rm(path.join(buildRoot, '.index-root'), { recursive: true, force: true }); - await enrichPiecesManifestChecksums(modeDir, { corruptFirst: corruptManifest }); - await writeJson(path.join(buildRoot, 'build_state.json'), { - schemaVersion: 1, - buildId, - configHash: `cfg-${buildId}`, - tool: { version: '1.0.0' }, - validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } - }); - return buildRoot; -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - -const goodBuildRoot = await seedBuildRoot({ - repoCacheRoot, - buildId: 'build-freeze-good', - corruptManifest: false -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-freeze-good', - buildRoot: 'builds/build-freeze-good', - buildRoots: { code: 'builds/build-freeze-good' } -}); - -const pointerSnapshot = await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-frz001' -}); -assert.equal(pointerSnapshot.snapshotId, 'snap-20260212000000-frz001'); - -const freezeResult = await freezeSnapshot({ - repoRoot, - userConfig, - snapshotId: pointerSnapshot.snapshotId, - modes: ['code'], - method: 'hardlink', - verify: true -}); -assert.equal(freezeResult.alreadyFrozen, false, 'first freeze should materialize frozen data'); -assert.equal(freezeResult.verificationOk, true, 'freeze verification should pass'); -assert.ok(Number.isFinite(freezeResult.filesChecked), 'freeze should report verification file count'); - -const frozenMeta = loadFrozen(repoCacheRoot, pointerSnapshot.snapshotId); -assert.equal(frozenMeta?.verification?.ok, true, 'frozen.json must record successful verification'); -const manifestAfterFreeze = loadSnapshotsManifest(repoCacheRoot); -assert.equal( - manifestAfterFreeze.snapshots[pointerSnapshot.snapshotId]?.hasFrozen, - true, - 'manifest entry must be marked hasFrozen=true' -); -await fs.access(path.join(repoCacheRoot, 'snapshots', pointerSnapshot.snapshotId, 'frozen', 'index-code', 'chunk_meta.json')); - -const idempotent = await freezeSnapshot({ - repoRoot, - userConfig, - snapshotId: pointerSnapshot.snapshotId -}); -assert.equal(idempotent.alreadyFrozen, true, 'second freeze should be idempotent'); - -await fs.rm(goodBuildRoot, { recursive: true, force: true }); -const resolvedFrozen = resolveIndexRef({ - ref: `snap:${pointerSnapshot.snapshotId}`, - repoRoot, - userConfig, - requestedModes: ['code'], - preferFrozen: true, - allowMissingModes: false -}); -assert.ok( - resolvedFrozen.indexDirByMode.code.includes(path.join('snapshots', pointerSnapshot.snapshotId, 'frozen', 'index-code')), - 'resolved snapshot should use frozen roots after source build is removed' -); - -const badBuildRoot = await seedBuildRoot({ - repoCacheRoot, - buildId: 'build-freeze-bad', - corruptManifest: true -}); -await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { - buildId: 'build-freeze-bad', - buildRoot: 'builds/build-freeze-bad', - buildRoots: { code: 'builds/build-freeze-bad' } -}); -const badSnapshot = await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-frz002' -}); - -const escapeFreezeTarget = path.join(tempRoot, 'freeze-escape-target'); -const escapeFreezeLink = path.join(repoCacheRoot, 'builds', 'build-freeze-escape-link'); -let freezeEscapeLinkCreated = false; -try { - await fs.mkdir(path.join(escapeFreezeTarget, 'index-code'), { recursive: true }); - await fs.symlink(escapeFreezeTarget, escapeFreezeLink, process.platform === 'win32' ? 'junction' : 'dir'); - freezeEscapeLinkCreated = true; -} catch {} -if (freezeEscapeLinkCreated) { - const escapeSnapshot = await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: 'snap-20260212000000-frz003' - }); - const escapeSnapshotPath = path.join(repoCacheRoot, 'snapshots', escapeSnapshot.snapshotId, 'snapshot.json'); - const escapeSnapshotJson = JSON.parse(await fs.readFile(escapeSnapshotPath, 'utf8')); - escapeSnapshotJson.pointer = { - ...(escapeSnapshotJson.pointer || {}), - buildRootsByMode: { - ...(escapeSnapshotJson.pointer?.buildRootsByMode || {}), - code: 'builds/build-freeze-escape-link' - } - }; - await fs.writeFile(escapeSnapshotPath, `${JSON.stringify(escapeSnapshotJson, null, 2)}\n`, 'utf8'); - - await assert.rejects( - () => freezeSnapshot({ - repoRoot, - userConfig, - snapshotId: escapeSnapshot.snapshotId, - modes: ['code'], - method: 'copy' - }), - /escapes repo cache root/, - 'freeze should reject pointer roots that escape via symlink' - ); -} - -await assert.rejects( - () => freezeSnapshot({ - repoRoot, - userConfig, - snapshotId: badSnapshot.snapshotId, - modes: ['code'], - method: 'copy', - verify: true - }), - /Checksum mismatch/, - 'freeze should fail when copied pieces fail checksum verification' -); -await assert.rejects( - () => fs.stat(path.join(repoCacheRoot, 'snapshots', badSnapshot.snapshotId, 'frozen')), - 'failed freeze must not finalize frozen directory' -); -assert.equal( - loadSnapshotsManifest(repoCacheRoot).snapshots[badSnapshot.snapshotId]?.hasFrozen, - false, - 'failed freeze should keep hasFrozen=false' -); - -assert.ok(badBuildRoot); - -console.log('snapshot freeze service test passed'); diff --git a/tests/services/snapshot-query.test.js b/tests/services/snapshot-query.test.js deleted file mode 100644 index 7dbcb03c0..000000000 --- a/tests/services/snapshot-query.test.js +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { runSearchCli } from '../../src/retrieval/cli.js'; -import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; -import { loadUserConfig } from '../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'snapshot-query-service'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); -await fs.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - embeddings: { - enabled: false, - mode: 'off', - lancedb: { enabled: false }, - hnsw: { enabled: false } - } - } - }, - extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } -}); - -const runBuild = () => { - const result = spawnSync( - process.execPath, - [ - path.join(root, 'build_index.js'), - '--repo', - repoRoot, - '--mode', - 'code', - '--stub-embeddings', - '--no-sqlite', - '--progress', - 'off' - ], - { - cwd: repoRoot, - env, - encoding: 'utf8' - } - ); - if (result.status !== 0) { - throw new Error(`build_index failed: ${result.stderr || result.stdout || 'unknown error'}`); - } -}; - -const markerPath = path.join(repoRoot, 'src', 'phase14-snapshot-query.js'); -await fs.mkdir(path.dirname(markerPath), { recursive: true }); -await fs.writeFile(markerPath, 'export const phase14_marker = "phase14alpha";\n', 'utf8'); - -runBuild(); - -const userConfig = loadUserConfig(repoRoot); - -const snapshotA = 'snap-20260212000000-snapqa'; -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: snapshotA -}); - -await fs.writeFile(markerPath, 'export const phase14_marker = "phase14beta";\n', 'utf8'); -runBuild(); - -const snapshotB = 'snap-20260212000000-snapqb'; -await createPointerSnapshot({ - repoRoot, - userConfig, - modes: ['code'], - snapshotId: snapshotB -}); - -const searchA = await runSearchCli([ - '--repo', - repoRoot, - '--mode', - 'code', - '--backend', - 'memory', - '--top', - '50', - '--json', - '--compact', - '--snapshot', - snapshotA, - '--', - 'phase14alpha' -], { - emitOutput: false, - exitOnError: false -}); - -assert.equal(searchA.asOf?.ref, `snap:${snapshotA}`, 'snapshot alias should normalize to as-of snap:'); -assert.ok( - Array.isArray(searchA.code) - && searchA.code.some((hit) => String(hit.file || '').includes('phase14-snapshot-query.js')), - 'snapshot A query should find the alpha marker' -); -const snapshotAHit = searchA.code.find((hit) => String(hit.file || '').includes('phase14-snapshot-query.js')); - -const searchB = await runSearchCli([ - '--repo', - repoRoot, - '--mode', - 'code', - '--backend', - 'memory', - '--top', - '50', - '--json', - '--compact', - '--snapshot', - snapshotB, - '--', - 'phase14alpha' -], { - emitOutput: false, - exitOnError: false -}); - -const snapshotBHit = Array.isArray(searchB.code) - ? searchB.code.find((hit) => String(hit.file || '').includes('phase14-snapshot-query.js')) - : null; -assert.ok(snapshotBHit, 'snapshot B query should still resolve marker file in its own snapshot state'); -assert.notEqual( - snapshotAHit?.end ?? null, - snapshotBHit?.end ?? null, - 'snapshot A and B should yield different chunk bounds for the same query' -); - -const latest = await runSearchCli([ - '--repo', - repoRoot, - '--mode', - 'code', - '--backend', - 'memory', - '--top', - '50', - '--json', - '--compact', - '--', - 'phase14beta' -], { - emitOutput: false, - exitOnError: false -}); - -assert.equal(latest.asOf?.ref, 'latest', 'latest should remain the default as-of ref'); -const latestHit = Array.isArray(latest.code) - ? latest.code.find((hit) => String(hit.file || '').includes('phase14-snapshot-query.js')) - : null; -assert.ok( - Array.isArray(latest.code) - && latest.code.some((hit) => String(hit.file || '').includes('phase14-snapshot-query.js')), - 'latest query should resolve to current build without snapshot flag' -); -assert.equal(latestHit?.end ?? null, snapshotBHit?.end ?? null, 'latest should match snapshot B (current build)'); - -console.log('snapshot query service test passed'); diff --git a/tests/services/snapshot-ref-contract-matrix.test.js b/tests/services/snapshot-ref-contract-matrix.test.js new file mode 100644 index 000000000..768088b95 --- /dev/null +++ b/tests/services/snapshot-ref-contract-matrix.test.js @@ -0,0 +1,250 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { acquireIndexLock } from '../../src/index/build/lock.js'; +import { resolveIndexRef } from '../../src/index/index-ref.js'; +import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../tools/shared/dict-utils.js'; +import { loadChunkMeta } from '../../src/shared/artifact-io.js'; +import { runSearchCli } from '../../src/retrieval/cli.js'; + +import { ensureFixtureIndex } from '../helpers/fixture-index.js'; +import { + seedCodeSnapshotBuildRoot, + setCurrentCodeSnapshotBuild +} from '../helpers/snapshot-build-fixture.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const runSnapshotQueryCase = async () => { + const tempRoot = resolveTestCachePath(process.cwd(), 'snapshot-query-service'); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(repoRoot, { recursive: true }); + + applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + } + }, + extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } + }); + + const seedBuildRoot = async ({ + repoCacheRoot, + buildId, + token, + end + }) => seedCodeSnapshotBuildRoot({ + repoCacheRoot, + buildId, + chunkMeta: [ + { + id: 0, + file: 'src/phase14-snapshot-query.js', + start: 0, + end, + text: `export const phase14_marker = "${token}";` + } + ], + fileMeta: [ + { + id: 0, + file: 'src/phase14-snapshot-query.js', + ext: '.js' + } + ], + tokenPostings: { + vocab: [token], + postings: [ + [[0, 1]] + ], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + } + }); + + const markerPath = path.join(repoRoot, 'src', 'phase14-snapshot-query.js'); + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, 'export const phase14_marker = "alpha";\n', 'utf8'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); + + await seedBuildRoot({ + repoCacheRoot, + buildId: 'build-alpha', + token: 'alpha', + end: 38 + }); + await setCurrentCodeSnapshotBuild({ repoCacheRoot, buildId: 'build-alpha' }); + + const snapshotA = 'snap-20260212000000-snapqa'; + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: snapshotA + }); + + await fs.writeFile(markerPath, 'export const phase14_marker = "beta";\n', 'utf8'); + await seedBuildRoot({ + repoCacheRoot, + buildId: 'build-beta', + token: 'beta', + end: 37 + }); + await setCurrentCodeSnapshotBuild({ repoCacheRoot, buildId: 'build-beta' }); + + const snapshotB = 'snap-20260212000000-snapqb'; + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId: snapshotB + }); + + const resolvedA = resolveIndexRef({ + ref: `snap:${snapshotA}`, + repoRoot, + userConfig, + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + assert.equal(resolvedA.canonical, `snap:${snapshotA}`); + assert.equal(resolvedA.identity?.snapshotId, snapshotA); + const chunkMetaA = await loadChunkMeta(resolvedA.indexDirByMode.code, { strict: false }); + assert.equal(chunkMetaA[0]?.file, 'src/phase14-snapshot-query.js'); + assert.equal(chunkMetaA[0]?.end, 38); + + const activeBuildLock = await acquireIndexLock({ + repoCacheRoot, + waitMs: 0, + metadata: { + owner: 'build-index', + operation: 'stage4-promote' + } + }); + assert.ok(activeBuildLock); + + const resolvedAWhileLocked = resolveIndexRef({ + ref: `snap:${snapshotA}`, + repoRoot, + userConfig, + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + const chunkMetaAWhileLocked = await loadChunkMeta(resolvedAWhileLocked.indexDirByMode.code, { strict: false }); + assert.equal(chunkMetaAWhileLocked[0]?.end, 38); + await activeBuildLock.release(); + + const resolvedB = resolveIndexRef({ + ref: `snap:${snapshotB}`, + repoRoot, + userConfig, + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + const chunkMetaB = await loadChunkMeta(resolvedB.indexDirByMode.code, { strict: false }); + assert.equal(chunkMetaB[0]?.end, 37); + assert.notEqual(chunkMetaA[0]?.end ?? null, chunkMetaB[0]?.end ?? null); + + const latest = resolveIndexRef({ + ref: 'latest', + repoRoot, + userConfig, + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + const latestChunkMeta = await loadChunkMeta(latest.indexDirByMode.code, { strict: false }); + assert.equal(latest.canonical, 'latest'); + assert.equal(latestChunkMeta[0]?.end, chunkMetaB[0]?.end); +}; + +const runExplicitRootNoFallbackCase = async () => { + applyTestEnv(); + + const root = process.cwd(); + const cacheName = 'asof-explicit-root-no-fallback'; + const cacheRoot = resolveTestCachePath(root, cacheName); + await fs.rm(cacheRoot, { recursive: true, force: true }); + + const { fixtureRoot } = await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName, + cacheScope: 'shared' + }); + const userConfig = loadUserConfig(fixtureRoot); + const repoCacheRoot = getRepoCacheRoot(fixtureRoot, userConfig); + + const snapshotId = 'snap-20260212000000-nofb01'; + await createPointerSnapshot({ + repoRoot: fixtureRoot, + userConfig, + modes: ['code'], + snapshotId + }); + + const snapshotPath = path.join(repoCacheRoot, 'snapshots', snapshotId, 'snapshot.json'); + const snapshotJson = JSON.parse(await fs.readFile(snapshotPath, 'utf8')); + snapshotJson.pointer = snapshotJson.pointer || {}; + snapshotJson.pointer.buildRootsByMode = snapshotJson.pointer.buildRootsByMode || {}; + snapshotJson.pointer.buildRootsByMode.code = 'builds/missing-build-root'; + snapshotJson.pointer.buildRoot = 'builds/missing-build-root'; + await fs.writeFile(snapshotPath, `${JSON.stringify(snapshotJson, null, 2)}\n`, 'utf8'); + + await assert.rejects( + () => runSearchCli([ + '--repo', + fixtureRoot, + '--mode', + 'code', + '--backend', + 'memory', + '--json', + '--compact', + '--as-of', + `snap:${snapshotId}`, + '--', + 'return' + ], { emitOutput: false, exitOnError: false }), + /missing build root/i + ); + + const latest = await runSearchCli([ + '--repo', + fixtureRoot, + '--mode', + 'code', + '--backend', + 'memory', + '--json', + '--compact', + '--', + 'return' + ], { emitOutput: false, exitOnError: false }); + + assert.ok(Array.isArray(latest.code) && latest.code.length > 0); +}; + +await runSnapshotQueryCase(); +await runExplicitRootNoFallbackCase(); + +console.log('snapshot ref contract matrix test passed'); diff --git a/tests/services/snapshot-retention-governance.test.js b/tests/services/snapshot-retention-governance.test.js new file mode 100644 index 000000000..f9dade060 --- /dev/null +++ b/tests/services/snapshot-retention-governance.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { gcSnapshots } from '../../src/index/snapshots/freeze.js'; +import { getRepoCacheRoot } from '../../src/shared/dict-utils.js'; +import { writeSnapshotsManifest } from '../../src/index/snapshots/registry.js'; + +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'snapshot-retention-governance'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const userConfig = { cache: { root: cacheRoot } }; +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const snapshotsRoot = path.join(repoCacheRoot, 'snapshots'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(snapshotsRoot, { recursive: true }); + +const oldCreatedAt = '2025-01-01T00:00:00.000Z'; +const freshCreatedAt = new Date().toISOString(); +const manifest = { + version: 1, + updatedAt: freshCreatedAt, + snapshots: { + 'snap-20260101000000-cache01': { + snapshotId: 'snap-20260101000000-cache01', + createdAt: oldCreatedAt, + kind: 'pointer', + tags: [], + hasFrozen: false, + retention: { + tier: 'cache', + reason: 'cache_default' + } + }, + 'snap-20260101000000-frz001': { + snapshotId: 'snap-20260101000000-frz001', + createdAt: freshCreatedAt, + kind: 'pointer', + tags: [], + hasFrozen: true, + retention: { + tier: 'forensic', + reason: 'frozen_snapshot' + } + }, + 'snap-20260101000000-pin001': { + snapshotId: 'snap-20260101000000-pin001', + createdAt: oldCreatedAt, + kind: 'pointer', + tags: ['release/v1.0.0'], + hasFrozen: false, + retention: { + tier: 'pinned', + reason: 'tagged' + } + } + }, + tags: { + 'release/v1.0.0': ['snap-20260101000000-pin001'] + } +}; +await writeSnapshotsManifest(repoCacheRoot, manifest); +for (const snapshotId of Object.keys(manifest.snapshots)) { + await fs.mkdir(path.join(snapshotsRoot, snapshotId), { recursive: true }); +} + +const dryRun = await gcSnapshots({ + repoRoot, + userConfig, + keepPointer: 0, + keepFrozen: 0, + keepTags: 'release/*', + maxAgeDays: 30, + dryRun: true +}); + +assert.deepEqual( + dryRun.removed, + ['snap-20260101000000-cache01'], + 'cache-like snapshots should be the only dry-run removal in this governance shape' +); +assert.ok( + dryRun.decisions.some((entry) => ( + entry.snapshotId === 'snap-20260101000000-frz001' + && entry.action === 'keep' + && entry.reason === 'max_age' + )), + 'forensic snapshots should remain retained by the forensic age policy' +); +assert.ok( + dryRun.decisions.some((entry) => ( + entry.snapshotId === 'snap-20260101000000-pin001' + && entry.action === 'keep' + && entry.reason === 'tag_pattern' + )), + 'tagged pinned snapshots should remain protected with an explicit reason' +); +assert.ok( + dryRun.decisions.some((entry) => ( + entry.snapshotId === 'snap-20260101000000-cache01' + && entry.action === 'remove' + && entry.reason === 'age_and_pointer_budget' + )), + 'cache-like snapshots should report deterministic prune reasons' +); + +console.log('snapshot retention governance test passed'); diff --git a/tests/services/snapshots/concurrent-registry-writers.test.js b/tests/services/snapshots/concurrent-registry-writers.test.js index aabeaf9a9..128caa82f 100644 --- a/tests/services/snapshots/concurrent-registry-writers.test.js +++ b/tests/services/snapshots/concurrent-registry-writers.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { acquireIndexLock } from '../../../src/index/build/lock.js'; +import { acquireRegistryLock } from '../../../src/index/registry-lock.js'; import { loadSnapshotsManifest, writeSnapshotsManifest @@ -61,32 +61,38 @@ const diffsManifestB = { diffs: {} }; -const lock = await acquireIndexLock({ repoCacheRoot, waitMs: 0 }); -assert.ok(lock, 'expected to acquire index lock for contention test'); +const snapshotLock = await acquireRegistryLock({ repoCacheRoot, domain: 'snapshots', waitMs: 0 }); +assert.ok(snapshotLock, 'expected to acquire snapshots lock for contention test'); try { - await writeSnapshotsManifest(repoCacheRoot, snapshotsManifestA, { lock }); + await writeSnapshotsManifest(repoCacheRoot, snapshotsManifestA, { lock: snapshotLock }); await assert.rejects( () => writeSnapshotsManifest(repoCacheRoot, snapshotsManifestB, { waitMs: 0 }), (error) => error?.code === 'QUEUE_OVERLOADED', - 'concurrent snapshot writer should fail fast while lock is held' + 'concurrent snapshot writer should fail fast while snapshots lock is held' ); const snapshotManifestPath = path.join(repoCacheRoot, 'snapshots', 'manifest.json'); await fs.writeFile(`${snapshotManifestPath}.tmp-interrupted`, '{broken', 'utf8'); const loadedSnapshots = loadSnapshotsManifest(repoCacheRoot); assert.equal(loadedSnapshots.updatedAt, snapshotsManifestA.updatedAt); +} finally { + await snapshotLock.release(); +} - await writeDiffsManifest(repoCacheRoot, diffsManifestA, { lock }); +const diffLock = await acquireRegistryLock({ repoCacheRoot, domain: 'diffs', waitMs: 0 }); +assert.ok(diffLock, 'expected to acquire diffs lock for contention test'); +try { + await writeDiffsManifest(repoCacheRoot, diffsManifestA, { lock: diffLock }); await assert.rejects( () => writeDiffsManifest(repoCacheRoot, diffsManifestB, { waitMs: 0 }), (error) => error?.code === 'QUEUE_OVERLOADED', - 'concurrent diff writer should fail fast while lock is held' + 'concurrent diff writer should fail fast while diff lock is held' ); const diffManifestPath = path.join(repoCacheRoot, 'diffs', 'manifest.json'); await fs.writeFile(`${diffManifestPath}.tmp-interrupted`, '{broken', 'utf8'); const loadedDiffs = loadDiffsManifest(repoCacheRoot); assert.equal(loadedDiffs.updatedAt, diffsManifestA.updatedAt); } finally { - await lock.release(); + await diffLock.release(); } console.log('concurrent registry writers test passed'); diff --git a/tests/services/soak/operational-recovery.test.js b/tests/services/soak/operational-recovery.test.js new file mode 100644 index 000000000..11951aa5b --- /dev/null +++ b/tests/services/soak/operational-recovery.test.js @@ -0,0 +1,439 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { acquireIndexLock } from '../../../src/index/build/lock.js'; +import { resolveIndexRef } from '../../../src/index/index-ref.js'; +import { createPointerSnapshot } from '../../../src/index/snapshots/create.js'; +import { loadChunkMeta } from '../../../src/shared/artifact-io.js'; +import { replaceDir } from '../../../src/shared/json-stream/atomic.js'; +import { + claimNextJob, + completeJob, + enqueueJob, + ensureQueueDir, + loadQueue, + loadQuarantine, + quarantineSummary, + queueSummary, + requeueStaleJobs, + retryQuarantinedJob, + saveQueue +} from '../../../tools/service/queue.js'; +import { collectEmbeddingReplayState, repairEmbeddingReplayState } from '../../../tools/service/embedding-replay.js'; +import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { createBaseIndex } from '../../indexing/validate/helpers.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'services-soak-operational-recovery'); +const queueDir = path.join(tempRoot, 'queue'); +const embeddingsRoot = path.join(tempRoot, 'embeddings'); +const snapshotRoot = path.join(tempRoot, 'snapshots'); +const testLogRoot = process.env.PAIROFCLEATS_TEST_LOG_DIR + || process.env.npm_config_test_log_dir + || ''; + +const writeArtifact = async (fileName, payload) => { + if (!testLogRoot) return; + const outPath = path.join(path.resolve(testLogRoot), fileName); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +}; + +const writeJson = async (filePath, value) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +}; + +const runQueueWorkerSoak = async () => { + await fs.rm(queueDir, { recursive: true, force: true }); + await ensureQueueDir(queueDir); + + const metrics = { + completed: 0, + staleRecoveries: 0, + quarantinedRecoveries: 0, + latencyMs: [], + queueHealth: [] + }; + + for (let cycle = 0; cycle < 8; cycle += 1) { + const queueName = cycle % 2 === 0 ? 'index-soak' : 'index-soak-heavy'; + const createdAt = Date.now(); + const maxRetries = cycle % 3 === 0 ? 0 : 1; + await enqueueJob(queueDir, { + id: `job-${cycle}`, + createdAt: new Date(createdAt).toISOString(), + repo: `/tmp/queue-soak-${cycle}`, + repoRoot: `/tmp/queue-soak-${cycle}`, + mode: cycle % 2 === 0 ? 'code' : 'both', + stage: cycle % 2 === 0 ? 'stage1' : 'stage2', + buildId: `build-${cycle}`, + maxRetries + }, null, queueName, { + forceDuplicate: true + }); + + const claimed = await claimNextJob(queueDir, queueName, { + ownerId: `worker-${cycle}`, + leaseMs: 5 + }); + assert.ok(claimed, `expected claimed job for cycle ${cycle}`); + + if (cycle % 3 === 0) { + const queuePayload = await loadQueue(queueDir, queueName); + const running = queuePayload.jobs.find((entry) => entry.id === claimed.id); + const expiredAt = new Date(Date.now() - 1000).toISOString(); + running.lease.expiresAt = expiredAt; + running.lastHeartbeatAt = expiredAt; + await saveQueue(queueDir, queuePayload, queueName); + const recovery = await requeueStaleJobs(queueDir, queueName, { maxRetries }); + metrics.staleRecoveries += recovery.retried + recovery.quarantined; + if (recovery.quarantined > 0) { + const retried = await retryQuarantinedJob(queueDir, claimed.id, queueName, { + forceDuplicate: true + }); + assert.ok(retried?.job, `expected quarantined retry recovery for cycle ${cycle}`); + const recoveredClaim = await claimNextJob(queueDir, queueName, { + ownerId: `worker-recovered-${cycle}`, + leaseMs: 5000 + }); + await completeJob(queueDir, recoveredClaim.id, 'done', { exitCode: 0 }, queueName, { + ownerId: `worker-recovered-${cycle}`, + expectedLeaseVersion: recoveredClaim.lease?.version ?? null + }); + metrics.quarantinedRecoveries += 1; + } else { + const recoveredClaim = await claimNextJob(queueDir, queueName, { + ownerId: `worker-retry-${cycle}`, + leaseMs: 5000 + }); + await completeJob(queueDir, recoveredClaim.id, 'done', { exitCode: 0 }, queueName, { + ownerId: `worker-retry-${cycle}`, + expectedLeaseVersion: recoveredClaim.lease?.version ?? null + }); + } + } else { + await completeJob(queueDir, claimed.id, 'done', { exitCode: 0 }, queueName, { + ownerId: `worker-${cycle}`, + expectedLeaseVersion: claimed.lease?.version ?? null + }); + } + + const summary = await queueSummary(queueDir, queueName); + const quarantine = await quarantineSummary(queueDir, queueName); + metrics.queueHealth.push({ + cycle, + queueName, + queued: summary.queued, + running: summary.running, + done: summary.done, + failed: summary.failed, + quarantined: quarantine.quarantined, + retried: quarantine.retried + }); + metrics.completed += 1; + metrics.latencyMs.push(Date.now() - createdAt); + } + + const finalQueues = await Promise.all([ + queueSummary(queueDir, 'index-soak'), + queueSummary(queueDir, 'index-soak-heavy'), + loadQuarantine(queueDir, 'index-soak'), + loadQuarantine(queueDir, 'index-soak-heavy') + ]); + + return { + iterations: 8, + completed: metrics.completed, + staleRecoveries: metrics.staleRecoveries, + quarantinedRecoveries: metrics.quarantinedRecoveries, + latencyMs: metrics.latencyMs, + latency: { + max: Math.max(...metrics.latencyMs), + min: Math.min(...metrics.latencyMs) + }, + queueHealth: metrics.queueHealth, + final: { + index: finalQueues[0], + heavy: finalQueues[1], + indexQuarantine: finalQueues[2].jobs.length, + heavyQuarantine: finalQueues[3].jobs.length + } + }; +}; + +const runEmbeddingRecoverySoak = async () => { + await fs.rm(embeddingsRoot, { recursive: true, force: true }); + const metrics = { + repairedRuns: 0, + idempotentRuns: 0, + partialDurableRuns: 0 + }; + + for (let cycle = 0; cycle < 4; cycle += 1) { + const repoRoot = path.join(embeddingsRoot, `repo-${cycle}`); + const buildRoot = path.join(repoRoot, 'builds', `build-${cycle}`); + const indexDir = path.join(buildRoot, 'index-code'); + const backendStageDir = path.join(buildRoot, '.embeddings-backend-staging', 'index-code'); + const buildStatePath = path.join(buildRoot, 'build_state.json'); + const indexStatePath = path.join(indexDir, 'index_state.json'); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + await fs.mkdir(backendStageDir, { recursive: true }); + await fs.writeFile(path.join(indexDir, 'dense_vectors_uint8.bin'), `vector-${cycle}`); + await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ cycle }, null, 2)); + await writeJson(buildStatePath, { + stage: 'stage3', + updatedAt: new Date().toISOString(), + phases: { + stage3: { + status: 'running' + } + }, + progress: { + code: { + completed: cycle + 1, + total: 8 + } + } + }); + await writeJson(indexStatePath, { + generatedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + embeddings: { + ready: false, + pending: true, + embeddingIdentityKey: `identity-${cycle}` + } + }); + + const job = { + id: `embedding-job-${cycle}`, + repo: repoRoot, + repoRoot, + buildRoot, + indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 + }; + + const before = await collectEmbeddingReplayState(job); + assert.equal(before.partialDurableState, true, `expected partial durable embeddings state for cycle ${cycle}`); + metrics.partialDurableRuns += 1; + + const repair = await repairEmbeddingReplayState(job); + assert.equal(repair.repaired, true, `expected embeddings repair to take action for cycle ${cycle}`); + metrics.repairedRuns += 1; + + const secondRepair = await repairEmbeddingReplayState(job); + assert.equal(secondRepair.repaired, false, `expected second repair to be idempotent for cycle ${cycle}`); + metrics.idempotentRuns += 1; + } + + return { + iterations: 4, + repairedRuns: metrics.repairedRuns, + idempotentRuns: metrics.idempotentRuns, + partialDurableRuns: metrics.partialDurableRuns + }; +}; + +const seedSnapshotBuildRoot = async ({ + repoCacheRoot, + buildId, + token, + end +}) => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + await fs.mkdir(buildRoot, { recursive: true }); + const { indexDir } = await createBaseIndex({ + rootDir: buildRoot, + chunkMeta: [ + { + id: 0, + file: 'src/soak-snapshot.js', + start: 0, + end, + text: `export const soak_marker = "${token}";` + } + ], + fileMeta: [ + { + id: 0, + file: 'src/soak-snapshot.js', + ext: '.js' + } + ], + tokenPostings: { + vocab: [token], + postings: [ + [[0, 1]] + ], + docLengths: [1], + avgDocLen: 1, + totalDocs: 1 + } + }); + await replaceDir(indexDir, path.join(buildRoot, 'index-code')); + await writeJson(path.join(buildRoot, 'build_state.json'), { + schemaVersion: 1, + buildId, + configHash: `cfg-${buildId}`, + tool: { version: '1.0.0' }, + validation: { ok: true, issueCount: 0, warningCount: 0, issues: [] } + }); +}; + +const runSnapshotRecoverySoak = async () => { + await fs.rm(snapshotRoot, { recursive: true, force: true }); + const repoRoot = path.join(snapshotRoot, 'repo'); + const cacheRoot = path.join(snapshotRoot, 'cache'); + await fs.mkdir(repoRoot, { recursive: true }); + + applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + } + }, + extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } + }); + + const markerPath = path.join(repoRoot, 'src', 'soak-snapshot.js'); + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, 'export const soak_marker = "alpha";\n', 'utf8'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); + + const tokens = ['alpha', 'beta', 'gamma']; + const snapshotChecks = []; + + for (let cycle = 0; cycle < tokens.length; cycle += 1) { + const token = tokens[cycle]; + const buildId = `build-${token}`; + await seedSnapshotBuildRoot({ + repoCacheRoot, + buildId, + token, + end: 30 + cycle + }); + await writeJson(path.join(repoCacheRoot, 'builds', 'current.json'), { + buildId, + buildRoot: `builds/${buildId}`, + buildRoots: { + code: `builds/${buildId}` + } + }); + const snapshotId = `snap-${token}`; + await createPointerSnapshot({ + repoRoot, + userConfig, + modes: ['code'], + snapshotId + }); + + const activeBuildLock = await acquireIndexLock({ + repoCacheRoot, + waitMs: 0, + metadata: { + owner: 'build-index', + operation: `stage4-promote-${token}` + } + }); + assert.ok(activeBuildLock, `expected index lock during snapshot cycle ${token}`); + + const resolved = resolveIndexRef({ + ref: `snap:${snapshotId}`, + repoRoot, + userConfig: loadUserConfig(repoRoot), + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + const chunkMeta = await loadChunkMeta(resolved.indexDirByMode.code, { strict: false }); + snapshotChecks.push({ + snapshotId, + token, + canonical: resolved.canonical, + end: chunkMeta[0]?.end ?? null + }); + await activeBuildLock.release(); + } + + const latest = resolveIndexRef({ + ref: 'latest', + repoRoot, + userConfig: loadUserConfig(repoRoot), + requestedModes: ['code'], + preferFrozen: true, + allowMissingModes: false + }); + const latestChunkMeta = await loadChunkMeta(latest.indexDirByMode.code, { strict: false }); + + return { + iterations: tokens.length, + snapshots: snapshotChecks, + latest: { + canonical: latest.canonical, + end: latestChunkMeta[0]?.end ?? null + } + }; +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const queue = await runQueueWorkerSoak(); +const embeddings = await runEmbeddingRecoverySoak(); +const snapshots = await runSnapshotRecoverySoak(); + +const passConditions = [ + { + name: 'queue finished without active work', + pass: queue.final.index.queued === 0 + && queue.final.index.running === 0 + && queue.final.heavy.queued === 0 + && queue.final.heavy.running === 0 + }, + { + name: 'queue exercised stale recovery paths', + pass: queue.staleRecoveries >= 2 && queue.quarantinedRecoveries >= 1 + }, + { + name: 'embedding replay repair stayed idempotent', + pass: embeddings.repairedRuns === embeddings.iterations && embeddings.idempotentRuns === embeddings.iterations + }, + { + name: 'snapshot reads remained stable across lock-protected cycles', + pass: snapshots.snapshots.length === snapshots.iterations + && snapshots.snapshots.every((entry, index) => entry.end === 30 + index) + && snapshots.latest.end === 32 + } +]; + +const artifact = { + generatedAt: new Date().toISOString(), + suite: 'services-soak-operational-recovery', + queue, + embeddings, + snapshots, + passConditions +}; + +await writeArtifact('services-soak-operational-recovery.json', artifact); + +assert.equal(passConditions.every((entry) => entry.pass), true, 'expected all soak pass conditions to hold'); + +console.log('services soak operational recovery test passed'); diff --git a/tests/services/sqlite-build-snapshot.test.js b/tests/services/sqlite-build-snapshot.test.js index 1ab6a580d..e424b374f 100644 --- a/tests/services/sqlite-build-snapshot.test.js +++ b/tests/services/sqlite-build-snapshot.test.js @@ -4,35 +4,43 @@ import assert from 'node:assert/strict'; import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import Database from 'better-sqlite3'; import { buildSqliteIndex } from '../../src/integrations/core/index.js'; import { createPointerSnapshot } from '../../src/index/snapshots/create.js'; import { loadUserConfig } from '../../tools/shared/dict-utils.js'; +import { runNode } from '../helpers/run-node.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'sqlite-build-snapshot-service'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); const repoRoot = path.join(tempRoot, 'repo'); const cacheRoot = path.join(tempRoot, 'cache'); await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); -await fs.cp(fixtureRoot, repoRoot, { recursive: true }); +await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fs.mkdir(cacheRoot, { recursive: true }); const env = applyTestEnv({ cacheRoot, embeddings: 'stub', testConfig: { indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, embeddings: { enabled: false, mode: 'off', lancedb: { enabled: false }, hnsw: { enabled: false } } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } } }, extraEnv: { PAIROFCLEATS_WORKER_POOL: 'off' } @@ -43,8 +51,7 @@ await fs.mkdir(path.dirname(markerPath), { recursive: true }); await fs.writeFile(markerPath, 'export const phase14_sqlite_marker = "phase14alpha";\n', 'utf8'); const runBuild = () => { - const result = spawnSync( - process.execPath, + const result = runNode( [ path.join(root, 'build_index.js'), '--repo', @@ -56,11 +63,10 @@ const runBuild = () => { '--progress', 'off' ], - { - cwd: repoRoot, - env, - encoding: 'utf8' - } + 'sqlite snapshot build index', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); if (result.status !== 0) { throw new Error(`build_index failed: ${result.stderr || result.stdout || 'unknown error'}`); diff --git a/tests/services/sqlite-hydration-metaV2-parity.test.js b/tests/services/sqlite-hydration-metaV2-parity.test.js index 6f9121069..74efbb8c8 100644 --- a/tests/services/sqlite-hydration-metaV2-parity.test.js +++ b/tests/services/sqlite-hydration-metaV2-parity.test.js @@ -3,13 +3,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import Database from 'better-sqlite3'; import { buildSqliteIndex } from '../../src/integrations/core/index.js'; import { loadChunkMeta } from '../../src/shared/artifact-io.js'; import { createSqliteHelpers } from '../../src/retrieval/sqlite-helpers.js'; import { getCurrentBuildInfo, getIndexDir, loadUserConfig } from '../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../helpers/test-env.js'; +import { runNode } from '../helpers/run-node.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; @@ -43,10 +43,12 @@ const env = applyTestEnv({ } }); -const buildResult = spawnSync( - process.execPath, +const buildResult = runNode( [path.join(root, 'build_index.js'), '--repo', repoRoot, '--mode', 'extracted-prose', '--stub-embeddings', '--no-sqlite'], - { cwd: repoRoot, env, stdio: 'inherit' } + 'sqlite hydration extracted-prose build', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } ); assert.equal(buildResult.status, 0, 'expected extracted-prose artifact build to succeed'); diff --git a/tests/shared/abort/abort-propagates-to-queues.test.js b/tests/shared/abort/abort-propagates-to-queues.test.js deleted file mode 100644 index a3c4873e8..000000000 --- a/tests/shared/abort/abort-propagates-to-queues.test.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import PQueue from 'p-queue'; -import { scanImports } from '../../../src/index/build/imports.js'; -import { isAbortError } from '../../../src/shared/abort.js'; - -const makeFixtureRepo = async (count = 80) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-abort-queue-')); - const entries = []; - await fs.mkdir(path.join(root, 'src'), { recursive: true }); - for (let i = 0; i < count; i += 1) { - const filePath = path.join(root, 'src', `file-${i}.js`); - const content = `import x from './mod-${i}.js';\nconst n = ${i};\nexport default n + 1;\n`; - await fs.writeFile(filePath, content, 'utf8'); - entries.push(filePath); - } - return { root, entries }; -}; - -const cleanup = async (root) => { - try { - await fs.rm(root, { recursive: true, force: true }); - } catch {} -}; - -const queue = new PQueue({ concurrency: 1 }); -const controller = new AbortController(); - -const { root, entries } = await makeFixtureRepo(); - -setTimeout(() => controller.abort(), 10); - -try { - await scanImports({ - files: entries, - root, - mode: 'code', - languageOptions: {}, - importConcurrency: 1, - queue, - abortSignal: controller.signal - }); - assert.fail('expected abort'); -} catch (err) { - assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); -} finally { - await cleanup(root); -} - -console.log('abort propagation to queue test passed'); diff --git a/tests/shared/abort/abort-propagates-to-subprocess.test.js b/tests/shared/abort/abort-propagates-to-subprocess.test.js deleted file mode 100644 index 93f14996a..000000000 --- a/tests/shared/abort/abort-propagates-to-subprocess.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSubprocess } from '../../../src/shared/subprocess.js'; -import { resolveSilentStdio } from '../../helpers/test-env.js'; - -const controller = new AbortController(); -const args = ['-e', 'setInterval(() => {}, 1000)']; - -setTimeout(() => controller.abort(), 50); - -try { - await spawnSubprocess(process.execPath, args, { - stdio: resolveSilentStdio('ignore'), - signal: controller.signal - }); - assert.fail('expected abort'); -} catch (err) { - assert.equal(err?.code, 'ABORT_ERR'); -} - -console.log('abort propagation to subprocess test passed'); diff --git a/tests/shared/abort/contract-matrix.test.js b/tests/shared/abort/contract-matrix.test.js new file mode 100644 index 000000000..2d6a92939 --- /dev/null +++ b/tests/shared/abort/contract-matrix.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import PQueue from 'p-queue'; + +import { scanImports } from '../../../src/index/build/imports.js'; +import { isAbortError } from '../../../src/shared/abort.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; +import { resolveSilentStdio } from '../../helpers/test-env.js'; + +const makeFixtureRepo = async (count = 80) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-abort-queue-')); + const entries = []; + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + for (let i = 0; i < count; i += 1) { + const filePath = path.join(root, 'src', `file-${i}.js`); + const content = `import x from './mod-${i}.js';\nconst n = ${i};\nexport default n + 1;\n`; + await fs.writeFile(filePath, content, 'utf8'); + entries.push(filePath); + } + return { root, entries }; +}; + +{ + const queue = new PQueue({ concurrency: 1 }); + const controller = new AbortController(); + const { root, entries } = await makeFixtureRepo(); + setTimeout(() => controller.abort(), 10); + try { + await scanImports({ + files: entries, + root, + mode: 'code', + languageOptions: {}, + importConcurrency: 1, + queue, + abortSignal: controller.signal + }); + assert.fail('expected abort'); + } catch (err) { + assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); + } finally { + await fs.rm(root, { recursive: true, force: true }).catch(() => {}); + } +} + +{ + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + try { + await spawnSubprocess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: resolveSilentStdio('ignore'), + signal: controller.signal + }); + assert.fail('expected abort'); + } catch (err) { + assert.equal(err?.code, 'ABORT_ERR'); + } +} + +console.log('shared abort contract matrix test passed'); diff --git a/tests/shared/artifact-io/artifact-io-bench-contract.test.js b/tests/shared/artifact-io/artifact-io-bench-contract.test.js deleted file mode 100644 index d3c32cf91..000000000 --- a/tests/shared/artifact-io/artifact-io-bench-contract.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const script = path.join(root, 'tools', 'bench', 'artifact-io', 'artifact-io-throughput.js'); -const result = spawnSync(process.execPath, [script, '--rows', '500', '--shard-bytes', '2048', '--concurrency', '2'], { - cwd: root, - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(1); -} - -const output = `${result.stdout || ''}${result.stderr || ''}`; -assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); -assert.ok(output.includes('[bench] current'), 'missing current output'); -assert.ok(output.includes('[bench] delta'), 'missing delta output'); - -console.log('artifact IO bench contract test passed'); diff --git a/tests/shared/artifact-io/bench-contract.test.js b/tests/shared/artifact-io/bench-contract.test.js new file mode 100644 index 000000000..0988796c1 --- /dev/null +++ b/tests/shared/artifact-io/bench-contract.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const script = path.join(root, 'tools', 'bench', 'artifact-io', 'artifact-io-throughput.js'); +const result = runNode( + [script, '--rows', '500', '--shard-bytes', '2048', '--concurrency', '2'], + 'artifact IO bench contract', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(1); +} + +const output = `${result.stdout || ''}${result.stderr || ''}`; +assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); +assert.ok(output.includes('[bench] current'), 'missing current output'); +assert.ok(output.includes('[bench] delta'), 'missing delta output'); + +console.log('artifact IO bench contract test passed'); diff --git a/tests/shared/artifact-io/binary-columnar-meta-path-traversal.test.js b/tests/shared/artifact-io/binary-columnar-meta-path-traversal.test.js index 936e451d1..e4c65332c 100644 --- a/tests/shared/artifact-io/binary-columnar-meta-path-traversal.test.js +++ b/tests/shared/artifact-io/binary-columnar-meta-path-traversal.test.js @@ -49,4 +49,29 @@ await assert.rejects( 'expected binary-columnar loader to reject traversal sidecar paths from meta' ); +const unreadableDir = path.join(indexDir, 'sample.binary-columnar.data-dir'); +await fs.mkdir(unreadableDir, { recursive: true }); +await fs.writeFile( + path.join(indexDir, 'sample.binary-columnar.meta.json'), + JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: rows.length, + data: 'sample.binary-columnar.data-dir', + offsets: 'sample.binary-columnar.offsets.bin', + lengths: 'sample.binary-columnar.lengths.varint' + } + }, null, 2) +); + +await assert.rejects( + () => loadJsonArrayArtifact(indexDir, 'sample', { strict: true }), + (err) => { + const message = String(err?.message || ''); + if (/checksum mismatch/i.test(message)) return false; + return err?.code === 'ERR_ARTIFACT_READ' || /truncated/i.test(message); + }, + 'expected unreadable binary-columnar payload paths to fail without being misclassified as checksum mismatch' +); + console.log('binary-columnar meta path traversal test passed'); diff --git a/tests/shared/artifact-io/binary-columnar-phase-timings.test.js b/tests/shared/artifact-io/binary-columnar-phase-timings.test.js new file mode 100644 index 000000000..c6b042782 --- /dev/null +++ b/tests/shared/artifact-io/binary-columnar-phase-timings.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { writeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const testRoot = resolveTestCachePath(root, 'binary-columnar-phase-timings'); +await fs.rm(testRoot, { recursive: true, force: true }); +await fs.mkdir(testRoot, { recursive: true }); + +const result = await writeBinaryRowFrames({ + rowBuffers: ['alpha', 'beta', 'gamma'], + dataPath: path.join(testRoot, 'rows.bin'), + offsetsPath: path.join(testRoot, 'rows.offsets.bin'), + lengthsPath: path.join(testRoot, 'rows.lengths.varint') +}); + +assert.equal(result.count, 3, 'expected row count'); +assert.ok(result.phaseTimings && typeof result.phaseTimings === 'object', 'expected phase timings'); +assert.ok(Number.isFinite(result.phaseTimings.flushMs), 'expected flushMs timing'); +assert.ok(Number.isFinite(result.phaseTimings.fsyncMs), 'expected fsyncMs timing'); +assert.ok(Number.isFinite(result.phaseTimings.publishMs), 'expected publishMs timing'); +assert.equal(result.phaseTimings.serializationMs, 0, 'expected serializationMs to remain writer-owned'); + +await fs.rm(testRoot, { recursive: true, force: true }); + +console.log('binary columnar phase timings test passed'); diff --git a/tests/shared/artifact-io/binary-columnar-streaming-frames.test.js b/tests/shared/artifact-io/binary-columnar-streaming-frames.test.js index 0bda2df8f..d8b89d253 100644 --- a/tests/shared/artifact-io/binary-columnar-streaming-frames.test.js +++ b/tests/shared/artifact-io/binary-columnar-streaming-frames.test.js @@ -75,4 +75,27 @@ const malformedWritten = await writeBinaryRowFrames({ assert.equal(malformedWritten.count, 0, 'expected malformed sync rowBuffers payload to fail closed'); assert.equal(malformedWritten.totalBytes, 0, 'expected malformed sync rowBuffers payload to produce no bytes'); +const asyncDataPath = path.join(tempRoot, 'chunk_meta-async.binary-columnar.bin'); +const asyncOffsetsPath = path.join(tempRoot, 'chunk_meta-async.binary-columnar.offsets.bin'); +const asyncLengthsPath = path.join(tempRoot, 'chunk_meta-async.binary-columnar.lengths.varint'); +const asyncWritten = await writeBinaryRowFrames({ + rowBuffers: (async function* asyncRows() { + for (const row of rows) { + yield row; + } + })(), + dataPath: asyncDataPath, + offsetsPath: asyncOffsetsPath, + lengthsPath: asyncLengthsPath +}); +assert.equal(asyncWritten.count, rows.length, 'expected async iterator row count to match'); +const [asyncDataBuffer, asyncOffsetsBuffer, asyncLengthsBuffer] = await Promise.all([ + fs.readFile(asyncDataPath), + fs.readFile(asyncOffsetsPath), + fs.readFile(asyncLengthsPath) +]); +assert.equal(Buffer.compare(asyncDataBuffer, expected.dataBuffer), 0, 'async data frame payload mismatch'); +assert.deepEqual(decodeU64Offsets(asyncOffsetsBuffer), expected.offsets, 'async offset sidecar mismatch'); +assert.deepEqual(decodeBinaryRowFrameLengths(asyncLengthsBuffer), expected.lengths, 'async length sidecar mismatch'); + console.log('binary columnar streaming frames test passed'); diff --git a/tests/shared/artifact-io/broken-offsets-fallback.test.js b/tests/shared/artifact-io/broken-offsets-fallback.test.js index 1fe5a66f2..041bff797 100644 --- a/tests/shared/artifact-io/broken-offsets-fallback.test.js +++ b/tests/shared/artifact-io/broken-offsets-fallback.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { loadSymbolOccurrencesByFile } from '../../../src/shared/artifact-io/loaders.js'; import { prepareArtifactIoTestDir, diff --git a/tests/shared/artifact-io/compression-tier-resolution.test.js b/tests/shared/artifact-io/compression-tier-resolution.test.js index c05e37eaf..46a80e311 100644 --- a/tests/shared/artifact-io/compression-tier-resolution.test.js +++ b/tests/shared/artifact-io/compression-tier-resolution.test.js @@ -1,6 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { resolveArtifactCompressionTier } from '../../../src/shared/artifact-io/compression.js'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + collectCompressedCandidates, + collectCompressedJsonlCandidates, + resolveArtifactCompressionTier +} from '../../../src/shared/artifact-io/compression.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; assert.equal( resolveArtifactCompressionTier('chunk_meta'), @@ -32,4 +39,42 @@ assert.equal( 'expected custom hot tier override to be honored' ); +const tempRoot = resolveTestCachePath(process.cwd(), 'compression-tier-resolution'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +const basePath = path.join(tempRoot, 'artifact.json'); +const candidatePaths = [ + [`${basePath}.zst`, new Date('2026-05-20T00:00:03.000Z')], + [`${basePath}.zst.bak`, new Date('2026-05-20T00:00:04.000Z')], + [`${basePath}.gz`, new Date('2026-05-20T00:00:02.000Z')], + [`${basePath}.gz.bak`, new Date('2026-05-20T00:00:01.000Z')] +]; +for (const [candidatePath, mtime] of candidatePaths) { + await fsPromises.writeFile(candidatePath, candidatePath, 'utf8'); + await fsPromises.utimes(candidatePath, mtime, mtime); +} + +const expectedCompressedCandidates = [ + { path: `${basePath}.zst`, compression: 'zstd', cleanup: true }, + { path: `${basePath}.gz`, compression: 'gzip', cleanup: true }, + { path: `${basePath}.zst.bak`, compression: 'zstd', cleanup: false }, + { path: `${basePath}.gz.bak`, compression: 'gzip', cleanup: false } +]; +const normalizeCandidate = ({ path: candidatePath, compression, cleanup }) => ({ + path: candidatePath, + compression, + cleanup +}); +assert.deepEqual( + collectCompressedCandidates(basePath).map(normalizeCandidate), + expectedCompressedCandidates, + 'expected compressed JSON candidate ordering to prefer live candidates, then newest backups' +); +assert.deepEqual( + collectCompressedJsonlCandidates(basePath).map(normalizeCandidate), + expectedCompressedCandidates, + 'expected compressed JSONL candidate ordering to match JSON candidate semantics' +); + console.log('compression tier resolution test passed'); diff --git a/tests/shared/artifact-io/core-binary-columnar-streaming-no-full-materialization.test.js b/tests/shared/artifact-io/core-binary-columnar-streaming-no-full-materialization.test.js new file mode 100644 index 000000000..2bf1cc94d --- /dev/null +++ b/tests/shared/artifact-io/core-binary-columnar-streaming-no-full-materialization.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { encodeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; +import { loadJsonArrayArtifactRows } from '../../../src/shared/artifact-io/loaders.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; + +applyTestEnv(); + +const root = process.cwd(); +const testRoot = path.join(root, '.testLogs', 'artifact-io-core-binary-columnar-streaming-no-full-materialization'); +await fsPromises.rm(testRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(testRoot, 'pieces'), { recursive: true }); + +const rows = [ + { id: 0, file: 'src/a.js', ext: '.js', size: 12 }, + { id: 1, file: 'src/b.ts', ext: '.ts', size: 42 }, + { id: 2, file: 'README.md', ext: '.md', size: 7 } +]; +const encoded = encodeBinaryRowFrames(rows.map((entry) => Buffer.from(JSON.stringify(entry), 'utf8'))); + +const dataPath = path.join(testRoot, 'sample.binary-columnar.bin'); +const offsetsPath = path.join(testRoot, 'sample.binary-columnar.offsets.bin'); +const lengthsPath = path.join(testRoot, 'sample.binary-columnar.lengths.varint'); +await fsPromises.writeFile(dataPath, encoded.dataBuffer); +await fsPromises.writeFile(offsetsPath, encoded.offsetsBuffer); +await fsPromises.writeFile(lengthsPath, encoded.lengthsBuffer); +await fsPromises.writeFile( + path.join(testRoot, 'sample.binary-columnar.meta.json'), + JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: rows.length, + data: 'sample.binary-columnar.bin', + offsets: 'sample.binary-columnar.offsets.bin', + lengths: 'sample.binary-columnar.lengths.varint' + } + }, null, 2) +); +await writePiecesManifest(testRoot, [ + { name: 'sample', path: 'sample.binary-columnar.bin', format: 'binary-columnar' }, + { name: 'sample_binary_columnar_offsets', path: 'sample.binary-columnar.offsets.bin', format: 'binary' }, + { name: 'sample_binary_columnar_lengths', path: 'sample.binary-columnar.lengths.varint', format: 'varint' }, + { name: 'sample_binary_columnar_meta', path: 'sample.binary-columnar.meta.json', format: 'json' } +]); + +const originalReadFileSync = fs.readFileSync; +const resolvedDataPath = path.resolve(dataPath); +let fullDataReadCount = 0; +fs.readFileSync = (targetPath, ...args) => { + if (typeof targetPath === 'string' && path.resolve(targetPath) === resolvedDataPath) { + fullDataReadCount += 1; + throw new Error('unexpected full data read'); + } + return originalReadFileSync.call(fs, targetPath, ...args); +}; + +const streamed = []; +try { + for await (const row of loadJsonArrayArtifactRows(testRoot, 'sample', { strict: true })) { + streamed.push(row); + } +} finally { + fs.readFileSync = originalReadFileSync; +} + +assert.deepEqual(streamed, rows, 'expected binary-columnar streaming rows to preserve payload values'); +assert.equal(fullDataReadCount, 0, 'expected streaming binary-columnar path to avoid full data readFileSync materialization'); + +console.log('core binary-columnar streaming no full materialization test passed'); diff --git a/tests/shared/artifact-io/artifact-io-format-parity.test.js b/tests/shared/artifact-io/format-parity.test.js similarity index 100% rename from tests/shared/artifact-io/artifact-io-format-parity.test.js rename to tests/shared/artifact-io/format-parity.test.js diff --git a/tests/shared/artifact-io/json-iterator-stream-cleanup-contract.test.js b/tests/shared/artifact-io/json-iterator-stream-cleanup-contract.test.js index 0a9cfe18d..6c1b51851 100644 --- a/tests/shared/artifact-io/json-iterator-stream-cleanup-contract.test.js +++ b/tests/shared/artifact-io/json-iterator-stream-cleanup-contract.test.js @@ -4,7 +4,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; const root = process.cwd(); -const sourcePath = path.join(root, 'src', 'shared', 'artifact-io', 'json.js'); +const sourcePath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'read-jsonl-stream.js'); const source = await fs.readFile(sourcePath, 'utf8'); assert.match( diff --git a/tests/shared/artifact-io/json-modularization.test.js b/tests/shared/artifact-io/json-modularization.test.js new file mode 100644 index 000000000..dc2026262 --- /dev/null +++ b/tests/shared/artifact-io/json-modularization.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const jsonPath = path.join(root, 'src', 'shared', 'artifact-io', 'json.js'); +const fallbackPath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'fallback.js'); +const readPlanPath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'read-plan.js'); +const readJsonPath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'read-json.js'); +const readJsonlStreamPath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'read-jsonl-stream.js'); +const readJsonlArrayPath = path.join(root, 'src', 'shared', 'artifact-io', 'json', 'read-jsonl-array.js'); + +for (const target of [ + jsonPath, + fallbackPath, + readPlanPath, + readJsonPath, + readJsonlStreamPath, + readJsonlArrayPath +]) { + assert.equal(fs.existsSync(target), true, `missing expected artifact-io modularization file: ${target}`); +} + +const source = fs.readFileSync(jsonPath, 'utf8'); + +for (const marker of [ + "./json/read-json.js", + "./json/read-jsonl-stream.js", + "./json/read-jsonl-array.js", + 'export { readJsonFile }', + 'export {', + 'readJsonLinesIterator' +]) { + assert.equal( + source.includes(marker), + true, + `expected shared artifact-io json module to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'export const readJsonFile = (', + 'export const readJsonLinesEach = async (', + 'export const readJsonLinesIterator = function (', + 'export const readJsonLinesArray = async (', + 'export const readJsonLinesArraySync = (' +]) { + assert.equal( + source.includes(legacyInlineMarker), + false, + `expected shared artifact-io json module to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('artifact-io json modularization test passed'); diff --git a/tests/shared/artifact-io/jsonl-stream-roundtrip.test.js b/tests/shared/artifact-io/jsonl-stream-roundtrip.test.js index 77b99effe..481197a2e 100644 --- a/tests/shared/artifact-io/jsonl-stream-roundtrip.test.js +++ b/tests/shared/artifact-io/jsonl-stream-roundtrip.test.js @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { readJsonLinesArray } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/artifact-io/loader-parallelism.test.js b/tests/shared/artifact-io/loader-parallelism.test.js index 1c3ca7b3b..d63c7985a 100644 --- a/tests/shared/artifact-io/loader-parallelism.test.js +++ b/tests/shared/artifact-io/loader-parallelism.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { readJsonLinesArray } from '../../../src/shared/artifact-io/json.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/artifact-io/manifest-read-plan.test.js b/tests/shared/artifact-io/manifest-read-plan.test.js new file mode 100644 index 000000000..feb62b6a7 --- /dev/null +++ b/tests/shared/artifact-io/manifest-read-plan.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + loadPiecesManifestWithReadPlan, + resolvePiecesManifestReadPlan +} from '../../../src/shared/artifact-io.js'; + +const strictPlan = resolvePiecesManifestReadPlan({ strict: true }); +assert.deepEqual(strictPlan.attempts, [0, 25, 50], 'expected strict plan to include retry delays'); +assert.deepEqual(strictPlan.retryableCodes, ['ERR_MANIFEST_MISSING'], 'expected strict plan retryable codes'); + +const nonStrictPlan = resolvePiecesManifestReadPlan({ strict: false }); +assert.deepEqual(nonStrictPlan.attempts, [0], 'expected non-strict plan to make a single attempt'); + +let attempts = 0; +const slept = []; +const manifest = await loadPiecesManifestWithReadPlan('ignored', { + strict: true, + sleep: async (ms) => { + slept.push(ms); + }, + readManifest: () => { + attempts += 1; + if (attempts < 3) { + const error = new Error('missing'); + error.code = 'ERR_MANIFEST_MISSING'; + throw error; + } + return { pieces: [{ path: 'token_postings.json' }] }; + } +}); +assert.equal(attempts, 3, 'expected strict manifest plan to retry missing manifests'); +assert.deepEqual(slept, [25, 50], 'expected retry delays to follow the manifest read plan'); +assert.equal(manifest?.pieces?.length, 1, 'expected successful retry to return the manifest payload'); + +let nonRetryAttempts = 0; +await assert.rejects( + loadPiecesManifestWithReadPlan('ignored', { + strict: true, + readManifest: () => { + nonRetryAttempts += 1; + const error = new Error('invalid'); + error.code = 'ERR_MANIFEST_INVALID'; + throw error; + } + }), + /invalid/, + 'expected non-retryable manifest errors to fail fast' +); +assert.equal(nonRetryAttempts, 1, 'expected non-retryable manifest errors to avoid retries'); + +console.log('shared manifest read plan test passed'); diff --git a/tests/shared/artifact-io/manifest-streaming.test.js b/tests/shared/artifact-io/manifest-streaming.test.js index 0c1853cee..89b8d05fd 100644 --- a/tests/shared/artifact-io/manifest-streaming.test.js +++ b/tests/shared/artifact-io/manifest-streaming.test.js @@ -2,7 +2,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { prepareArtifactIoTestDir, writePiecesManifest diff --git a/tests/shared/artifact-io/minhash-checksum-validation.test.js b/tests/shared/artifact-io/minhash-checksum-validation.test.js index ae6fea278..cc30d04c9 100644 --- a/tests/shared/artifact-io/minhash-checksum-validation.test.js +++ b/tests/shared/artifact-io/minhash-checksum-validation.test.js @@ -2,18 +2,21 @@ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { loadMinhashSignatures, loadMinhashSignatureRows } from '../../../src/shared/artifact-io/loaders.js'; -import { - prepareArtifactIoTestDir, - writePiecesManifest -} from '../../helpers/artifact-io-fixture.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; const root = process.cwd(); -const testRoot = await prepareArtifactIoTestDir('minhash-checksum-validation', { root }); +const testRoot = path.join( + root, + '.testLogs', + 'minhash-checksum-validation', + `${process.pid}-${Date.now()}` +); +await fs.mkdir(path.join(testRoot, 'pieces'), { recursive: true }); const buildPackedFixture = async (dir, { tamper = false } = {}) => { await fs.mkdir(dir, { recursive: true }); diff --git a/tests/shared/artifact-io/minhash-packed-allocation-caps.test.js b/tests/shared/artifact-io/minhash-packed-allocation-caps.test.js new file mode 100644 index 000000000..a3d95b282 --- /dev/null +++ b/tests/shared/artifact-io/minhash-packed-allocation-caps.test.js @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { + loadMinhashSignatures, + loadMinhashSignatureRows +} from '../../../src/shared/artifact-io/loaders.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; + +const root = process.cwd(); +const testRoot = path.join( + root, + '.testLogs', + 'minhash-packed-allocation-caps', + `${process.pid}-${Date.now()}` +); + +const createPackedFixture = async (dir, { + dims, + count, + byteLength = null +}) => { + await fs.mkdir(path.join(dir, 'pieces'), { recursive: true }); + const resolvedByteLength = byteLength == null + ? (dims * count * 4) + : Math.max(0, Math.floor(Number(byteLength))); + const packedPath = path.join(dir, 'minhash_signatures.packed.bin'); + const packedMetaPath = path.join(dir, 'minhash_signatures.packed.meta.json'); + const packed = Buffer.alloc(resolvedByteLength, 0); + await fs.writeFile(packedPath, packed); + await writeJsonObjectFile(packedMetaPath, { + fields: { + format: 'u32', + endian: 'le', + dims, + count + }, + atomic: true + }); + await writePiecesManifest(dir, [ + { name: 'minhash_signatures', path: 'minhash_signatures.packed.bin', format: 'packed' }, + { name: 'minhash_signatures_meta', path: 'minhash_signatures.packed.meta.json', format: 'json' } + ]); + return { packedPath, packedMetaPath }; +}; + +await fs.mkdir(path.join(testRoot, 'pieces'), { recursive: true }); + +const invalidDimsDir = path.join(testRoot, 'invalid-dims'); +await createPackedFixture(invalidDimsDir, { + dims: 3.5, + count: 1, + byteLength: 4 +}); +await assert.rejects( + () => loadMinhashSignatures(invalidDimsDir, { strict: false }), + (err) => err?.code === 'ERR_ARTIFACT_INVALID' + && /Invalid packed minhash dims/i.test(String(err?.message || '')), + 'expected strict integer validation for packed minhash dims' +); + +const overBudgetDir = path.join(testRoot, 'over-budget'); +await createPackedFixture(overBudgetDir, { + dims: 32, + count: 64 +}); +await assert.rejects( + () => loadMinhashSignatures(overBudgetDir, { strict: false, maxBytes: 2048 }), + (err) => err?.code === 'ERR_ARTIFACT_TOO_LARGE' + && /exceeds maxBytes/i.test(String(err?.message || '')), + 'expected maxBytes guard before full packed allocation' +); + +const streamBudgetDir = path.join(testRoot, 'stream-row-budget'); +await createPackedFixture(streamBudgetDir, { + dims: 20 * 1024 * 1024, + count: 1, + byteLength: 4 +}); +await assert.rejects( + async () => { + for await (const _row of loadMinhashSignatureRows(streamBudgetDir, { + strict: false, + maxBytes: 512 * 1024 * 1024, + batchSize: 1 + })) { + // no-op + } + }, + (err) => err?.code === 'ERR_ARTIFACT_TOO_LARGE' + && /stream buffer budget/i.test(String(err?.message || '')), + 'expected stream row-size budget guard before batch allocation' +); + +const cappedBatchDir = path.join(testRoot, 'capped-batch-size'); +await createPackedFixture(cappedBatchDir, { + dims: 8, + count: 128 +}); +let rowCount = 0; +for await (const row of loadMinhashSignatureRows(cappedBatchDir, { + strict: false, + maxBytes: 4096, + batchSize: Number.MAX_SAFE_INTEGER +})) { + assert.ok(row?.sig instanceof Uint32Array, 'expected packed rows to decode to Uint32Array'); + rowCount += 1; +} +assert.equal(rowCount, 128, 'expected rows to stream successfully with bounded batch allocation'); + +console.log('minhash packed allocation caps test passed'); diff --git a/tests/shared/artifact-io/minhash-packed-row-stability.test.js b/tests/shared/artifact-io/minhash-packed-row-stability.test.js index f4cba3e0b..b0eed202c 100644 --- a/tests/shared/artifact-io/minhash-packed-row-stability.test.js +++ b/tests/shared/artifact-io/minhash-packed-row-stability.test.js @@ -1,15 +1,18 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { loadMinhashSignatureRows } from '../../../src/shared/artifact-io/loaders.js'; -import { - prepareArtifactIoTestDir, - writePiecesManifest -} from '../../helpers/artifact-io-fixture.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; const root = process.cwd(); -const testRoot = await prepareArtifactIoTestDir('minhash-packed-row-stability', { root }); +const testRoot = path.join( + root, + '.testLogs', + 'minhash-packed-row-stability', + `${process.pid}-${Date.now()}` +); +await fs.mkdir(path.join(testRoot, 'pieces'), { recursive: true }); const dims = 6; const count = 7; diff --git a/tests/shared/artifact-io/offsets-misaligned-rejected.test.js b/tests/shared/artifact-io/offsets-misaligned-rejected.test.js new file mode 100644 index 000000000..339e63dde --- /dev/null +++ b/tests/shared/artifact-io/offsets-misaligned-rejected.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { readJsonlRowsAt, readOffsetsFile, resolveOffsetsCount } from '../../../src/shared/artifact-io/offsets.js'; + +const tempRoot = await makeTempDir('poc-offsets-misaligned-'); +const offsetsPath = path.join(tempRoot, 'rows.offsets.bin'); +const jsonlPath = path.join(tempRoot, 'rows.jsonl'); + +try { + await fs.writeFile(jsonlPath, '{"id":1}\n'); + await fs.writeFile(offsetsPath, Buffer.alloc(9, 0x01)); + + await assert.rejects( + () => readOffsetsFile(offsetsPath), + /ERR_OFFSETS_INVALID|misaligned/, + 'expected misaligned offsets file to be rejected' + ); + + await assert.rejects( + () => resolveOffsetsCount(offsetsPath), + /ERR_OFFSETS_INVALID|misaligned/, + 'expected misaligned offsets count resolution to be rejected' + ); + + await assert.rejects( + () => readJsonlRowsAt(jsonlPath, offsetsPath, [0]), + /ERR_OFFSETS_INVALID|misaligned/, + 'expected batch row reads to reject misaligned offsets sidecars' + ); + + console.log('offsets misaligned rejected test passed'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/shared/artifact-io/offsets-unified.test.js b/tests/shared/artifact-io/offsets-unified.test.js index 3dc97e455..d30889828 100644 --- a/tests/shared/artifact-io/offsets-unified.test.js +++ b/tests/shared/artifact-io/offsets-unified.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { OFFSETS_COMPRESSION, OFFSETS_FORMAT, @@ -33,10 +33,39 @@ const count = await resolveOffsetsCount(offsetsPath); assert.equal(count, rows.length); const middle = await readJsonlRowAt(jsonlPath, offsetsPath, 1, { maxBytes: 1024 }); assert.equal(middle.id, 2); +await assert.rejects( + () => readJsonlRowAt(jsonlPath, offsetsPath, 1, { maxBytes: 0.5 }), + (err) => err?.code === 'ERR_INVALID_MAX_BYTES', + 'expected fractional maxBytes to be rejected in strict integer mode' +); const batch = await readJsonlRowsAt(jsonlPath, offsetsPath, [2, 0, 1], { maxBytes: 1024 }); assert.deepEqual(batch.map((entry) => entry?.id ?? null), [3, 1, 2]); await validateOffsetsAgainstFile(jsonlPath, offsetsPath); +const encodeOffsets = (values) => { + const buffer = Buffer.alloc(values.length * 8); + for (let i = 0; i < values.length; i += 1) { + buffer.writeBigUInt64LE(BigInt(values[i]), i * 8); + } + return buffer; +}; + +const invalidStartOffsetsPath = path.join(cacheRoot, 'rows.invalid-start.offsets.bin'); +await fs.writeFile(invalidStartOffsetsPath, encodeOffsets([1, 11, 21])); +await assert.rejects( + () => validateOffsetsAgainstFile(jsonlPath, invalidStartOffsetsPath), + (err) => String(err?.message || '').includes('Offsets must start at zero'), + 'expected offsets validator to reject non-zero first offset' +); + +const invalidBoundaryOffsetsPath = path.join(cacheRoot, 'rows.invalid-boundary.offsets.bin'); +await fs.writeFile(invalidBoundaryOffsetsPath, encodeOffsets([0, 10, 21])); +await assert.rejects( + () => validateOffsetsAgainstFile(jsonlPath, invalidBoundaryOffsetsPath), + (err) => String(err?.message || '').includes('Offset boundary missing newline'), + 'expected offsets validator to reject offsets that are not newline-aligned' +); + assert.equal(OFFSETS_FORMAT, 'u64-le'); assert.equal(OFFSETS_FORMAT_VERSION, 1); assert.equal(OFFSETS_COMPRESSION, 'none'); diff --git a/tests/shared/artifact-io/prefer-binary-columnar-loaders.test.js b/tests/shared/artifact-io/prefer-binary-columnar-loaders.test.js index fcb761e18..9eb305582 100644 --- a/tests/shared/artifact-io/prefer-binary-columnar-loaders.test.js +++ b/tests/shared/artifact-io/prefer-binary-columnar-loaders.test.js @@ -155,4 +155,50 @@ assert.equal( 'expected disable-enforce flag to allow large binary token_postings to load' ); +await fs.writeFile( + path.join(testRoot, 'token_postings.binary-columnar.meta.json'), + JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: 1, + data: 'token_postings.binary-columnar.bin', + offsets: 'token_postings.binary-columnar.offsets.bin', + lengths: 'token_postings.binary-columnar.lengths.varint' + }, + arrays: { + vocab: [], + docLengths: [3] + } + }, null, 2) +); +assert.throws( + () => loadTokenPostings(testRoot, { strict: true, preferBinaryColumnar: true }), + /cardinality invariant failed/i, + 'expected token_postings binary-columnar loader to enforce vocab/postings cardinality invariants' +); + +await fs.writeFile(path.join(testRoot, 'token_postings.binary-columnar.bin'), tokenPostingsBinaryRows.dataBuffer); +await fs.writeFile(path.join(testRoot, 'token_postings.binary-columnar.offsets.bin'), tokenPostingsBinaryRows.offsetsBuffer); +await fs.writeFile(path.join(testRoot, 'token_postings.binary-columnar.lengths.varint'), tokenPostingsBinaryRows.lengthsBuffer); +await fs.writeFile( + path.join(testRoot, 'token_postings.binary-columnar.meta.json'), + JSON.stringify({ + format: 'binary-columnar-v1', + count: 1, + data: 'token_postings.binary-columnar.bin', + offsets: 'token_postings.binary-columnar.offsets.bin', + lengths: 'token_postings.binary-columnar.lengths.varint', + totalDocs: 1, + avgDocLen: 3, + vocab: ['top_level_tok'], + docLengths: [3] + }, null, 2) +); +const postingsTopLevelEnvelope = loadTokenPostings(testRoot, { strict: true, preferBinaryColumnar: true }); +assert.equal( + postingsTopLevelEnvelope?.vocab?.[0], + 'top_level_tok', + 'expected binary token_postings loader to accept top-level meta envelope shape' +); + console.log('prefer binary-columnar loaders test passed'); diff --git a/tests/shared/artifact-io/artifact-io-spec-contract.test.js b/tests/shared/artifact-io/spec-contract.test.js similarity index 100% rename from tests/shared/artifact-io/artifact-io-spec-contract.test.js rename to tests/shared/artifact-io/spec-contract.test.js diff --git a/tests/shared/artifact-io/validation-fastpath.test.js b/tests/shared/artifact-io/validation-fastpath.test.js index 24028eb3d..afe31e1d3 100644 --- a/tests/shared/artifact-io/validation-fastpath.test.js +++ b/tests/shared/artifact-io/validation-fastpath.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { readJsonLinesArray } from '../../../src/shared/artifact-io/json.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/async/promise-timeout-contract.test.js b/tests/shared/async/promise-timeout-contract.test.js index 2829f9e80..595231d2c 100644 --- a/tests/shared/async/promise-timeout-contract.test.js +++ b/tests/shared/async/promise-timeout-contract.test.js @@ -51,4 +51,27 @@ assert.equal(abortingTimeoutErr.code, 'ABORT_TIMEOUT'); assert.ok(observedAbortReason instanceof Error, 'expected abort reason to be propagated to operation signal'); assert.equal(observedAbortReason.code, 'ABORT_TIMEOUT'); +const preAbortedController = new AbortController(); +preAbortedController.abort(new Error('already-aborted')); +const preAbortedErr = await runWithTimeout( + () => Promise.resolve('unexpected-success'), + { timeoutMs: 50, signal: preAbortedController.signal } +).then(() => null, (err) => err); +assert.ok(preAbortedErr instanceof Error, 'expected immediate abort rejection for pre-aborted signal'); +assert.equal(preAbortedErr.code, 'ABORT_ERR'); +assert.match(String(preAbortedErr.message || ''), /already-aborted/i); + +const upstreamAbortController = new AbortController(); +const upstreamAbortErrPromise = runWithTimeout( + () => new Promise((resolve) => { + setTimeout(() => resolve('ignored-abort-success'), 80); + }), + { timeoutMs: 500, signal: upstreamAbortController.signal } +).then(() => null, (err) => err); +setTimeout(() => upstreamAbortController.abort(new Error('upstream-stop')), 20); +const upstreamAbortErr = await upstreamAbortErrPromise; +assert.ok(upstreamAbortErr instanceof Error, 'expected upstream abort to reject runWithTimeout'); +assert.equal(upstreamAbortErr.code, 'ABORT_ERR'); +assert.match(String(upstreamAbortErr.message || ''), /upstream-stop/i); + console.log('promise timeout contract test passed'); diff --git a/tests/shared/async/runwithqueue-failfast.test.js b/tests/shared/async/runwithqueue-failfast.test.js index 65550111a..c1ed4b080 100644 --- a/tests/shared/async/runwithqueue-failfast.test.js +++ b/tests/shared/async/runwithqueue-failfast.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; const queue = new PQueue({ concurrency: 1 }); queue.maxPending = 1; diff --git a/tests/shared/bloom/bloom-decode-length-contract.test.js b/tests/shared/bloom/decode-length-contract.test.js similarity index 100% rename from tests/shared/bloom/bloom-decode-length-contract.test.js rename to tests/shared/bloom/decode-length-contract.test.js diff --git a/tests/shared/bundle-io-patch-append.test.js b/tests/shared/bundle-io-patch-append.test.js new file mode 100644 index 000000000..794859bcb --- /dev/null +++ b/tests/shared/bundle-io-patch-append.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveBundlePatchMetaPath } from '../../src/shared/bundle-io-paths.js'; +import { + readBundleFile, + writeBundlePatch, + writeBundleFile +} from '../../src/shared/bundle-io.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'bundle-io-patch-append'); +const bundlePath = path.join(tempRoot, 'bundle.json'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const initialBundle = { + file: 'src/main.js', + hash: 'hash-a', + mtimeMs: 1, + size: 10, + chunks: [{ text: 'one' }], + fileRelations: { imports: ['./a.js'] } +}; +await writeBundleFile({ bundlePath, format: 'json', bundle: initialBundle }); + +const secondBundle = { + ...initialBundle, + hash: 'hash-b', + fileRelations: { imports: ['./a.js'], exports: ['thing'] } +}; +await writeBundlePatch({ + bundlePath, + format: 'json', + previousBundle: initialBundle, + nextBundle: secondBundle +}); + +const thirdBundle = { + ...secondBundle, + size: 11, + chunks: [{ text: 'one' }, { text: 'two' }] +}; +await writeBundlePatch({ + bundlePath, + format: 'json', + previousBundle: secondBundle, + nextBundle: thirdBundle +}); + +const readResult = await readBundleFile(bundlePath, { format: 'json' }); +assert.equal(readResult.ok, true, `expected readable bundle after sequential patch appends: ${readResult.reason || 'unknown'}`); +assert.equal(readResult.bundle.hash, 'hash-b', 'expected earlier patch-set update to survive later append'); +assert.deepEqual(readResult.bundle.fileRelations.exports, ['thing'], 'expected earlier patch-set field to survive later append'); +assert.equal(readResult.bundle.size, 11, 'expected latest scalar update to be applied'); +assert.equal(readResult.bundle.chunks.length, 2, 'expected latest chunk patch to be applied'); +const patchMeta = JSON.parse(await fs.readFile(resolveBundlePatchMetaPath(bundlePath), 'utf8')); +const patchStat = await fs.stat(`${bundlePath}.patch.jsonl`); +assert.equal(patchMeta.bytes, patchStat.size, 'expected patch meta bytes to match patch file size'); + +console.log('bundle io patch append test passed'); diff --git a/tests/shared/cache/cache-gc.test.js b/tests/shared/cache/cache-gc.test.js deleted file mode 100644 index 84317e573..000000000 --- a/tests/shared/cache/cache-gc.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'cache-gc'); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(cacheRoot, 'repos'); -const toolPath = path.join(root, 'tools', 'index', 'cache-gc.js'); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot -}; - -const makeRepo = async (name, bytes, mtimeMs) => { - const repoPath = path.join(repoRoot, name); - await fsPromises.mkdir(repoPath, { recursive: true }); - const payload = Buffer.alloc(bytes, 'a'); - await fsPromises.writeFile(path.join(repoPath, 'data.bin'), payload); - const stamp = new Date(mtimeMs); - await fsPromises.utimes(repoPath, stamp, stamp); - return repoPath; -}; - -const run = (args, label) => { - const result = spawnSync(process.execPath, [toolPath, ...args], { env, encoding: 'utf8' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); - -const now = Date.now(); -await makeRepo('old-repo', 2048, now - 5 * 24 * 60 * 60 * 1000); -const newRepoPath = await makeRepo('new-repo', 2048, now); - -const ageOutput = run(['--max-age-days', '1', '--json'], 'cache-gc age'); -const agePayload = JSON.parse(ageOutput); -if (!agePayload.removals.some((entry) => entry.id === 'old-repo')) { - console.error('cache-gc age failed to remove old-repo'); - process.exit(1); -} -if (!fs.existsSync(newRepoPath)) { - console.error('cache-gc age removed new-repo unexpectedly'); - process.exit(1); -} - -await fsPromises.rm(repoRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await makeRepo('repo-a', 4096, now - 10 * 24 * 60 * 60 * 1000); -const repoBPath = await makeRepo('repo-b', 4096, now - 1 * 24 * 60 * 60 * 1000); - -const sizeOutput = run(['--max-bytes', '4096', '--json'], 'cache-gc size'); -const sizePayload = JSON.parse(sizeOutput); -if (!sizePayload.removals.some((entry) => entry.id === 'repo-a')) { - console.error('cache-gc size failed to remove oldest repo-a'); - process.exit(1); -} -if (!fs.existsSync(repoBPath)) { - console.error('cache-gc size removed repo-b unexpectedly'); - process.exit(1); -} - -console.log('cache gc test passed'); - diff --git a/tests/shared/cache/cache-hit-rate-contract.test.js b/tests/shared/cache/cache-hit-rate-contract.test.js deleted file mode 100644 index 4034d1508..000000000 --- a/tests/shared/cache/cache-hit-rate-contract.test.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; - -const scriptPath = path.join(process.cwd(), 'tools', 'bench', 'cache-hit-rate.js'); -const output = execFileSync( - 'node', - [scriptPath, '--ops', '2000', '--keys', '200', '--hitRate', '0.7', '--mode', 'compare'], - { encoding: 'utf8' } -); - -assert.match(output, /\[bench\] baseline/, 'expected baseline output'); -assert.match(output, /\[bench\] current/, 'expected current output'); -assert.match(output, /\[bench\] delta/, 'expected delta output'); - -console.log('cache hit rate bench contract test passed'); diff --git a/tests/shared/cache/cache-key-schema.test.js b/tests/shared/cache/cache-key-schema.test.js deleted file mode 100644 index f81f67f34..000000000 --- a/tests/shared/cache/cache-key-schema.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - buildCacheKey, - buildCacheKeyPayload, - normalizeCacheNamespace -} from '../../../src/shared/cache-key.js'; - -const base = buildCacheKey({ - repoHash: 'repoA', - buildConfigHash: 'cfgA', - mode: 'code', - schemaVersion: 'sv1', - featureFlags: ['zeta', 'alpha'], - pathPolicy: 'posix' -}); -const reordered = buildCacheKey({ - repoHash: 'repoA', - buildConfigHash: 'cfgA', - mode: 'code', - schemaVersion: 'sv1', - featureFlags: ['alpha', 'zeta'], - pathPolicy: 'posix' -}); -const differentMode = buildCacheKey({ - repoHash: 'repoA', - buildConfigHash: 'cfgA', - mode: 'prose', - schemaVersion: 'sv1', - featureFlags: ['alpha', 'zeta'], - pathPolicy: 'posix' -}); - -assert.equal(base.key, reordered.key, 'cache key should be flag-order independent'); -assert.notEqual(base.key, differentMode.key, 'cache key should change when mode changes'); -assert.match(base.key, /^[a-z0-9-]+:ck1:[a-f0-9]{40}$/); - -const payload = buildCacheKeyPayload({ - repoHash: 'repoA', - buildConfigHash: 'cfgA', - mode: 'code', - schemaVersion: 'sv1', - featureFlags: ['b', 'a'], - pathPolicy: true -}); -assert.equal(payload.featureFlags, 'a,b'); -assert.equal(payload.pathPolicy, 'native'); - -assert.equal(normalizeCacheNamespace(' Repo/Cache Value '), 'repo-cache-value'); - -console.log('cache key schema test passed'); diff --git a/tests/shared/cache/cache-lru.test.js b/tests/shared/cache/cache-lru.test.js deleted file mode 100644 index 0853805c1..000000000 --- a/tests/shared/cache/cache-lru.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import { createLruCache, estimateStringBytes } from '../../../src/shared/cache.js'; - -const sizeCache = createLruCache({ - name: 'size-test', - maxMb: 0.0001, - ttlMs: 0, - sizeCalculation: estimateStringBytes -}); - -sizeCache.set('a', 'a'.repeat(80)); -sizeCache.set('b', 'b'.repeat(80)); - -const hasA = sizeCache.get('a') !== null; -const hasB = sizeCache.get('b') !== null; -assert.ok(!(hasA && hasB), 'expected size-based eviction'); -assert.ok(sizeCache.stats.evictions >= 1, 'expected at least one eviction'); - -const ttlCache = createLruCache({ - name: 'ttl-test', - maxMb: 1, - ttlMs: 10, - sizeCalculation: estimateStringBytes -}); - -ttlCache.set('x', 'value'); -await new Promise((resolve) => setTimeout(resolve, 25)); -const expired = ttlCache.get('x'); -assert.equal(expired, null, 'expected ttl-based expiration'); - -const badSizerCache = createLruCache({ - name: 'bad-sizer-test', - maxMb: 1, - ttlMs: 0, - sizeCalculation: () => 0 -}); -let badSizerError = null; -try { - badSizerCache.set('bad', 'value'); -} catch (err) { - badSizerError = err; -} -assert.ok(badSizerError, 'expected bad sizeCalculation to throw'); -assert.ok( - String(badSizerError.message || badSizerError).includes('sizeCalculation returned'), - 'expected sizeCalculation error message' -); - -console.log('cache lru test passed'); diff --git a/tests/shared/cache/cache-migration.test.js b/tests/shared/cache/cache-migration.test.js deleted file mode 100644 index cf824d688..000000000 --- a/tests/shared/cache/cache-migration.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { getCacheRoot, resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'cache-migration'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const runScenario = async ({ baseName, rebuild }) => { - const baseRoot = path.join(tempRoot, baseName); - const resolvedRoot = resolveVersionedCacheRoot(baseRoot); - await fs.mkdir(baseRoot, { recursive: true }); - await fs.mkdir(resolvedRoot, { recursive: true }); - const legacyPath = path.join(resolvedRoot, 'legacy.txt'); - const sentinelPath = path.join(resolvedRoot, 'sentinel.txt'); - await fs.writeFile(legacyPath, 'legacy'); - await fs.writeFile(sentinelPath, 'keep'); - - process.env.PAIROFCLEATS_CACHE_ROOT = baseRoot; - if (rebuild) { - process.env.PAIROFCLEATS_CACHE_REBUILD = '1'; - } else { - delete process.env.PAIROFCLEATS_CACHE_REBUILD; - } - - const resolved = getCacheRoot(); - assert.equal(path.resolve(resolved), path.resolve(resolvedRoot), 'expected resolved cache root'); - - if (rebuild) { - assert.equal(fsSync.existsSync(legacyPath), false, 'expected rebuild to clear cache root'); - assert.equal(fsSync.existsSync(sentinelPath), false, 'expected rebuild to clear cache root'); - } else { - assert.equal(fsSync.existsSync(legacyPath), true, 'expected cache root to remain without rebuild'); - assert.equal(fsSync.existsSync(sentinelPath), true, 'expected cache root to remain without rebuild'); - } -}; - -await runScenario({ baseName: 'root-a', rebuild: false }); -await runScenario({ baseName: 'root-b', rebuild: true }); - -console.log('cache migration tests passed'); diff --git a/tests/shared/cache/cache-policy-contract.test.js b/tests/shared/cache/cache-policy-contract.test.js deleted file mode 100644 index 4253ef447..000000000 --- a/tests/shared/cache/cache-policy-contract.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { defineCachePolicy, resolveCachePolicy } from '../../../src/shared/cache/policy.js'; - -let shutdownCalls = 0; -const basePolicy = defineCachePolicy({ - name: 'contract.cache', - maxEntries: 5, - maxBytes: 1024, - ttlMs: 1000, - invalidationTrigger: ['build-pointer-change', 'build-pointer-change', 'ttl'], - shutdown: () => { - shutdownCalls += 1; - } -}); - -assert.equal(basePolicy.name, 'contract.cache'); -assert.equal(basePolicy.maxEntries, 5); -assert.equal(basePolicy.maxBytes, 1024); -assert.equal(basePolicy.ttlMs, 1000); -assert.deepEqual(basePolicy.invalidationTriggers, ['build-pointer-change', 'ttl']); -assert.equal(basePolicy.invalidationTrigger, 'build-pointer-change'); -basePolicy.shutdown(); -assert.equal(shutdownCalls, 1); - -const resolved = resolveCachePolicy( - { maxEntries: 9, ttlMs: 5000 }, - basePolicy -); -assert.equal(resolved.maxEntries, 9); -assert.equal(resolved.maxBytes, 1024); -assert.equal(resolved.ttlMs, 5000); -assert.equal(resolved.invalidationTrigger, 'build-pointer-change'); - -let missingTriggerError = null; -try { - defineCachePolicy({ - name: 'bad.cache.trigger', - maxEntries: 1, - maxBytes: null, - ttlMs: 0, - shutdown: () => {} - }); -} catch (err) { - missingTriggerError = err; -} -assert.ok(missingTriggerError, 'expected missing invalidation trigger to throw'); - -let missingShutdownError = null; -try { - defineCachePolicy({ - name: 'bad.cache.shutdown', - maxEntries: 1, - maxBytes: null, - ttlMs: 0, - invalidationTrigger: 'manual' - }); -} catch (err) { - missingShutdownError = err; -} -assert.ok(missingShutdownError, 'expected missing shutdown hook to throw'); - -console.log('cache policy contract ok.'); - diff --git a/tests/shared/cache/cache-preflight-meta.test.js b/tests/shared/cache/cache-preflight-meta.test.js deleted file mode 100644 index df47d2544..000000000 --- a/tests/shared/cache/cache-preflight-meta.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { readCacheMeta, writeCacheMeta } from '../../../tools/build/embeddings/cache.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'cache-preflight-meta'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const mode = 'code'; -const identity = { provider: 'test', modelId: 'model', dims: 256 }; -const missing = readCacheMeta(tempRoot, identity, mode); -assert.equal(missing, null, 'expected missing cache meta to return null'); - -const meta = { - version: 1, - identityKey: 'abc123', - dims: 256, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() -}; -await writeCacheMeta(tempRoot, identity, mode, meta); - -const loaded = readCacheMeta(tempRoot, identity, mode); -assert.ok(loaded, 'expected cache meta to be readable'); -assert.equal(loaded.identityKey, meta.identityKey); -assert.equal(loaded.dims, meta.dims); - -console.log('cache preflight meta test passed'); diff --git a/tests/shared/cache/cache-reuse-determinism.test.js b/tests/shared/cache/cache-reuse-determinism.test.js deleted file mode 100644 index 594279b38..000000000 --- a/tests/shared/cache/cache-reuse-determinism.test.js +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildGraphIndexCacheKey } from '../../../src/graph/store.js'; -import { buildMapCacheKey } from '../../../src/map/build-map.js'; -import { buildQueryCacheKey } from '../../../src/retrieval/cli-index.js'; -import { buildQueryPlanCacheKey } from '../../../src/retrieval/query-plan-cache.js'; - -const graphKeyA = buildGraphIndexCacheKey({ - indexSignature: 'sig', - repoRoot: '/repo', - graphs: ['usage', 'call'], - includeCsr: true -}); -const graphKeyB = buildGraphIndexCacheKey({ - indexSignature: 'sig', - repoRoot: '/repo', - graphs: ['call', 'usage'], - includeCsr: true -}); -assert.equal(graphKeyA, graphKeyB, 'graph cache key should be order-independent'); - -const mapKeyA = buildMapCacheKey({ - buildId: 'build-1', - options: { scope: 'repo', focus: null, include: ['src'], onlyExported: true } -}); -const mapKeyB = buildMapCacheKey({ - buildId: 'build-1', - options: { include: ['src'], onlyExported: true, focus: null, scope: 'repo' } -}); -assert.equal(mapKeyA, mapKeyB, 'map cache key should be deterministic'); - -const queryKeyA = buildQueryCacheKey({ query: 'foo', filters: ['a', 'b'] }); -const queryKeyB = buildQueryCacheKey({ filters: ['a', 'b'], query: 'foo' }); -assert.equal(queryKeyA.key, queryKeyB.key, 'query cache key should be deterministic'); - -const planKeyA = buildQueryPlanCacheKey({ - query: 'foo', - configSignature: 'cfg', - indexSignature: 'idx' -}); -const planKeyB = buildQueryPlanCacheKey({ - query: 'foo', - configSignature: 'cfg', - indexSignature: 'idx' -}); -assert.equal(planKeyA.key, planKeyB.key, 'query plan cache key should be deterministic'); - -console.log('cache reuse determinism tests passed'); diff --git a/tests/shared/cache/cache-root-versioning.test.js b/tests/shared/cache/cache-root-versioning.test.js deleted file mode 100644 index 1fc4f49a2..000000000 --- a/tests/shared/cache/cache-root-versioning.test.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import { - CACHE_ROOT_LAYOUT_VERSION, - clearCacheRoot, - getCacheRoot, - resolveVersionedCacheRoot -} from '../../../src/shared/cache-roots.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'cache-root-versioning'); -await fsp.rm(tempRoot, { recursive: true, force: true }); -await fsp.mkdir(tempRoot, { recursive: true }); - -const savedEnv = { ...process.env }; -const restoreEnv = () => { - for (const key of Object.keys(process.env)) { - if (!(key in savedEnv)) delete process.env[key]; - } - for (const [key, value] of Object.entries(savedEnv)) { - process.env[key] = value; - } -}; - -try { - const baseRoot = path.join(tempRoot, 'cache'); - const versionedRoot = resolveVersionedCacheRoot(baseRoot); - assert.notEqual(path.resolve(baseRoot), path.resolve(versionedRoot), 'versioned root must differ from base root'); - assert.ok( - versionedRoot.endsWith(path.join('cache', CACHE_ROOT_LAYOUT_VERSION)), - 'versioned root should append layout version suffix' - ); - - await fsp.mkdir(baseRoot, { recursive: true }); - await fsp.mkdir(versionedRoot, { recursive: true }); - const versionedSentinel = path.join(versionedRoot, 'versioned.txt'); - const legacySentinel = path.join(baseRoot, 'legacy.txt'); - await fsp.writeFile(versionedSentinel, 'versioned'); - await fsp.writeFile(legacySentinel, 'legacy'); - - process.env.PAIROFCLEATS_CACHE_ROOT = baseRoot; - const resolved = getCacheRoot(); - assert.equal(path.resolve(resolved), path.resolve(versionedRoot), 'getCacheRoot should resolve versioned root'); - - clearCacheRoot({ baseRoot, includeLegacy: false }); - assert.equal(fs.existsSync(versionedSentinel), false, 'versioned root contents should be removed'); - assert.equal(fs.existsSync(legacySentinel), true, 'legacy base entries should remain when includeLegacy=false'); - - clearCacheRoot({ baseRoot, includeLegacy: true }); - assert.equal(fs.existsSync(baseRoot), false, 'base root should be removed when includeLegacy=true'); -} finally { - restoreEnv(); -} - -console.log('cache root versioning test passed'); diff --git a/tests/shared/cache/contract-matrix.test.js b/tests/shared/cache/contract-matrix.test.js new file mode 100644 index 000000000..2ad4a3bb4 --- /dev/null +++ b/tests/shared/cache/contract-matrix.test.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { createLruCache } from '../../../src/shared/cache/lru.js'; +import { estimateStringBytes } from '../../../src/shared/cache/size.js'; +import { + buildCacheKey, + buildCacheKeyPayload, + createLocalCacheKeyBuilder, + buildLocalCacheKey, + normalizeCacheNamespace +} from '../../../src/shared/cache-key.js'; +import { + normalizeLegacyCacheRootPath, + CACHE_ROOT_LAYOUT_VERSION, + clearCacheRoot, + getCacheRoot, + getCacheTempRoot, + resolveVersionedCacheRoot +} from '../../../src/shared/cache-roots.js'; +import { defineCachePolicy, resolveCachePolicy } from '../../../src/shared/cache/policy.js'; +import { buildGraphIndexCacheKey } from '../../../src/graph/store.js'; +import { buildMapCacheKey } from '../../../src/map/build-map.js'; +import { buildQueryCacheKey } from '../../../src/retrieval/cli-index.js'; +import { buildQueryPlanCacheKey } from '../../../src/retrieval/query-plan-cache.js'; +import { readCacheMeta, writeCacheMeta } from '../../../tools/build/embeddings/cache.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +let shutdownCalls = 0; +const basePolicy = defineCachePolicy({ + name: 'contract.cache', + maxEntries: 5, + maxBytes: 1024, + ttlMs: 1000, + invalidationTrigger: ['build-pointer-change', 'build-pointer-change', 'ttl'], + shutdown: () => { + shutdownCalls += 1; + } +}); +assert.equal(basePolicy.name, 'contract.cache'); +assert.equal(basePolicy.maxEntries, 5); +assert.equal(basePolicy.maxBytes, 1024); +assert.equal(basePolicy.ttlMs, 1000); +assert.deepEqual(basePolicy.invalidationTriggers, ['build-pointer-change', 'ttl']); +assert.equal(basePolicy.invalidationTrigger, 'build-pointer-change'); +basePolicy.shutdown(); +assert.equal(shutdownCalls, 1); +const resolvedPolicy = resolveCachePolicy({ maxEntries: 9, ttlMs: 5000 }, basePolicy); +assert.equal(resolvedPolicy.maxEntries, 9); +assert.equal(resolvedPolicy.maxBytes, 1024); +assert.equal(resolvedPolicy.ttlMs, 5000); +assert.equal(resolvedPolicy.invalidationTrigger, 'build-pointer-change'); +assert.throws(() => defineCachePolicy({ + name: 'bad.cache.trigger', + maxEntries: 1, + maxBytes: null, + ttlMs: 0, + shutdown: () => {} +})); +assert.throws(() => defineCachePolicy({ + name: 'bad.cache.shutdown', + maxEntries: 1, + maxBytes: null, + ttlMs: 0, + invalidationTrigger: 'manual' +})); + +const cacheRootTemp = resolveTestCachePath(process.cwd(), 'cache-root-versioning'); +await fsp.rm(cacheRootTemp, { recursive: true, force: true }); +await fsp.mkdir(cacheRootTemp, { recursive: true }); +await withTemporaryEnv({ + PAIROFCLEATS_CACHE_ROOT: '', + PAIROFCLEATS_HOME: path.join(cacheRootTemp, 'pairofcleats-home') +}, async () => { + const baseRoot = path.join(cacheRootTemp, 'pairofcleats-home'); + const cacheRoot = resolveVersionedCacheRoot(baseRoot); + assert.ok(cacheRoot.endsWith(path.join('pairofcleats-home', CACHE_ROOT_LAYOUT_VERSION))); + assert.equal(path.basename(cacheRoot), 'cache'); + assert.throws( + () => normalizeLegacyCacheRootPath(path.join(baseRoot, 'cache-v1', 'bench-language')), + { code: 'ERR_LEGACY_CACHE_ROOT_UNSUPPORTED' } + ); + + await fsp.mkdir(baseRoot, { recursive: true }); + await fsp.mkdir(cacheRoot, { recursive: true }); + const legacyRoot = path.join(baseRoot, 'cache-v1'); + await fsp.mkdir(legacyRoot, { recursive: true }); + const versionedSentinel = path.join(cacheRoot, 'versioned.txt'); + const legacySentinel = path.join(baseRoot, 'legacy.txt'); + const legacyCacheSentinel = path.join(legacyRoot, 'legacy-cache.txt'); + await fsp.writeFile(versionedSentinel, 'versioned'); + await fsp.writeFile(legacySentinel, 'legacy'); + await fsp.writeFile(legacyCacheSentinel, 'legacy-cache'); + + assert.equal(path.resolve(getCacheRoot()), path.resolve(cacheRoot)); + assert.equal(path.resolve(getCacheTempRoot('sqlite-build')), path.resolve(path.join(cacheRoot, 'tmp', 'sqlite-build'))); + + clearCacheRoot({ baseRoot, includeLegacy: false }); + assert.equal(fs.existsSync(versionedSentinel), false); + assert.equal(fs.existsSync(legacySentinel), true); + assert.equal(fs.existsSync(legacyCacheSentinel), true); + + clearCacheRoot({ baseRoot, includeLegacy: true }); + assert.equal(fs.existsSync(cacheRoot), false); + assert.equal(fs.existsSync(legacyRoot), false); + assert.equal(fs.existsSync(baseRoot), true); +}); + +const migrationTemp = resolveTestCachePath(process.cwd(), 'cache-migration'); +await fsp.rm(migrationTemp, { recursive: true, force: true }); +await fsp.mkdir(migrationTemp, { recursive: true }); +for (const scenario of [ + { baseName: 'root-a', rebuild: false }, + { baseName: 'root-b', rebuild: true } +]) { + const baseRoot = path.join(migrationTemp, scenario.baseName, 'cache-root'); + const resolvedRoot = resolveVersionedCacheRoot(baseRoot); + await fsp.mkdir(resolvedRoot, { recursive: true }); + const legacyPath = path.join(resolvedRoot, 'legacy.txt'); + const sentinelPath = path.join(resolvedRoot, 'sentinel.txt'); + await fsp.writeFile(legacyPath, 'legacy'); + await fsp.writeFile(sentinelPath, 'keep'); + + await withTemporaryEnv({ + PAIROFCLEATS_CACHE_ROOT: baseRoot, + PAIROFCLEATS_CACHE_REBUILD: scenario.rebuild ? '1' : undefined + }, async () => { + const resolved = getCacheRoot(); + assert.equal(path.resolve(resolved), path.resolve(resolvedRoot)); + assert.equal(fs.existsSync(legacyPath), !scenario.rebuild); + assert.equal(fs.existsSync(sentinelPath), !scenario.rebuild); + }); +} + +const base = buildCacheKey({ + repoHash: 'repoA', + buildConfigHash: 'cfgA', + mode: 'code', + schemaVersion: 'sv1', + featureFlags: ['zeta', 'alpha'], + pathPolicy: 'posix' +}); +const reordered = buildCacheKey({ + repoHash: 'repoA', + buildConfigHash: 'cfgA', + mode: 'code', + schemaVersion: 'sv1', + featureFlags: ['alpha', 'zeta'], + pathPolicy: 'posix' +}); +const differentMode = buildCacheKey({ + repoHash: 'repoA', + buildConfigHash: 'cfgA', + mode: 'prose', + schemaVersion: 'sv1', + featureFlags: ['alpha', 'zeta'], + pathPolicy: 'posix' +}); +assert.equal(base.key, reordered.key); +assert.notEqual(base.key, differentMode.key); +assert.match(base.key, /^[a-z0-9-]+:ck1:[a-f0-9]{40}$/); +const payload = buildCacheKeyPayload({ + repoHash: 'repoA', + buildConfigHash: 'cfgA', + mode: 'code', + schemaVersion: 'sv1', + featureFlags: ['b', 'a'], + pathPolicy: true +}); +assert.equal(payload.featureFlags, 'a,b'); +assert.equal(payload.pathPolicy, 'native'); +assert.equal(normalizeCacheNamespace(' Repo/Cache Value '), 'repo-cache-value'); + +const preflightTemp = resolveTestCachePath(process.cwd(), 'cache-preflight-meta'); +await fsp.rm(preflightTemp, { recursive: true, force: true }); +await fsp.mkdir(preflightTemp, { recursive: true }); +const identity = { provider: 'test', modelId: 'model', dims: 256 }; +assert.equal(readCacheMeta(preflightTemp, identity, 'code'), null); +const meta = { + version: 1, + identityKey: 'abc123', + dims: 256, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() +}; +await writeCacheMeta(preflightTemp, identity, 'code', meta); +const loaded = readCacheMeta(preflightTemp, identity, 'code'); +assert.ok(loaded); +assert.equal(loaded.identityKey, meta.identityKey); +assert.equal(loaded.dims, meta.dims); + +const sizeCache = createLruCache({ + name: 'size-test', + maxMb: 0.0001, + ttlMs: 0, + sizeCalculation: estimateStringBytes +}); +sizeCache.set('a', 'a'.repeat(80)); +sizeCache.set('b', 'b'.repeat(80)); +const hasA = sizeCache.get('a') !== null; +const hasB = sizeCache.get('b') !== null; +assert.ok(!(hasA && hasB)); +assert.ok(sizeCache.stats.evictions >= 1); + +const ttlCache = createLruCache({ + name: 'ttl-test', + maxMb: 1, + ttlMs: 10, + sizeCalculation: estimateStringBytes +}); +ttlCache.set('x', 'value'); +await new Promise((resolve) => setTimeout(resolve, 25)); +assert.equal(ttlCache.get('x'), null); + +const badSizerCache = createLruCache({ + name: 'bad-sizer-test', + maxMb: 1, + ttlMs: 0, + sizeCalculation: () => 0 +}); +assert.throws(() => badSizerCache.set('bad', 'value'), /sizeCalculation returned/); + +const graphKeyA = buildGraphIndexCacheKey({ + indexSignature: 'sig', + repoRoot: '/repo', + graphs: ['usage', 'call'], + includeCsr: true +}); +const graphKeyB = buildGraphIndexCacheKey({ + indexSignature: 'sig', + repoRoot: '/repo', + graphs: ['call', 'usage'], + includeCsr: true +}); +assert.equal(graphKeyA, graphKeyB); + +const mapKeyA = buildMapCacheKey({ + buildId: 'build-1', + options: { scope: 'repo', focus: null, include: ['src'], onlyExported: true } +}); +const mapKeyB = buildMapCacheKey({ + buildId: 'build-1', + options: { include: ['src'], onlyExported: true, focus: null, scope: 'repo' } +}); +assert.equal(mapKeyA, mapKeyB); + +const queryKeyA = buildQueryCacheKey({ query: 'foo', filters: ['a', 'b'] }); +const queryKeyB = buildQueryCacheKey({ filters: ['a', 'b'], query: 'foo' }); +assert.equal(queryKeyA.key, queryKeyB.key); + +const localKeyA = buildLocalCacheKey({ + namespace: 'Bench Cache', + payload: { b: 2, a: 1, omitted: undefined } +}); +const localKeyB = buildLocalCacheKey({ + namespace: 'bench-cache', + payload: { a: 1, b: 2 } +}); +assert.equal(localKeyA.serialized, '{"namespace":"bench-cache","payload":{"a":1,"b":2},"version":"lk1"}'); +assert.equal(localKeyA.key, localKeyB.key); + +const localArrayKey = buildLocalCacheKey({ + namespace: 'array', + payload: [1, undefined, { z: true, a: null }] +}); +assert.equal( + localArrayKey.serialized, + '{"namespace":"array","payload":[1,null,{"a":null,"z":true}],"version":"lk1"}' +); + +const localBuilder = createLocalCacheKeyBuilder({ namespace: 'Bench Cache' }); +const builtLocal = localBuilder.build({ b: 2, a: 1 }); +assert.equal(localBuilder.namespace, 'bench-cache'); +assert.equal(localBuilder.version, 'lk1'); +assert.equal(builtLocal.key, localKeyA.key); +assert.equal(localBuilder.key({ a: 1, b: 2 }), localKeyA.key); +assert.equal( + localBuilder.keyForProperty('id', 42), + buildLocalCacheKey({ namespace: 'bench-cache', payload: { id: 42 } }).key +); +assert.equal( + localBuilder.keyForProperty('omitted', undefined), + buildLocalCacheKey({ namespace: 'bench-cache', payload: {} }).key +); +assert.equal( + localBuilder.keyForProperty('id', Number.NaN), + buildLocalCacheKey({ namespace: 'bench-cache', payload: { id: Number.NaN } }).key +); + +const planKeyA = buildQueryPlanCacheKey({ query: 'foo', configSignature: 'cfg', indexSignature: 'idx' }); +const planKeyB = buildQueryPlanCacheKey({ query: 'foo', configSignature: 'cfg', indexSignature: 'idx' }); +assert.equal(planKeyA.key, planKeyB.key); + +console.log('cache contract matrix test passed'); diff --git a/tests/shared/cache/gc.test.js b/tests/shared/cache/gc.test.js new file mode 100644 index 000000000..c43964102 --- /dev/null +++ b/tests/shared/cache/gc.test.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'cache-gc'); +const cacheRoot = path.join(tempRoot, 'cache'); +const repoRoot = path.join(cacheRoot, 'repos'); +const toolPath = path.join(root, 'tools', 'index', 'cache-gc.js'); + +const env = applyTestEnv({ cacheRoot, syncProcess: false }); + +const makeRepo = async (name, bytes, mtimeMs) => { + const repoPath = path.join(repoRoot, name); + await fsPromises.mkdir(repoPath, { recursive: true }); + const payload = Buffer.alloc(bytes, 'a'); + await fsPromises.writeFile(path.join(repoPath, 'data.bin'), payload); + const stamp = new Date(mtimeMs); + await fsPromises.utimes(repoPath, stamp, stamp); + return repoPath; +}; + +const run = (args, label) => { + const result = runNode([toolPath, ...args], label, root, env, { stdio: 'pipe' }); + return result.stdout || ''; +}; + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); + +const now = Date.now(); +await makeRepo('old-repo', 2048, now - 5 * 24 * 60 * 60 * 1000); +const newRepoPath = await makeRepo('new-repo', 2048, now); + +const ageOutput = run(['--max-age-days', '1', '--json'], 'cache-gc age'); +const agePayload = JSON.parse(ageOutput); +if (!agePayload.removals.some((entry) => entry.id === 'old-repo')) { + console.error('cache-gc age failed to remove old-repo'); + process.exit(1); +} +if (!fs.existsSync(newRepoPath)) { + console.error('cache-gc age removed new-repo unexpectedly'); + process.exit(1); +} + +await fsPromises.rm(repoRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await makeRepo('repo-a', 4096, now - 10 * 24 * 60 * 60 * 1000); +const repoBPath = await makeRepo('repo-b', 4096, now - 1 * 24 * 60 * 60 * 1000); + +const sizeOutput = run(['--max-bytes', '4096', '--json'], 'cache-gc size'); +const sizePayload = JSON.parse(sizeOutput); +if (!sizePayload.removals.some((entry) => entry.id === 'repo-a')) { + console.error('cache-gc size failed to remove oldest repo-a'); + process.exit(1); +} +if (!fs.existsSync(repoBPath)) { + console.error('cache-gc size removed repo-b unexpectedly'); + process.exit(1); +} + +console.log('cache gc test passed'); + diff --git a/tests/shared/cache/hit-rate-contract.test.js b/tests/shared/cache/hit-rate-contract.test.js new file mode 100644 index 000000000..23aed48f5 --- /dev/null +++ b/tests/shared/cache/hit-rate-contract.test.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const scriptPath = path.join(process.cwd(), 'tools', 'bench', 'cache-hit-rate.js'); +const output = execFileSync( + 'node', + [scriptPath, '--ops', '2000', '--keys', '200', '--hitRate', '0.7', '--mode', 'compare'], + { encoding: 'utf8' } +); +const noWriterOutput = execFileSync( + 'node', + [scriptPath, '--ops', '2000', '--keys', '200', '--hitRate', '0', '--mode', 'compare', '--writer', 'false'], + { encoding: 'utf8' } +); + +assert.match(output, /\[bench\] baseline/, 'expected baseline output'); +assert.match(output, /\[bench\] current/, 'expected current output'); +assert.match(output, /\[bench\] delta/, 'expected delta output'); +assert.match(noWriterOutput, /hits=0 misses=2000/, 'expected hitRate=0 to force miss-only coverage'); +assert.doesNotMatch(noWriterOutput, /\[bench\] writer/, 'expected --writer false to disable writer output'); + +console.log('cache hit rate bench contract test passed'); diff --git a/tests/shared/cache/json-file-phase.test.js b/tests/shared/cache/json-file-phase.test.js new file mode 100644 index 000000000..de1e2f75e --- /dev/null +++ b/tests/shared/cache/json-file-phase.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadBoundedJsonFile, loadBoundedJsonFileSync } from '../../../src/shared/cache/json-file.js'; + +const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-json-cache-file-')); +const invalidPath = path.join(tempRoot, 'invalid.json'); +await fsPromises.writeFile(invalidPath, '{bad-json', 'utf8'); + +const syncInvalid = loadBoundedJsonFileSync(invalidPath, { fallback: null, maxBytes: 1024 }); +assert.equal(syncInvalid.data, null, 'expected sync parser failure fallback'); +assert.equal(syncInvalid.phase, 'parse', 'expected sync parse phase classification'); +assert.ok(syncInvalid.error, 'expected sync parser error metadata'); + +const asyncInvalid = await loadBoundedJsonFile(invalidPath, { fallback: null, maxBytes: 1024 }); +assert.equal(asyncInvalid.data, null, 'expected async parser failure fallback'); +assert.equal(asyncInvalid.phase, 'parse', 'expected async parse phase classification'); +assert.ok(asyncInvalid.error, 'expected async parser error metadata'); + +const oversizedPath = path.join(tempRoot, 'oversized.json'); +fs.writeFileSync(oversizedPath, '{"payload":"' + 'x'.repeat(8192) + '"}', 'utf8'); +const syncOversized = loadBoundedJsonFileSync(oversizedPath, { fallback: null, maxBytes: 64 }); +assert.equal(syncOversized.data, null, 'expected sync oversized fallback payload'); +assert.equal(syncOversized.phase, 'stat', 'expected sync oversize to be classified at stat phase'); +assert.equal(syncOversized.error?.code, 'ERR_JSON_FILE_TOO_LARGE', 'expected sync oversize error code'); + +const missingPath = path.join(tempRoot, 'missing.json'); +const asyncMissing = await loadBoundedJsonFile(missingPath, { fallback: null, maxBytes: 64 }); +assert.equal(asyncMissing.data, null, 'expected async missing file fallback payload'); +assert.equal(asyncMissing.phase, 'stat', 'expected async missing file to be stat-phase classified'); +assert.equal(asyncMissing.error?.code, 'ENOENT', 'expected async missing file ENOENT classification'); + +console.log('shared cache json file phase test passed'); diff --git a/tests/shared/cache/lru-parity.test.js b/tests/shared/cache/lru-parity.test.js index 46e73851d..e8845fa93 100644 --- a/tests/shared/cache/lru-parity.test.js +++ b/tests/shared/cache/lru-parity.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { createLruCache } from '../../../src/shared/cache.js'; +import { createLruCache } from '../../../src/shared/cache/lru.js'; import { createIndexCache } from '../../../src/retrieval/index-cache.js'; import { createSqliteDbCache } from '../../../src/retrieval/sqlite-cache.js'; diff --git a/tests/shared/cli-option-validators-contract.test.js b/tests/shared/cli-option-validators-contract.test.js new file mode 100644 index 000000000..4e74c1a0f --- /dev/null +++ b/tests/shared/cli-option-validators-contract.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { validateBenchArgs, validateBuildArgs } from '../../src/shared/cli-options.js'; + +const capture = (fn) => { + try { + fn(); + return null; + } catch (error) { + return error; + } +}; + +const buildUnknownError = capture(() => validateBuildArgs({ bogus: true })); +assert.ok(buildUnknownError instanceof Error, 'expected build validator to throw for unknown options'); +assert.equal(buildUnknownError?.code, 'ERR_BUILD_ARG_VALIDATION'); +assert.match(buildUnknownError?.message || '', /unknown options: bogus/i); +assert.deepEqual(buildUnknownError?.details, ['unknown option: bogus']); + +const buildSchemaError = capture(() => validateBuildArgs({ threads: 'many' })); +assert.ok(buildSchemaError instanceof Error, 'expected build validator to throw for schema mismatch'); +assert.equal(buildSchemaError?.code, 'ERR_BUILD_ARG_VALIDATION'); +assert.match(buildSchemaError?.message || '', /build-index args validation failed/i); +assert.ok(Array.isArray(buildSchemaError?.details) && buildSchemaError.details.length > 0); + +const benchUnknownError = capture(() => validateBenchArgs({ rogue: true })); +assert.ok(benchUnknownError instanceof Error, 'expected bench validator to throw for unknown options'); +assert.equal(benchUnknownError?.code, 'ERR_BENCH_ARG_VALIDATION'); +assert.match(benchUnknownError?.message || '', /unknown options: rogue/i); +assert.deepEqual(benchUnknownError?.details, ['unknown option: rogue']); + +const benchConflictError = capture(() => validateBenchArgs({ ann: true, 'no-ann': true })); +assert.ok(benchConflictError instanceof Error, 'expected bench validator to throw for conflicting flags'); +assert.equal(benchConflictError?.code, 'ERR_BENCH_ARG_VALIDATION'); +assert.match(benchConflictError?.message || '', /ann and no-ann cannot both be set/i); +assert.deepEqual(benchConflictError?.details, ['ann and no-ann cannot both be set']); + +console.log('cli option validators contract test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-abort-inflight-hang.test.js b/tests/shared/concurrency/concurrency-run-with-queue-abort-inflight-hang.test.js deleted file mode 100644 index 10b0960ec..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-abort-inflight-hang.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; -import { isAbortError } from '../../../src/shared/abort.js'; - -const queue = new PQueue({ concurrency: 1 }); -const controller = new AbortController(); -const items = [1, 2]; -let started = 0; - -const runPromise = runWithQueue( - queue, - items, - async (item) => { - started += 1; - if (item === 1) { - await new Promise(() => {}); - } - return item; - }, - { - signal: controller.signal - } -); - -setTimeout(() => { - controller.abort(new Error('abort-test')); -}, 50); - -const startedAtMs = Date.now(); - -try { - await runPromise; - assert.fail('expected abort for hung in-flight task'); -} catch (err) { - assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); -} - -const elapsedMs = Date.now() - startedAtMs; -assert.ok(elapsedMs < 1000, `abort should fail fast, took ${elapsedMs}ms`); -assert.equal(started, 1, 'expected only first task to start before abort'); - -console.log('runWithQueue abort in-flight hang test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-abort.test.js b/tests/shared/concurrency/concurrency-run-with-queue-abort.test.js deleted file mode 100644 index 169dbdeff..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-abort.test.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; -import { isAbortError } from '../../../src/shared/abort.js'; - -const queue = new PQueue({ concurrency: 1 }); -const controller = new AbortController(); -const items = [1, 2, 3, 4, 5]; - -setTimeout(() => controller.abort(), 30); - -try { - await runWithQueue(queue, items, async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - return true; - }, { - signal: controller.signal - }); - assert.fail('expected abort'); -} catch (err) { - assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); -} - -console.log('runWithQueue abort test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-backpressure-on-reject.test.js b/tests/shared/concurrency/concurrency-run-with-queue-backpressure-on-reject.test.js deleted file mode 100644 index 97cabb886..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-backpressure-on-reject.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; - -const queue = new PQueue({ concurrency: 1 }); -queue.maxPending = 1; - -const started = []; -const items = [0, 1, 2]; -const err = await runWithQueue( - queue, - items, - async (item) => { - started.push(item); - if (item === 0) { - throw new Error('fail-fast'); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - return item; - } -).then(() => null, (error) => error); - -assert.ok(err instanceof Error, 'expected rejection'); -assert.equal(err.message, 'fail-fast'); -assert.deepEqual(started, [0], 'expected hard stop after first failure'); - -console.log('concurrency backpressure on reject test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-best-effort.test.js b/tests/shared/concurrency/concurrency-run-with-queue-best-effort.test.js deleted file mode 100644 index 211ff3f63..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-best-effort.test.js +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; - -const queue = new PQueue({ concurrency: 2 }); -const items = ['a', 'b', 'c', 'd']; -const failures = new Set(['b', 'd']); -const onResult = []; -const onError = []; -let processed = 0; - -try { - await runWithQueue(queue, items, async (item, ctx) => { - processed += 1; - if (failures.has(item)) { - throw new Error(`fail:${item}`); - } - return item.toUpperCase(); - }, { - bestEffort: true, - onResult: (_result, ctx) => { - onResult.push(ctx.index); - }, - onError: (_error, ctx) => { - onError.push(ctx.index); - } - }); - assert.fail('expected AggregateError'); -} catch (err) { - assert.ok(err instanceof AggregateError, 'expected AggregateError for best-effort failures'); - assert.strictEqual(err.errors.length, failures.size, 'AggregateError should include each failure'); -} - -assert.strictEqual(processed, items.length, 'best-effort should process every item'); -assert.strictEqual(onResult.length, items.length - failures.size, 'onResult should fire for successes only'); -assert.strictEqual(onError.length, failures.size, 'onError should fire once per failure'); - -console.log('runWithQueue best-effort test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-error-propagation.test.js b/tests/shared/concurrency/concurrency-run-with-queue-error-propagation.test.js deleted file mode 100644 index 65fd33dee..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-error-propagation.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; - -const unhandled = []; -const onUnhandled = (reason) => { - unhandled.push(reason); -}; -process.on('unhandledRejection', onUnhandled); - -const queue = new PQueue({ concurrency: 2 }); -const items = [0, 1, 2]; -const err = await runWithQueue( - queue, - items, - async (item) => { - if (item === 1) throw new Error('boom'); - return item; - } -).then(() => null, (error) => error); - -process.removeListener('unhandledRejection', onUnhandled); - -assert.ok(err instanceof Error, 'expected rejection'); -assert.equal(err.message, 'boom'); -assert.equal(unhandled.length, 0, 'unexpected unhandled rejection'); - -console.log('concurrency error propagation test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-iterables.test.js b/tests/shared/concurrency/concurrency-run-with-queue-iterables.test.js deleted file mode 100644 index 188ce3b14..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-iterables.test.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; - -const queue = new PQueue({ concurrency: 2 }); - -const setItems = new Set(['a', 'b', 'c']); -const setResults = await runWithQueue(queue, setItems, async (item) => item.toUpperCase()); -assert.deepEqual(setResults, ['A', 'B', 'C'], 'set iteration should preserve order'); - -function *gen() { - yield 1; - yield 2; - yield 3; -} -const genResults = await runWithQueue(queue, gen(), async (item) => item * 2); -assert.deepEqual(genResults, [2, 4, 6], 'generator iteration should preserve order'); - -console.log('concurrency iterable inputs test passed'); diff --git a/tests/shared/concurrency/concurrency-run-with-queue-non-retryable.test.js b/tests/shared/concurrency/concurrency-run-with-queue-non-retryable.test.js deleted file mode 100644 index cf1541730..000000000 --- a/tests/shared/concurrency/concurrency-run-with-queue-non-retryable.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import PQueue from 'p-queue'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; - -ensureTestingEnv(process.env); - -const queue = new PQueue({ concurrency: 1 }); -let attempts = 0; -const marker = new Error('non-retryable'); -marker.retryable = false; - -const err = await runWithQueue( - queue, - [0], - async () => { - attempts += 1; - throw marker; - }, - { - retries: 5, - retryDelayMs: 1 - } -).then(() => null, (error) => error); - -assert.equal(attempts, 1, 'expected non-retryable failures to bypass retry loops'); -assert.equal(err, marker, 'expected original error to surface'); - -console.log('concurrency non-retryable test passed'); - diff --git a/tests/shared/concurrency/io-concurrency-cap-uv-threadpool.test.js b/tests/shared/concurrency/io-cap-uv-threadpool.test.js similarity index 100% rename from tests/shared/concurrency/io-concurrency-cap-uv-threadpool.test.js rename to tests/shared/concurrency/io-cap-uv-threadpool.test.js diff --git a/tests/shared/concurrency/io-concurrency-cap.test.js b/tests/shared/concurrency/io-cap.test.js similarity index 100% rename from tests/shared/concurrency/io-concurrency-cap.test.js rename to tests/shared/concurrency/io-cap.test.js diff --git a/tests/shared/concurrency/pending-bytes-limit-enforced.test.js b/tests/shared/concurrency/pending-bytes-limit-enforced.test.js index 96fda9e38..dd43d38b8 100644 --- a/tests/shared/concurrency/pending-bytes-limit-enforced.test.js +++ b/tests/shared/concurrency/pending-bytes-limit-enforced.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; const queue = new PQueue({ concurrency: 2 }); queue.maxPendingBytes = 100; diff --git a/tests/shared/concurrency/pending-limit-enforced.test.js b/tests/shared/concurrency/pending-limit-enforced.test.js index 7e123478f..378a3f924 100644 --- a/tests/shared/concurrency/pending-limit-enforced.test.js +++ b/tests/shared/concurrency/pending-limit-enforced.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; const queue = new PQueue({ concurrency: 1 }); queue.maxPending = 1; diff --git a/tests/shared/concurrency/pending-limit-zero-disabled.test.js b/tests/shared/concurrency/pending-limit-zero-disabled.test.js index 864a30103..e25b5685e 100644 --- a/tests/shared/concurrency/pending-limit-zero-disabled.test.js +++ b/tests/shared/concurrency/pending-limit-zero-disabled.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import PQueue from 'p-queue'; -import { runWithQueue } from '../../../src/shared/concurrency.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; const queue = new PQueue({ concurrency: 3 }); queue.maxPending = 0; diff --git a/tests/shared/concurrency/run-with-queue-abort-backpressure.test.js b/tests/shared/concurrency/run-with-queue-abort-backpressure.test.js new file mode 100644 index 000000000..ca6500ebe --- /dev/null +++ b/tests/shared/concurrency/run-with-queue-abort-backpressure.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import PQueue from 'p-queue'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; +import { isAbortError } from '../../../src/shared/abort.js'; + +const withTimeout = async (promise, timeoutMs, label) => ( + Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }) + ]) +); + +const runAbortDuringBackpressureScenario = async ({ + label, + configureQueue, + items +}) => { + const queue = new PQueue({ concurrency: 1 }); + configureQueue(queue); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + try { + await withTimeout( + runWithQueue( + queue, + items, + async (item) => { + if (item?.hang) { + await new Promise(() => {}); + } + return true; + }, + { signal: controller.signal } + ), + 2000, + label + ); + assert.fail(`${label}: expected abort`); + } catch (error) { + assert.ok(isAbortError(error), `${label}: expected AbortError, got ${error?.name || error}`); + } +}; + +await runAbortDuringBackpressureScenario({ + label: 'maxPending backpressure wait', + configureQueue(queue) { + queue.maxPending = 1; + }, + items: [{ hang: true }, { hang: false }] +}); + +await runAbortDuringBackpressureScenario({ + label: 'maxPendingBytes backpressure wait', + configureQueue(queue) { + queue.maxPendingBytes = 1; + }, + items: [{ hang: true, bytes: 1 }, { hang: false, bytes: 1 }] +}); + +console.log('runWithQueue backpressure abort test passed'); diff --git a/tests/shared/concurrency/run-with-queue-abort-inflight-hang.test.js b/tests/shared/concurrency/run-with-queue-abort-inflight-hang.test.js new file mode 100644 index 000000000..d488ed13e --- /dev/null +++ b/tests/shared/concurrency/run-with-queue-abort-inflight-hang.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import PQueue from 'p-queue'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; +import { isAbortError } from '../../../src/shared/abort.js'; + +const queue = new PQueue({ concurrency: 1 }); +const controller = new AbortController(); +const items = [1, 2]; +let started = 0; + +const runPromise = runWithQueue( + queue, + items, + async (item) => { + started += 1; + if (item === 1) { + await new Promise(() => {}); + } + return item; + }, + { + signal: controller.signal + } +); + +setTimeout(() => { + controller.abort(new Error('abort-test')); +}, 50); + +const startedAtMs = Date.now(); + +try { + await runPromise; + assert.fail('expected abort for hung in-flight task'); +} catch (err) { + assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); +} + +const elapsedMs = Date.now() - startedAtMs; +assert.ok(elapsedMs < 1000, `abort should fail fast, took ${elapsedMs}ms`); +assert.equal(started, 1, 'expected only first task to start before abort'); + +console.log('runWithQueue abort in-flight hang test passed'); diff --git a/tests/shared/concurrency/run-with-queue-contract-matrix.test.js b/tests/shared/concurrency/run-with-queue-contract-matrix.test.js new file mode 100644 index 000000000..2d8bc506f --- /dev/null +++ b/tests/shared/concurrency/run-with-queue-contract-matrix.test.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import PQueue from 'p-queue'; + +import { isAbortError } from '../../../src/shared/abort.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; +import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); +ensureTestingEnv(process.env); + +const childEnv = () => applyTestEnv({ syncProcess: false }); + +const spawnModuleEval = async (lines, { expectAliveAfterMs = null } = {}) => { + const child = spawn( + process.execPath, + ['--input-type=module', '-e', lines.join('\n')], + { + cwd: process.cwd(), + env: childEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + if (Number.isFinite(expectAliveAfterMs) && expectAliveAfterMs > 0) { + await new Promise((resolve) => setTimeout(resolve, expectAliveAfterMs)); + assert.equal( + child.exitCode, + null, + `expected child to remain alive after ${expectAliveAfterMs}ms; stderr=${stderr || ''}` + ); + child.kill(); + } + + const closeResult = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => resolve({ exitCode, signal })); + }); + + return { + ...closeResult, + stdout, + stderr + }; +}; + +const cases = [ + { + name: 'iterable inputs preserve input order', + async run() { + const queue = new PQueue({ concurrency: 2 }); + const setItems = new Set(['a', 'b', 'c']); + const setResults = await runWithQueue(queue, setItems, async (item) => item.toUpperCase()); + assert.deepEqual(setResults, ['A', 'B', 'C']); + + function *gen() { + yield 1; + yield 2; + yield 3; + } + const genResults = await runWithQueue(queue, gen(), async (item) => item * 2); + assert.deepEqual(genResults, [2, 4, 6]); + } + }, + { + name: 'worker failures propagate without unhandled rejections', + async run() { + const queue = new PQueue({ concurrency: 2 }); + const unhandled = []; + const onUnhandled = (reason) => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + const err = await runWithQueue( + queue, + [0, 1, 2], + async (item) => { + if (item === 1) throw new Error('boom'); + return item; + } + ).then(() => null, (error) => error); + process.removeListener('unhandledRejection', onUnhandled); + + assert.ok(err instanceof Error); + assert.equal(err.message, 'boom'); + assert.equal(unhandled.length, 0); + } + }, + { + name: 'best-effort mode records all successes and failures', + async run() { + const queue = new PQueue({ concurrency: 2 }); + const items = ['a', 'b', 'c', 'd']; + const failures = new Set(['b', 'd']); + const onResult = []; + const onError = []; + let processed = 0; + + const err = await runWithQueue( + queue, + items, + async (item) => { + processed += 1; + if (failures.has(item)) throw new Error(`fail:${item}`); + return item.toUpperCase(); + }, + { + bestEffort: true, + onResult: (_result, ctx) => { + onResult.push(ctx.index); + }, + onError: (_error, ctx) => { + onError.push(ctx.index); + } + } + ).then(() => null, (error) => error); + + assert.ok(err instanceof AggregateError); + assert.equal(err.errors.length, failures.size); + assert.equal(processed, items.length); + assert.equal(onResult.length, items.length - failures.size); + assert.equal(onError.length, failures.size); + } + }, + { + name: 'fail-fast backpressure stops dispatch after first rejection', + async run() { + const queue = new PQueue({ concurrency: 1 }); + queue.maxPending = 1; + const started = []; + const err = await runWithQueue( + queue, + [0, 1, 2], + async (item) => { + started.push(item); + if (item === 0) throw new Error('fail-fast'); + await new Promise((resolve) => setTimeout(resolve, 10)); + return item; + } + ).then(() => null, (error) => error); + + assert.ok(err instanceof Error); + assert.equal(err.message, 'fail-fast'); + assert.deepEqual(started, [0]); + } + }, + { + name: 'abort signal interrupts queued work', + async run() { + const queue = new PQueue({ concurrency: 1 }); + const controller = new AbortController(); + const items = [1, 2, 3, 4, 5]; + + setTimeout(() => controller.abort(), 30); + + const err = await runWithQueue( + queue, + items, + async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return true; + }, + { signal: controller.signal } + ).then(() => null, (error) => error); + + assert.ok(isAbortError(err), `expected AbortError, got ${err?.name || err}`); + } + }, + { + name: 'backpressure keepalive prevents unsettled top-level await exit', + async run() { + const result = await spawnModuleEval( + [ + "import PQueue from 'p-queue';", + "import { runWithQueue } from './src/shared/concurrency/run-with-queue.js';", + 'const queue = new PQueue({ concurrency: 1 });', + 'queue.maxPending = 1;', + 'await runWithQueue(queue, [1, 2], async (item) => item, {', + ' collectResults: false,', + ' onResult: async () => {', + ' await new Promise(() => {});', + ' }', + '});' + ], + { expectAliveAfterMs: 200 } + ); + + assert.notEqual( + result.exitCode, + 13, + `expected keepalive child to avoid unsettled top-level await exit 13; stderr=${result.stderr || ''}` + ); + } + }, + { + name: 'pending-drain keepalive exits cleanly after timeout', + async run() { + const result = await spawnModuleEval([ + "import PQueue from 'p-queue';", + "import { runWithQueue } from './src/shared/concurrency/run-with-queue.js';", + 'const queue = new PQueue({ concurrency: 1 });', + 'try {', + ' await runWithQueue(queue, [1], async (item) => item, {', + ' collectResults: false,', + ' pendingDrainTimeoutMs: 120,', + ' onResult: async () => {', + ' await new Promise(() => {});', + ' }', + ' });', + " throw new Error('expected pending drain timeout');", + '} catch (error) {', + " if (error?.code !== 'RUN_WITH_QUEUE_PENDING_DRAIN_TIMEOUT') throw error;", + " process.stdout.write('TIMED_OUT\\n');", + '}' + ]); + + assert.equal( + result.exitCode, + 0, + `expected pending-drain child exit=0; signal=${result.signal} stderr=${result.stderr || ''}` + ); + assert.match(result.stdout, /TIMED_OUT/); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('runWithQueue contract matrix test passed'); diff --git a/tests/shared/concurrency/run-with-queue-non-retryable.test.js b/tests/shared/concurrency/run-with-queue-non-retryable.test.js new file mode 100644 index 000000000..59d20c6c0 --- /dev/null +++ b/tests/shared/concurrency/run-with-queue-non-retryable.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import PQueue from 'p-queue'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; + +ensureTestingEnv(process.env); + +const queue = new PQueue({ concurrency: 1 }); +let attempts = 0; +const marker = new Error('non-retryable'); +marker.retryable = false; + +const err = await runWithQueue( + queue, + [0], + async () => { + attempts += 1; + throw marker; + }, + { + retries: 5, + retryDelayMs: 1 + } +).then(() => null, (error) => error); + +assert.equal(attempts, 1, 'expected non-retryable failures to bypass retry loops'); +assert.equal(err, marker, 'expected original error to surface'); + +console.log('concurrency non-retryable test passed'); + diff --git a/tests/shared/concurrency/run-with-queue-pending-drain-timeout.test.js b/tests/shared/concurrency/run-with-queue-pending-drain-timeout.test.js new file mode 100644 index 000000000..3502cbd97 --- /dev/null +++ b/tests/shared/concurrency/run-with-queue-pending-drain-timeout.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import PQueue from 'p-queue'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; + +ensureTestingEnv(process.env); + +const queue = new PQueue({ concurrency: 1 }); +let stallEvents = 0; + +await assert.rejects( + () => runWithQueue( + queue, + [1, 2], + async (item) => item, + { + collectResults: false, + pendingDrainTimeoutMs: 120, + pendingDrainStallPollMs: 20, + onPendingDrainStall: () => { + stallEvents += 1; + }, + onResult: async (_result, ctx) => { + if (ctx.index === 0) { + await new Promise(() => {}); + } + } + } + ), + (error) => error?.code === 'RUN_WITH_QUEUE_PENDING_DRAIN_TIMEOUT', + 'expected pending-drain timeout while one queue task never settles' +); + +assert.ok(stallEvents >= 1, 'expected pending-drain stall callback to fire'); + +console.log('runWithQueue pending-drain timeout test passed'); diff --git a/tests/shared/concurrency/scheduler-abort-pending.test.js b/tests/shared/concurrency/scheduler-abort-pending.test.js index 67eb2c77d..864f2a8d3 100644 --- a/tests/shared/concurrency/scheduler-abort-pending.test.js +++ b/tests/shared/concurrency/scheduler-abort-pending.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-adapter-bytes-gating.test.js b/tests/shared/concurrency/scheduler-adapter-bytes-gating.test.js index c393e6aa5..88b765777 100644 --- a/tests/shared/concurrency/scheduler-adapter-bytes-gating.test.js +++ b/tests/shared/concurrency/scheduler-adapter-bytes-gating.test.js @@ -1,6 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler, createSchedulerQueueAdapter, runWithQueue } from '../../../src/shared/concurrency.js'; +import { createSchedulerQueueAdapter } from '../../../src/shared/concurrency/queue-adapter.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-adapter-zero-limits-disabled.test.js b/tests/shared/concurrency/scheduler-adapter-zero-limits-disabled.test.js index 231ad4bdd..8d002f27a 100644 --- a/tests/shared/concurrency/scheduler-adapter-zero-limits-disabled.test.js +++ b/tests/shared/concurrency/scheduler-adapter-zero-limits-disabled.test.js @@ -1,10 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { - createBuildScheduler, - createSchedulerQueueAdapter, - runWithQueue -} from '../../../src/shared/concurrency.js'; +import { createSchedulerQueueAdapter } from '../../../src/shared/concurrency/queue-adapter.js'; +import { runWithQueue } from '../../../src/shared/concurrency/run-with-queue.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-adaptive-surface-nested-io-deadlock.test.js b/tests/shared/concurrency/scheduler-adaptive-surface-nested-io-deadlock.test.js index 93ec7f499..aba524a29 100644 --- a/tests/shared/concurrency/scheduler-adaptive-surface-nested-io-deadlock.test.js +++ b/tests/shared/concurrency/scheduler-adaptive-surface-nested-io-deadlock.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-adaptive-surfaces.test.js b/tests/shared/concurrency/scheduler-adaptive-surfaces.test.js index 213767322..a6a454683 100644 --- a/tests/shared/concurrency/scheduler-adaptive-surfaces.test.js +++ b/tests/shared/concurrency/scheduler-adaptive-surfaces.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-contract.test.js b/tests/shared/concurrency/scheduler-contract.test.js index c65e8a7f4..ce4e44773 100644 --- a/tests/shared/concurrency/scheduler-contract.test.js +++ b/tests/shared/concurrency/scheduler-contract.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-core-modularization.test.js b/tests/shared/concurrency/scheduler-core-modularization.test.js new file mode 100644 index 000000000..48f780837 --- /dev/null +++ b/tests/shared/concurrency/scheduler-core-modularization.test.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const schedulerCorePath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core.js'); +const schedulerIndexPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'index.js'); +const schedulerConfigPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'config.js'); +const schedulerAdaptiveControllerPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'adaptive-controller.js'); +const schedulerAdaptiveSignalsPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'adaptive-signals.js'); +const schedulerAdaptiveSurfaceControllerPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'adaptive-surface-controller.js'); +const schedulerAdaptiveSurfaceSnapshotsPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'adaptive-surface-snapshots.js'); +const schedulerAdaptiveTokenControllerPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'adaptive-token-controller.js'); +const schedulerQueueLifecyclePath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'queue-lifecycle.js'); +const schedulerDispatchPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'dispatch.js'); +const schedulerShutdownPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core', 'shutdown.js'); +const policyPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core-policy.js'); +const queueStatePath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core-queue-state.js'); +const statsPath = path.join(root, 'src', 'shared', 'concurrency', 'scheduler-core-stats.js'); + +for (const target of [ + schedulerCorePath, + schedulerIndexPath, + schedulerConfigPath, + schedulerAdaptiveControllerPath, + schedulerAdaptiveSignalsPath, + schedulerAdaptiveSurfaceControllerPath, + schedulerAdaptiveSurfaceSnapshotsPath, + schedulerAdaptiveTokenControllerPath, + schedulerQueueLifecyclePath, + schedulerDispatchPath, + schedulerShutdownPath, + policyPath, + queueStatePath, + statsPath +]) { + assert.equal(fs.existsSync(target), true, `missing expected scheduler modularization file: ${target}`); +} + +const source = fs.readFileSync(schedulerCorePath, 'utf8'); +const indexSource = fs.readFileSync(schedulerIndexPath, 'utf8'); +const adaptiveSource = fs.readFileSync(schedulerAdaptiveControllerPath, 'utf8'); + +for (const marker of [ + "./scheduler-core/index.js" +]) { + assert.equal( + source.includes(marker), + true, + `expected scheduler core to delegate via ${marker}` + ); +} + +for (const marker of [ + "./config.js", + "./adaptive-controller.js", + "./queue-lifecycle.js", + "./dispatch.js", + "./shutdown.js", + 'buildSchedulerStatsSnapshot(', + 'createSchedulerTelemetryCapture(' +]) { + assert.equal( + indexSource.includes(marker), + true, + `expected scheduler core index to compose ${marker}` + ); +} + +for (const marker of [ + "./adaptive-signals.js", + "./adaptive-surface-controller.js", + "./adaptive-surface-snapshots.js", + "./adaptive-token-controller.js", + 'createAdaptiveSurfaceSnapshotHelpers(', + 'createAdaptiveSignalReader(', + 'createAdaptiveSurfaceController(', + 'createAdaptiveTokenController(' +]) { + assert.equal( + adaptiveSource.includes(marker), + true, + `expected adaptive controller to compose ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'export function createBuildScheduler(input = {}) {', + 'const shouldRequireSignalForQueue = (queueName) => (', + 'const maybeAdaptTokens = () => {', + 'const pump = () => {', + 'const shutdown = ({' +]) { + assert.equal( + source.includes(legacyInlineMarker), + false, + `expected scheduler core to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('scheduler core modularization test passed'); diff --git a/tests/shared/concurrency/scheduler-fd-pressure-adaptive-backoff.test.js b/tests/shared/concurrency/scheduler-fd-pressure-adaptive-backoff.test.js index 78e819783..8e12c7234 100644 --- a/tests/shared/concurrency/scheduler-fd-pressure-adaptive-backoff.test.js +++ b/tests/shared/concurrency/scheduler-fd-pressure-adaptive-backoff.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; let nowMs = 0; let sampledFdPressure = 1; diff --git a/tests/shared/concurrency/scheduler-fd-pressure-threshold-compat.test.js b/tests/shared/concurrency/scheduler-fd-pressure-threshold-compat.test.js index c5f7a9b53..4cbc2569f 100644 --- a/tests/shared/concurrency/scheduler-fd-pressure-threshold-compat.test.js +++ b/tests/shared/concurrency/scheduler-fd-pressure-threshold-compat.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const scheduler = createBuildScheduler({ enabled: true, diff --git a/tests/shared/concurrency/scheduler-maxpending-zero-clears.test.js b/tests/shared/concurrency/scheduler-maxpending-zero-clears.test.js index d0296081a..2ada32680 100644 --- a/tests/shared/concurrency/scheduler-maxpending-zero-clears.test.js +++ b/tests/shared/concurrency/scheduler-maxpending-zero-clears.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-oversized-token-request.test.js b/tests/shared/concurrency/scheduler-oversized-token-request.test.js new file mode 100644 index 000000000..a3d52b6bb --- /dev/null +++ b/tests/shared/concurrency/scheduler-oversized-token-request.test.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const scheduler = createBuildScheduler({ + cpuTokens: 1, + ioTokens: 1, + memoryTokens: 1 +}); + +let releaseOversized = null; +let oversizedStarted = false; +const oversized = scheduler.schedule('oversized', { io: 2, mem: 2 }, async () => { + oversizedStarted = true; + return new Promise((resolve) => { + releaseOversized = resolve; + }); +}); + +await sleep(20); +assert.equal(oversizedStarted, true, 'expected a single oversized token request to start when pools are idle'); + +let regularStarted = false; +const regular = scheduler.schedule('regular', { io: 1 }, async () => { + regularStarted = true; + return 'regular'; +}); + +await sleep(20); +assert.equal(regularStarted, false, 'expected normal token gating while oversized work is running'); + +releaseOversized('oversized'); +assert.equal(await oversized, 'oversized'); +assert.equal(await regular, 'regular'); +assert.equal(regularStarted, true, 'expected regular work to start after oversized tokens release'); + +scheduler.shutdown(); +console.log('scheduler oversized token request test passed'); diff --git a/tests/shared/concurrency/scheduler-required-signal.test.js b/tests/shared/concurrency/scheduler-required-signal.test.js new file mode 100644 index 000000000..29ff490fc --- /dev/null +++ b/tests/shared/concurrency/scheduler-required-signal.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; + +const scheduler = createBuildScheduler({ + requireSignals: true, + requiredSignalQueues: ['stage1.cpu'] +}); + +await assert.rejects( + scheduler.schedule('stage1.cpu', { cpu: 1 }, async () => 'missing-signal'), + (error) => ( + error?.code === 'SCHEDULER_SIGNAL_REQUIRED' + && error?.meta?.queueName === 'stage1.cpu' + ), + 'expected required-signal queues to reject tasks without an AbortSignal' +); + +const controller = new AbortController(); +const allowed = await scheduler.schedule( + 'stage1.cpu', + { cpu: 1, signal: controller.signal }, + async () => 'with-signal' +); +assert.equal(allowed, 'with-signal', 'expected required-signal queue to run when a signal is present'); + +const unrestricted = await scheduler.schedule('stage1.io', { io: 1 }, async () => 'no-signal-needed'); +assert.equal(unrestricted, 'no-signal-needed', 'expected unrestricted queues to run without a signal'); + +scheduler.shutdown(); + +console.log('scheduler required signal test passed'); diff --git a/tests/shared/concurrency/scheduler-shutdown-drain.test.js b/tests/shared/concurrency/scheduler-shutdown-drain.test.js new file mode 100644 index 000000000..863850686 --- /dev/null +++ b/tests/shared/concurrency/scheduler-shutdown-drain.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +{ + const scheduler = createBuildScheduler({ + cpuTokens: 1, + ioTokens: 1, + memoryTokens: 1 + }); + scheduler.registerQueue('drain', { priority: 10 }); + + let releaseFirst = null; + const first = scheduler.schedule('drain', { cpu: 1 }, async () => ( + new Promise((resolve) => { + releaseFirst = resolve; + }) + )); + await sleep(5); + const second = scheduler.schedule('drain', { cpu: 1 }, async () => 'second'); + const secondOutcome = second.then( + (value) => ({ ok: true, value }), + (error) => ({ ok: false, error }) + ); + const shutdownPromise = scheduler.shutdown({ awaitRunning: true, timeoutMs: 1000 }); + setTimeout(() => { + releaseFirst?.('first'); + }, 20); + await shutdownPromise; + + assert.equal(await first, 'first', 'expected in-flight task to finish during shutdown drain'); + const secondSettled = await secondOutcome; + assert.equal(secondSettled?.ok, false, 'expected pending queued work to fail on shutdown'); + assert.match( + String(secondSettled?.error?.message || ''), + /scheduler shutdown/, + 'expected pending queued work to be rejected on shutdown' + ); + await assert.rejects( + () => scheduler.schedule('drain', { cpu: 1 }, async () => null), + /shut down/, + 'expected new scheduling to be rejected after shutdown' + ); +} + +{ + const scheduler = createBuildScheduler({ + cpuTokens: 1, + ioTokens: 1, + memoryTokens: 1 + }); + scheduler.registerQueue('timeout', { priority: 10 }); + + let release = null; + const running = scheduler.schedule('timeout', { cpu: 1 }, async () => ( + new Promise((resolve) => { + release = resolve; + }) + )); + + const startedAt = Date.now(); + await scheduler.shutdown({ awaitRunning: true, timeoutMs: 25 }); + const elapsedMs = Date.now() - startedAt; + assert(elapsedMs >= 10 && elapsedMs < 500, 'expected bounded shutdown wait window'); + + release?.('ok'); + assert.equal(await running, 'ok', 'expected timed-out shutdown wait to not cancel in-flight work'); +} + +console.log('scheduler shutdown drain test passed'); diff --git a/tests/shared/concurrency/scheduler-stage1-io-postings-nested-deadlock.test.js b/tests/shared/concurrency/scheduler-stage1-io-postings-nested-deadlock.test.js index ef07cb037..954dd5168 100644 --- a/tests/shared/concurrency/scheduler-stage1-io-postings-nested-deadlock.test.js +++ b/tests/shared/concurrency/scheduler-stage1-io-postings-nested-deadlock.test.js @@ -1,85 +1,42 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { - createBuildScheduler, - createSchedulerQueueAdapter -} from '../../../src/shared/concurrency.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const scheduler = createBuildScheduler({ - adaptive: true, - adaptiveIntervalMs: 1, - cpuTokens: 8, - ioTokens: 8, - memoryTokens: 8, - queues: { - 'stage1.cpu': { priority: 10, surface: 'parse' } - }, - adaptiveSurfaces: { - enabled: true, - parse: { - minConcurrency: 2, - maxConcurrency: 2, - initialConcurrency: 2, - upCooldownMs: 0, - downCooldownMs: 0, - oscillationGuardMs: 0 - } - } -}); - -const cpuQueue = createSchedulerQueueAdapter({ - scheduler, - queueName: 'stage1.cpu', - tokens: { cpu: 1 }, - concurrency: 2 -}); -const ioQueue = createSchedulerQueueAdapter({ - scheduler, - queueName: 'stage1.io', - tokens: { io: 1 }, - concurrency: 2 -}); -const postingsQueue = createSchedulerQueueAdapter({ - scheduler, - queueName: 'stage1.postings', - tokens: { mem: 1 }, - concurrency: 2 -}); + runStage1NestedSchedulerProbe, + sleep +} from './stage1-nested-scheduler-fixture.js'; let ioTasksStarted = 0; let postingsTasksStarted = 0; -const nested = Promise.all([ - cpuQueue.add(async () => { - await ioQueue.add(async () => { - ioTasksStarted += 1; - await sleep(5); - }); - await postingsQueue.add(async () => { - postingsTasksStarted += 1; - await sleep(5); - }); - return 'c1'; - }), - cpuQueue.add(async () => { - await ioQueue.add(async () => { - ioTasksStarted += 1; - await sleep(5); - }); - await postingsQueue.add(async () => { - postingsTasksStarted += 1; - await sleep(5); - }); - return 'c2'; - }) -]); - -const timeoutResult = Symbol('timeout'); -const result = await Promise.race([ - nested, - sleep(750).then(() => timeoutResult) -]); +const { result, timeoutResult } = await runStage1NestedSchedulerProbe( + [ + { key: 'ioQueue', queueName: 'stage1.io', tokens: { io: 1 } }, + { key: 'postingsQueue', queueName: 'stage1.postings', tokens: { mem: 1 } } + ], + ({ cpuQueue, queues: { ioQueue, postingsQueue } }) => [ + cpuQueue.add(async () => { + await ioQueue.add(async () => { + ioTasksStarted += 1; + await sleep(5); + }); + await postingsQueue.add(async () => { + postingsTasksStarted += 1; + await sleep(5); + }); + return 'c1'; + }), + cpuQueue.add(async () => { + await ioQueue.add(async () => { + ioTasksStarted += 1; + await sleep(5); + }); + await postingsQueue.add(async () => { + postingsTasksStarted += 1; + await sleep(5); + }); + return 'c2'; + }) + ] +); assert.notEqual( result, @@ -89,6 +46,4 @@ assert.notEqual( assert.equal(ioTasksStarted, 2, 'expected both nested stage1.io tasks to run'); assert.equal(postingsTasksStarted, 2, 'expected both nested stage1.postings tasks to run'); -scheduler.shutdown(); - console.log('scheduler stage1.io/stage1.postings nested deadlock test passed'); diff --git a/tests/shared/concurrency/scheduler-stage1-proc-nested-deadlock.test.js b/tests/shared/concurrency/scheduler-stage1-proc-nested-deadlock.test.js index 8036a8a4b..6e9278ca0 100644 --- a/tests/shared/concurrency/scheduler-stage1-proc-nested-deadlock.test.js +++ b/tests/shared/concurrency/scheduler-stage1-proc-nested-deadlock.test.js @@ -1,76 +1,36 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { - createBuildScheduler, - createSchedulerQueueAdapter -} from '../../../src/shared/concurrency.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const scheduler = createBuildScheduler({ - adaptive: true, - adaptiveIntervalMs: 1, - cpuTokens: 8, - ioTokens: 8, - memoryTokens: 8, - queues: { - 'stage1.cpu': { priority: 10, surface: 'parse' } - }, - adaptiveSurfaces: { - enabled: true, - parse: { - minConcurrency: 2, - maxConcurrency: 2, - initialConcurrency: 2, - upCooldownMs: 0, - downCooldownMs: 0, - oscillationGuardMs: 0 - } - } -}); - -const cpuQueue = createSchedulerQueueAdapter({ - scheduler, - queueName: 'stage1.cpu', - tokens: { cpu: 1 }, - concurrency: 2 -}); -const procQueue = createSchedulerQueueAdapter({ - scheduler, - queueName: 'stage1.proc', - tokens: { mem: 1 }, - concurrency: 2 -}); + runStage1NestedSchedulerProbe, + sleep +} from './stage1-nested-scheduler-fixture.js'; let procTasksStarted = 0; -const nested = Promise.all([ - cpuQueue.add(async () => { - await procQueue.add(async () => { - procTasksStarted += 1; - await sleep(5); - return 'p1'; - }); - return 'c1'; - }), - cpuQueue.add(async () => { - await procQueue.add(async () => { - procTasksStarted += 1; - await sleep(5); - return 'p2'; - }); - return 'c2'; - }) -]); - -const timeoutResult = Symbol('timeout'); -const result = await Promise.race([ - nested, - sleep(750).then(() => timeoutResult) -]); +const { result, timeoutResult } = await runStage1NestedSchedulerProbe( + [ + { key: 'procQueue', queueName: 'stage1.proc', tokens: { mem: 1 } } + ], + ({ cpuQueue, queues: { procQueue } }) => [ + cpuQueue.add(async () => { + await procQueue.add(async () => { + procTasksStarted += 1; + await sleep(5); + return 'p1'; + }); + return 'c1'; + }), + cpuQueue.add(async () => { + await procQueue.add(async () => { + procTasksStarted += 1; + await sleep(5); + return 'p2'; + }); + return 'c2'; + }) + ] +); assert.notEqual(result, timeoutResult, 'nested stage1.proc tasks should not deadlock under parse cap'); assert.equal(procTasksStarted, 2, 'expected both nested stage1.proc tasks to run'); -scheduler.shutdown(); - console.log('scheduler stage1.proc nested deadlock test passed'); diff --git a/tests/shared/concurrency/scheduler-wait-aging.test.js b/tests/shared/concurrency/scheduler-wait-aging.test.js index f1a48f7bd..c78a15e9e 100644 --- a/tests/shared/concurrency/scheduler-wait-aging.test.js +++ b/tests/shared/concurrency/scheduler-wait-aging.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/shared/concurrency/scheduler-write-backpressure.test.js b/tests/shared/concurrency/scheduler-write-backpressure.test.js index c2dd33531..269294773 100644 --- a/tests/shared/concurrency/scheduler-write-backpressure.test.js +++ b/tests/shared/concurrency/scheduler-write-backpressure.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; import { applyTestEnv } from '../../helpers/test-env.js'; applyTestEnv(); diff --git a/tests/shared/concurrency/stage1-nested-scheduler-fixture.js b/tests/shared/concurrency/stage1-nested-scheduler-fixture.js new file mode 100644 index 000000000..b03c2f332 --- /dev/null +++ b/tests/shared/concurrency/stage1-nested-scheduler-fixture.js @@ -0,0 +1,61 @@ +import { createSchedulerQueueAdapter } from '../../../src/shared/concurrency/queue-adapter.js'; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; + +export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const createStage1NestedSchedulerFixture = (queueSpecs = []) => { + const scheduler = createBuildScheduler({ + adaptive: true, + adaptiveIntervalMs: 1, + cpuTokens: 8, + ioTokens: 8, + memoryTokens: 8, + queues: { + 'stage1.cpu': { priority: 10, surface: 'parse' } + }, + adaptiveSurfaces: { + enabled: true, + parse: { + minConcurrency: 2, + maxConcurrency: 2, + initialConcurrency: 2, + upCooldownMs: 0, + downCooldownMs: 0, + oscillationGuardMs: 0 + } + } + }); + const createQueue = ({ queueName, tokens }) => createSchedulerQueueAdapter({ + scheduler, + queueName, + tokens, + concurrency: 2 + }); + + return { + scheduler, + cpuQueue: createQueue({ queueName: 'stage1.cpu', tokens: { cpu: 1 } }), + queues: Object.fromEntries( + queueSpecs.map((spec) => [spec.key || spec.queueName, createQueue(spec)]) + ) + }; +}; + +export const raceSchedulerTimeout = async (promise, timeoutMs = 750) => { + const timeoutResult = Symbol('timeout'); + const result = await Promise.race([ + promise, + sleep(timeoutMs).then(() => timeoutResult) + ]); + return { result, timeoutResult }; +}; + +export const runStage1NestedSchedulerProbe = async (queueSpecs, createTasks, { timeoutMs } = {}) => { + const fixture = createStage1NestedSchedulerFixture(queueSpecs); + try { + const nested = Promise.all(createTasks(fixture)); + return await raceSchedulerTimeout(nested, timeoutMs); + } finally { + fixture.scheduler.shutdown(); + } +}; diff --git a/tests/shared/concurrency/worker-fd-concurrency-cap.test.js b/tests/shared/concurrency/worker-fd-cap.test.js similarity index 100% rename from tests/shared/concurrency/worker-fd-concurrency-cap.test.js rename to tests/shared/concurrency/worker-fd-cap.test.js diff --git a/tests/shared/config/auto-policy-scan-logging.test.js b/tests/shared/config/auto-policy-scan-logging.test.js deleted file mode 100644 index 30daf51c0..000000000 --- a/tests/shared/config/auto-policy-scan-logging.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildAutoPolicy } from '../../../src/shared/auto-policy.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'auto-policy-scan-logging'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.mkdir(path.join(tempRoot, 'node_modules', 'pkg'), { recursive: true }); -await fs.mkdir(path.join(tempRoot, 'benchmarks', 'repos', 'fixture'), { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'README.md'), '# fixture\n', 'utf8'); -await fs.writeFile(path.join(tempRoot, 'src', 'index.js'), 'export const value = 1;\n', 'utf8'); -await fs.writeFile(path.join(tempRoot, 'node_modules', 'pkg', 'ignored.js'), 'module.exports = 1;\n', 'utf8'); -await fs.writeFile(path.join(tempRoot, 'benchmarks', 'repos', 'fixture', 'ignored.txt'), 'ignore me\n', 'utf8'); -await fs.writeFile(path.join(tempRoot, '.gitignore'), 'benchmarks/\n', 'utf8'); - -const logs = []; -const policy = await buildAutoPolicy({ - repoRoot: tempRoot, - config: { quality: 'max' }, - resources: { cpuCount: 16, memoryGb: 64 }, - scanLimits: { statConcurrency: 4 }, - logger: (line) => logs.push(String(line || '')) -}); - -assert.equal(policy.quality.value, 'max', 'expected explicit max quality to be preserved'); -assert.equal(policy.repo.fileCount, 2, `expected ignored directories to be skipped, got ${policy.repo.fileCount}`); -assert.ok( - logs.some((line) => line.includes('loaded ignore files') && line.includes('.gitignore')), - `expected .gitignore load log, got: ${logs.join(' | ')}` -); -assert.ok( - logs.some((line) => line.includes('auto policy scan: starting')), - `expected scan start log, got: ${logs.join(' | ')}` -); -assert.ok( - logs.some((line) => line.includes('auto policy scan: done in')), - `expected scan completion log, got: ${logs.join(' | ')}` -); -assert.ok( - logs.some((line) => line.includes('auto policy resolved:')), - `expected policy summary log, got: ${logs.join(' | ')}` -); - -console.log('auto policy scan logging test passed'); diff --git a/tests/shared/config/auto-policy.test.js b/tests/shared/config/auto-policy.test.js deleted file mode 100644 index ee90db2e0..000000000 --- a/tests/shared/config/auto-policy.test.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -import { buildAutoPolicy } from '../../../src/shared/auto-policy.js'; - -const assertEqual = (label, actual, expected) => { - if (actual !== expected) { - console.error(`${label} expected ${expected}, got ${actual}`); - process.exit(1); - } -}; - -const baseRepo = { fileCount: 1000, totalBytes: 1024, truncated: false, huge: false }; - -const fastPolicy = await buildAutoPolicy({ - config: { quality: 'auto' }, - resources: { cpuCount: 4, memoryGb: 8 }, - repo: baseRepo -}); -assertEqual('auto quality on low resources', fastPolicy.quality.value, 'fast'); - -const hugePolicy = await buildAutoPolicy({ - config: { quality: 'auto' }, - resources: { cpuCount: 16, memoryGb: 64 }, - repo: { ...baseRepo, huge: true } -}); -assertEqual('auto quality on huge repo', hugePolicy.quality.value, 'balanced'); -assertEqual('huge profile id', hugePolicy?.indexing?.hugeRepoProfile?.id, 'huge-repo'); -assertEqual( - 'huge profile write queue weight', - hugePolicy?.indexing?.hugeRepoProfile?.overrides?.scheduler?.queues?.['stage2.write']?.weight, - 5 -); -assertEqual( - 'huge profile sqlite queue weight', - hugePolicy?.indexing?.hugeRepoProfile?.overrides?.scheduler?.queues?.['stage4.sqlite']?.weight, - 5 -); -assertEqual( - 'huge profile overlap enabled', - hugePolicy?.indexing?.hugeRepoProfile?.overrides?.pipelineOverlap?.enabled, - true -); -assertEqual( - 'huge profile disables extracted prose', - hugePolicy?.indexing?.hugeRepoProfile?.overrides?.documentExtraction?.enabled, - false -); -assertEqual( - 'huge profile disables cross-file type inference', - hugePolicy?.indexing?.hugeRepoProfile?.overrides?.typeInferenceCrossFile, - false -); - -const explicitPolicy = await buildAutoPolicy({ - config: { quality: 'balanced' }, - resources: { cpuCount: 16, memoryGb: 64 }, - repo: { ...baseRepo, huge: true } -}); -assertEqual('explicit quality override', explicitPolicy.quality.value, 'balanced'); - -console.log('auto policy test passed'); diff --git a/tests/shared/config/config-normalization-quality-threads.test.js b/tests/shared/config/config-normalization-quality-threads.test.js deleted file mode 100644 index 68d2fb3a6..000000000 --- a/tests/shared/config/config-normalization-quality-threads.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { buildAutoPolicy } from '../../../src/shared/auto-policy.js'; -import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'config-normalization'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const configPath = path.join(tempRoot, '.pairofcleats.json'); -await fs.writeFile( - configPath, - JSON.stringify({ quality: 'fast', threads: 6 }, null, 2) -); - -const userConfig = loadUserConfig(tempRoot); -if (userConfig.quality !== 'fast') { - console.error(`expected quality to be preserved, got ${userConfig.quality}`); - process.exit(1); -} -if (userConfig.threads !== 6) { - console.error(`expected threads to be preserved, got ${userConfig.threads}`); - process.exit(1); -} - -const policy = await buildAutoPolicy({ - config: userConfig, - resources: { cpuCount: 8, memoryGb: 16 }, - repo: { fileCount: 100, totalBytes: 1024, truncated: false, huge: false } -}); -if (policy.quality.value !== 'fast') { - console.error(`expected auto policy to use config quality, got ${policy.quality.value}`); - process.exit(1); -} - -const envelope = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig, - autoPolicy: policy, - env: {}, - execArgv: [], - cpuCount: 8, - processInfo: { pid: 1, argv: [], execPath: 'node', nodeVersion: 'v0.0.0', platform: 'test', arch: 'x64', cpuCount: 8 }, - toolVersion: 'test' -}); - -if (envelope.concurrency.threads.value !== 6) { - console.error(`expected runtime envelope threads to be 6, got ${envelope.concurrency.threads.value}`); - process.exit(1); -} - -console.log('config normalization quality/threads test passed'); diff --git a/tests/shared/config/contract-matrix.test.js b/tests/shared/config/contract-matrix.test.js new file mode 100644 index 000000000..52d5207f9 --- /dev/null +++ b/tests/shared/config/contract-matrix.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildAutoPolicy } from '../../../src/shared/auto-policy/build.js'; +import { validateConfig } from '../../../src/config/validate.js'; +import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope/resolve.js'; +import { loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'shared-config-contract-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +{ + const scanRoot = path.join(tempRoot, 'auto-policy-scan'); + await fs.mkdir(path.join(scanRoot, 'src'), { recursive: true }); + await fs.mkdir(path.join(scanRoot, 'node_modules', 'pkg'), { recursive: true }); + await fs.mkdir(path.join(scanRoot, 'benchmarks', 'repos', 'fixture'), { recursive: true }); + await fs.writeFile(path.join(scanRoot, 'README.md'), '# fixture\n', 'utf8'); + await fs.writeFile(path.join(scanRoot, 'src', 'index.js'), 'export const value = 1;\n', 'utf8'); + await fs.writeFile(path.join(scanRoot, 'node_modules', 'pkg', 'ignored.js'), 'module.exports = 1;\n', 'utf8'); + await fs.writeFile(path.join(scanRoot, 'benchmarks', 'repos', 'fixture', 'ignored.txt'), 'ignore me\n', 'utf8'); + await fs.writeFile(path.join(scanRoot, '.gitignore'), 'benchmarks/\n', 'utf8'); + + const logs = []; + const policy = await buildAutoPolicy({ + repoRoot: scanRoot, + config: { quality: 'max' }, + resources: { cpuCount: 16, memoryGb: 64 }, + scanLimits: { statConcurrency: 4 }, + logger: (line) => logs.push(String(line || '')) + }); + assert.equal(policy.quality.value, 'max'); + assert.equal(policy.repo.fileCount, 2); + assert.ok(logs.some((line) => line.includes('loaded ignore files') && line.includes('.gitignore'))); + assert.ok(logs.some((line) => line.includes('auto policy scan: starting'))); + assert.ok(logs.some((line) => line.includes('auto policy scan: done in'))); + assert.ok(logs.some((line) => line.includes('auto policy resolved:'))); +} + +{ + const baseRepo = { fileCount: 1000, totalBytes: 1024, truncated: false, huge: false }; + const fastPolicy = await buildAutoPolicy({ + config: { quality: 'auto' }, + resources: { cpuCount: 4, memoryGb: 8 }, + repo: baseRepo + }); + assert.equal(fastPolicy.quality.value, 'fast'); + + const hugePolicy = await buildAutoPolicy({ + config: { quality: 'auto' }, + resources: { cpuCount: 16, memoryGb: 64 }, + repo: { ...baseRepo, huge: true } + }); + assert.equal(hugePolicy.quality.value, 'balanced'); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.id, 'huge-repo'); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.overrides?.scheduler?.queues?.['stage2.write']?.weight, 5); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.overrides?.scheduler?.queues?.['stage4.sqlite']?.weight, 5); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.overrides?.pipelineOverlap?.enabled, true); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.overrides?.documentExtraction?.enabled, false); + assert.equal(hugePolicy?.indexing?.hugeRepoProfile?.overrides?.typeInferenceCrossFile, false); + + const explicitPolicy = await buildAutoPolicy({ + config: { quality: 'balanced' }, + resources: { cpuCount: 16, memoryGb: 64 }, + repo: { ...baseRepo, huge: true } + }); + assert.equal(explicitPolicy.quality.value, 'balanced'); +} + +{ + const configRoot = path.join(tempRoot, 'normalization'); + await fs.mkdir(configRoot, { recursive: true }); + await fs.writeFile(path.join(configRoot, '.pairofcleats.json'), JSON.stringify({ + quality: 'fast', + threads: 6, + search: { hyperlinks: 'vscode' } + }, null, 2)); + + const userConfig = loadUserConfig(configRoot); + assert.equal(userConfig.quality, 'fast'); + assert.equal(userConfig.threads, 6); + assert.equal(userConfig?.search?.hyperlinks, 'vscode'); + + const policy = await buildAutoPolicy({ + config: userConfig, + resources: { cpuCount: 8, memoryGb: 16 }, + repo: { fileCount: 100, totalBytes: 1024, truncated: false, huge: false } + }); + assert.equal(policy.quality.value, 'fast'); + + const envelope = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig, + autoPolicy: policy, + env: {}, + execArgv: [], + cpuCount: 8, + processInfo: { pid: 1, argv: [], execPath: 'node', nodeVersion: 'v0.0.0', platform: 'test', arch: 'x64', cpuCount: 8 }, + toolVersion: 'test' + }); + assert.equal(envelope.concurrency.threads.value, 6); +} + +{ + const schema = { + type: 'object', + required: ['alpha'], + additionalProperties: false + }; + const result = validateConfig(schema, { beta: 1 }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((err) => err.includes('#/alpha is required'))); + assert.ok(result.errors.some((err) => err.includes('#/beta is not allowed'))); + assert.equal(validateConfig(schema, { alpha: 1 }).ok, true); +} + +console.log('shared config contract matrix test passed'); diff --git a/tests/shared/config/validate-object-without-properties.test.js b/tests/shared/config/validate-object-without-properties.test.js deleted file mode 100644 index e8cfedffd..000000000 --- a/tests/shared/config/validate-object-without-properties.test.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { validateConfig } from '../../../src/config/validate.js'; - -const schema = { - type: 'object', - required: ['alpha'], - additionalProperties: false -}; - -const result = validateConfig(schema, { beta: 1 }); -assert.equal(result.ok, false, 'expected validation to fail'); -assert.ok(result.errors.some((err) => err.includes('#/alpha is required'))); -assert.ok(result.errors.some((err) => err.includes('#/beta is not allowed'))); - -const okResult = validateConfig(schema, { alpha: 1 }); -assert.equal(okResult.ok, true, 'expected validation to pass when required key present'); - -console.log('config validate object without properties test passed'); diff --git a/tests/shared/context-pack-request-contract.test.js b/tests/shared/context-pack-request-contract.test.js new file mode 100644 index 000000000..1de00b163 --- /dev/null +++ b/tests/shared/context-pack-request-contract.test.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildCliContextPackRequestInput, + buildContextPackRequestInput +} from '../../src/shared/context-pack-request.js'; + +const source = { + seed: 'chunk:abc123', + hops: 2, + includeGraph: false, + includeTypes: true, + includeRisk: true, + includeRiskPartialFlows: true, + strictRisk: true, + strictEvidence: true, + filters: { + severity: 'high', + sourceRule: 'source.request' + }, + includeImports: false, + includeUsages: true, + includeCallersCallees: false, + includePaths: true, + maxBytes: 4096, + maxTokens: 512, + maxTypeEntries: 8, + maxDepth: 3, + maxFanoutPerNode: 4, + maxNodes: 30, + maxEdges: 40, + maxPaths: 5, + maxCandidates: 20, + maxWorkUnits: 100, + maxWallClockMs: 2500, + workspacePath: 'C:\\workspace\\.pairofcleats-workspace.jsonc', + workspaceId: 'workspace-test', + select: { + repoFilter: ['alpha'] + }, + repoFilter: 'ignored-top-level-api-filter', + includeDisabled: true, + maxFederatedRepos: 2 +}; + +const workspaceConfig = { + repoSetId: 'workspace-test', + workspacePath: source.workspacePath, + repos: [] +}; + +const apiRequest = buildContextPackRequestInput(source, { + repoRoot: 'C:\\repo', + riskFilters: source.filters || null, + workspaceConfig +}); + +assert.equal(apiRequest.repoRoot, 'C:\\repo'); +assert.equal(apiRequest.seed, source.seed); +assert.equal(apiRequest.hops, source.hops); +assert.equal(apiRequest.includeRiskPartialFlows, true); +assert.equal(apiRequest.strictEvidence, true); +assert.deepEqual(apiRequest.riskFilters, source.filters); +assert.equal(apiRequest.workspacePath, source.workspacePath); +assert.equal(apiRequest.workspaceId, source.workspaceId); +assert.deepEqual(apiRequest.select, source.select); +assert.equal(apiRequest.includeDisabled, true); +assert.equal(apiRequest.maxFederatedRepos, 2); +assert.equal(apiRequest.workspaceConfig, workspaceConfig); +assert.equal( + Object.hasOwn(apiRequest, 'repoFilter'), + false, + 'API/MCP projection should not add top-level repoFilter unless a surface explicitly opts in' +); + +const mcpRequest = buildContextPackRequestInput(source, { + repoRoot: 'C:\\repo', + riskFilters: source.filters || null +}); + +assert.deepEqual( + { + ...mcpRequest, + workspaceConfig + }, + apiRequest, + 'API and MCP projections should match for shared context-pack request fields' +); + +const cliRequest = buildCliContextPackRequestInput({ + seed: 'symbol:render', + hops: 1, + includeRisk: true, + includeRiskPartialFlows: true, + workspace: 'C:\\workspace\\.pairofcleats-workspace.jsonc', + workspaceId: 'workspace-test', + select: ['alpha'], + 'repo-filter': 'alpha', + includeDisabled: true, + maxFederatedRepos: 3, + severity: 'critical', + 'source-rule': 'source.request', + 'flow-id': 'flow-1' +}, { + repoRoot: 'C:\\repo' +}); + +assert.equal(cliRequest.repoRoot, 'C:\\repo'); +assert.equal(cliRequest.workspacePath, 'C:\\workspace\\.pairofcleats-workspace.jsonc'); +assert.equal(cliRequest.repoFilter, 'alpha'); +assert.deepEqual(cliRequest.select, ['alpha']); +assert.equal(cliRequest.includeDisabled, true); +assert.equal(cliRequest.maxFederatedRepos, 3); +assert.deepEqual(cliRequest.riskFilters, { + rule: undefined, + category: undefined, + severity: 'critical', + tag: undefined, + source: undefined, + sink: undefined, + flowId: 'flow-1', + sourceRule: 'source.request', + sinkRule: undefined +}); + +console.log('context-pack request contract test passed'); diff --git a/tests/shared/contracts/analysis-schemas-modularization.test.js b/tests/shared/contracts/analysis-schemas-modularization.test.js new file mode 100644 index 000000000..b17e51a7a --- /dev/null +++ b/tests/shared/contracts/analysis-schemas-modularization.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const analysisBarrelPath = path.join(root, 'src', 'contracts', 'schemas', 'analysis.js'); +const analysisIndexPath = path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'index.js'); +const modulePaths = [ + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'primitives.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'metadata.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'policy.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'graph.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'risk.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'context-pack.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'api.js'), + path.join(root, 'src', 'contracts', 'schemas', 'analysis', 'architecture.js') +]; + +for (const target of [analysisBarrelPath, analysisIndexPath, ...modulePaths]) { + assert.equal(fs.existsSync(target), true, `missing expected analysis schema module: ${target}`); +} + +const analysisBarrelSource = fs.readFileSync(analysisBarrelPath, 'utf8'); +const analysisIndexSource = fs.readFileSync(analysisIndexPath, 'utf8'); + +assert.equal( + analysisBarrelSource.includes("./analysis/index.js"), + true, + 'expected top-level analysis schema file to re-export the modularized index' +); + +for (const marker of [ + "./metadata.js", + "./policy.js", + "./graph.js", + "./risk.js", + "./context-pack.js", + "./api.js", + "./architecture.js", + 'METADATA_V2_SCHEMA', + 'RISK_RULES_BUNDLE_SCHEMA', + 'ANALYSIS_POLICY_SCHEMA', + 'GRAPH_CONTEXT_PACK_SCHEMA', + 'GRAPH_IMPACT_SCHEMA', + 'RISK_DELTA_SCHEMA', + 'COMPOSITE_CONTEXT_PACK_SCHEMA', + 'API_CONTRACTS_SCHEMA', + 'ARCHITECTURE_REPORT_SCHEMA', + 'SUGGEST_TESTS_SCHEMA' +]) { + assert.equal( + analysisIndexSource.includes(marker), + true, + `expected analysis schema index to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'export const METADATA_V2_SCHEMA = {', + 'export const RISK_RULES_BUNDLE_SCHEMA = {', + 'export const ANALYSIS_POLICY_SCHEMA = {', + 'export const GRAPH_CONTEXT_PACK_SCHEMA = {', + 'export const GRAPH_IMPACT_SCHEMA = {', + 'export const RISK_DELTA_SCHEMA = {', + 'export const COMPOSITE_CONTEXT_PACK_SCHEMA = {', + 'export const API_CONTRACTS_SCHEMA = {', + 'export const ARCHITECTURE_REPORT_SCHEMA = {', + 'export const SUGGEST_TESTS_SCHEMA = {' +]) { + assert.equal( + analysisBarrelSource.includes(legacyInlineMarker), + false, + `expected top-level analysis schema barrel to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('analysis schema modularization test passed'); diff --git a/tests/shared/contracts/analysis-schemas-validate.test.js b/tests/shared/contracts/analysis-schemas-validate.test.js index f9988a463..362d047b6 100644 --- a/tests/shared/contracts/analysis-schemas-validate.test.js +++ b/tests/shared/contracts/analysis-schemas-validate.test.js @@ -3,11 +3,18 @@ import assert from 'node:assert/strict'; import { validateGraphContextPack, validateGraphImpact, + validateRiskDelta, validateCompositeContextPack, validateApiContracts, validateArchitectureReport, validateSuggestTests } from '../../../src/contracts/validators/analysis.js'; +import { + CONTEXT_PACK_RISK_CONTRACT_VERSION, + CONTEXT_PACK_RISK_SCHEMA_VERSION +} from '../../../src/contracts/context-pack-risk-contract.js'; +import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; +import { createRiskWatchStep } from '../../helpers/risk-explanation-fixtures.js'; const provenance = { generatedAt: '2026-02-01T00:00:00Z', @@ -41,7 +48,382 @@ const compositeContextPack = { primary: { ref: seed, file: 'src/app.js', - excerpt: 'const alpha = 1;' + excerpt: 'const alpha = 1;', + excerptHash: 'sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + }, + evidence: { + schemaVersion: 1, + policy: { + strictEvidence: false + }, + primary: { + state: 'file-backed', + source: 'file', + fileBacked: true, + substituted: false, + missing: false, + truncated: false, + truncatedBytes: false, + truncatedTokens: false, + warningCodes: [] + }, + types: { + included: true, + state: 'complete', + count: 1, + truncated: false, + warningCodes: [] + }, + complete: true + }, + risk: { + version: CONTEXT_PACK_RISK_SCHEMA_VERSION, + contractVersion: CONTEXT_PACK_RISK_CONTRACT_VERSION, + status: 'ok', + reason: null, + anchor: { + kind: 'source', + chunkUid: 'chunk-1', + ref: seed, + alternateCount: 0, + alternates: [] + }, + summary: { + chunkUid: 'chunk-1', + file: 'src/app.js', + languageId: 'javascript', + symbol: null, + totals: { + sources: 1, + sinks: 1, + sanitizers: 0, + localFlows: 0 + }, + truncated: { + sources: false, + sinks: false, + sanitizers: false, + localFlows: false, + evidence: false + }, + topCategories: [], + topTags: [], + previewFlowIds: ['sha1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'] + }, + stats: { + status: 'ok', + reason: null, + summaryOnly: false, + flowsEmitted: 1, + partialFlowsEmitted: 1, + summariesEmitted: 1, + uniqueCallSitesReferenced: 1, + capsHit: [], + callSiteSampling: { + strategy: 'firstN' + }, + effectiveConfig: { + enabled: true, + summaryOnly: false + } + }, + analysisStatus: { + requested: true, + status: 'ok', + reason: null, + degraded: false, + summaryOnly: false, + code: 'ok', + strictFailure: false, + artifactStatus: { + stats: 'present', + summaries: 'present', + flows: 'present', + partialFlows: 'present', + callSites: 'present' + }, + degradedReasons: [] + }, + caps: { + maxFlows: 5, + maxPartialFlows: 3, + maxStepsPerFlow: 8, + maxCallSitesPerStep: 3, + maxBytes: 24576, + maxTokens: 2048, + maxPartialBytes: 4096, + maxPartialTokens: 512, + hits: [], + observed: { + candidateFlows: 1, + selectedFlows: 1, + selectedPartialFlows: 1, + omittedPartialFlows: 0, + omittedFlows: 0, + emittedSteps: 1, + omittedSteps: 0, + omittedCallSites: 0, + bytes: 100, + tokens: 10 + } + }, + truncation: [], + provenance: { + manifestVersion: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + compatibilityKey: 'compat-0001', + indexSignature: 'sig-0001', + indexCompatKey: 'compat-0001', + mode: 'code', + generatedAt: '2026-02-01T00:00:00Z', + ruleBundle: { + version: '1.0.0', + fingerprint: 'sha1:cccccccccccccccccccccccccccccccccccccccc', + provenance: { + defaults: true, + sourcePath: null + } + }, + effectiveConfigFingerprint: 'sha1:dddddddddddddddddddddddddddddddddddddddd', + artifacts: { + stats: 'present', + summaries: 'present', + flows: 'present', + callSites: 'present' + }, + artifactRefs: { + stats: { + name: 'risk_interprocedural_stats', + format: 'json', + sharded: false, + entrypoint: 'risk_interprocedural_stats.json', + totalEntries: 1 + }, + summaries: { + name: 'risk_summaries', + format: 'jsonl', + sharded: false, + entrypoint: 'risk_summaries.jsonl', + totalEntries: 1 + }, + flows: { + name: 'risk_flows', + format: 'jsonl', + sharded: false, + entrypoint: 'risk_flows.jsonl', + totalEntries: 1 + }, + partialFlows: { + name: 'risk_partial_flows', + format: 'jsonl', + sharded: false, + entrypoint: 'risk_partial_flows.jsonl', + totalEntries: 1 + }, + callSites: { + name: 'call_sites', + format: 'jsonl', + sharded: false, + entrypoint: 'call_sites.jsonl', + totalEntries: 1 + } + } + }, + flows: [ + { + flowId: 'sha1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + rank: 1, + source: { + chunkUid: 'chunk-1', + ruleId: 'source.req.body', + ruleName: 'req.body', + ruleType: 'source', + category: 'input', + severity: 'low', + confidence: 0.6 + }, + sink: { + chunkUid: 'chunk-2', + ruleId: 'sink.sql.query', + ruleName: 'sql.query', + ruleType: 'sink', + category: 'sql', + severity: 'high', + confidence: 0.9 + }, + path: { + nodes: [{ type: 'chunk', chunkUid: 'chunk-1' }], + truncatedSteps: 0, + watchByStep: [createRiskWatchStep()] + }, + evidence: { + callSitesByStep: [] + }, + confidence: 0.8, + notes: { + hopCount: 1, + strictness: 'conservative', + sanitizerPolicy: 'terminate', + sanitizerBarriersHit: 0 + }, + score: { + seedRelevance: 3, + severity: 4, + confidence: 0.8, + hopCount: 1 + } + } + ], + partialFlows: [ + { + partialFlowId: 'sha1:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + source: { + chunkUid: 'chunk-1', + ruleId: 'source.req.body', + ruleName: 'req.body', + ruleType: 'source', + category: 'input', + severity: null, + confidence: 0.6 + }, + frontier: { + chunkUid: 'chunk-2', + terminalReason: 'maxDepth', + blockedExpansions: [] + }, + path: { + nodes: [ + { type: 'chunk', chunkUid: 'chunk-1' }, + { type: 'chunk', chunkUid: 'chunk-2' } + ], + callSiteIdsByStep: [[]], + watchByStep: [createRiskWatchStep()] + }, + confidence: 0.7, + notes: { + strictness: 'conservative', + sanitizerPolicy: 'terminate', + hopCount: 1, + sanitizerBarriersHit: 0, + terminalReason: 'maxDepth', + capsHit: ['maxDepth'] + } + } + ], + degraded: false + } +}; + +const riskDelta = { + version: '1.0.0', + seed, + filters: { + rule: [], + category: [], + severity: [], + tag: [], + source: [], + sink: [], + sourceRule: [], + sinkRule: [], + flowId: [] + }, + includePartialFlows: true, + from: { + requestedRef: 'snap:snap-old', + canonical: 'snap:snap-old', + identity: { type: 'snapshot', snapshotId: 'snap-old' }, + snapshot: null, + warnings: [], + seedStatus: 'resolved', + target: { + chunkUid: 'chunk-1', + file: 'src/app.js', + name: 'alpha', + kind: 'function' + }, + summary: compositeContextPack.risk.summary, + stats: compositeContextPack.risk.stats, + provenance: { + manifestVersion: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + indexIdentity: { type: 'snapshot', snapshotId: 'snap-old' }, + ruleBundle: compositeContextPack.risk.provenance.ruleBundle, + artifacts: compositeContextPack.risk.provenance.artifacts + }, + flows: compositeContextPack.risk.flows, + partialFlows: compositeContextPack.risk.partialFlows + }, + to: { + requestedRef: 'snap:snap-new', + canonical: 'snap:snap-new', + identity: { type: 'snapshot', snapshotId: 'snap-new' }, + snapshot: null, + warnings: [], + seedStatus: 'resolved', + target: { + chunkUid: 'chunk-1', + file: 'src/app.js', + name: 'alpha', + kind: 'function' + }, + summary: compositeContextPack.risk.summary, + stats: compositeContextPack.risk.stats, + provenance: { + manifestVersion: 2, + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + indexIdentity: { type: 'snapshot', snapshotId: 'snap-new' }, + ruleBundle: compositeContextPack.risk.provenance.ruleBundle, + artifacts: compositeContextPack.risk.provenance.artifacts + }, + flows: compositeContextPack.risk.flows, + partialFlows: compositeContextPack.risk.partialFlows + }, + summary: { + flowCounts: { + from: 1, + to: 1, + added: 0, + removed: 0, + changed: 1, + unchanged: 0 + }, + partialFlowCounts: { + from: 1, + to: 1, + added: 0, + removed: 0, + changed: 1, + unchanged: 0 + } + }, + deltas: { + flows: { + added: [], + removed: [], + changed: [{ + flowId: compositeContextPack.risk.flows[0].flowId, + changedFields: ['confidence'], + beforeFingerprint: 'sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + afterFingerprint: 'sha1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + before: compositeContextPack.risk.flows[0], + after: { ...compositeContextPack.risk.flows[0], confidence: 0.9 } + }], + unchangedCount: 0 + }, + partialFlows: { + added: [], + removed: [], + changed: [{ + partialFlowId: compositeContextPack.risk.partialFlows[0].partialFlowId, + changedFields: ['confidence'], + beforeFingerprint: 'sha1:cccccccccccccccccccccccccccccccccccccccc', + afterFingerprint: 'sha1:dddddddddddddddddddddddddddddddddddddddd', + before: compositeContextPack.risk.partialFlows[0], + after: { ...compositeContextPack.risk.partialFlows[0], confidence: 0.8 } + }], + unchangedCount: 0 + } } }; @@ -71,12 +453,32 @@ const suggestTests = { version: '1.0.0', provenance, changed: [], - suggestions: [] + suggestions: [], + fidelity: { + schemaVersion: 1, + source: 'heuristic', + state: 'fallback', + reasonCodes: [], + graph: { + available: false, + used: false, + matchedSuggestions: 0, + visitedNodes: 0, + edgesVisited: 0, + workUnits: 0, + traversalCapsHit: [], + candidateTruncated: false + }, + heuristic: { + used: true + } + } }; const validators = [ ['graph context pack', validateGraphContextPack, graphContextPack], ['graph impact', validateGraphImpact, graphImpact], + ['risk delta', validateRiskDelta, riskDelta], ['composite context pack', validateCompositeContextPack, compositeContextPack], ['api contracts', validateApiContracts, apiContracts], ['architecture report', validateArchitectureReport, architectureReport], @@ -88,4 +490,33 @@ for (const [label, validator, payload] of validators) { assert.equal(result.ok, true, `expected ${label} to validate: ${result.errors.join(', ')}`); } +const graphImpactBoth = { + ...graphImpact, + direction: 'both' +}; +const graphImpactBothValidation = validateGraphImpact(graphImpactBoth); +assert.equal( + graphImpactBothValidation.ok, + true, + `expected graph impact both direction to validate: ${graphImpactBothValidation.errors.join(', ')}` +); + +const minimalEvidencePayload = structuredClone(compositeContextPack); +minimalEvidencePayload.evidence = { + schemaVersion: 1 +}; +minimalEvidencePayload.risk.futureField = { + statusDetail: 'forward-compatible' +}; +minimalEvidencePayload.evidence.futureField = { + producer: 'older-or-newer-runtime' +}; + +const minimalEvidenceValidation = validateCompositeContextPack(minimalEvidencePayload); +assert.equal( + minimalEvidenceValidation.ok, + true, + `expected minimal context-pack evidence to validate: ${minimalEvidenceValidation.errors.join(', ')}` +); + console.log('analysis schema validation tests passed'); diff --git a/tests/shared/dictionary/dictionary-pack-fallback.test.js b/tests/shared/dictionary/pack-fallback.test.js similarity index 100% rename from tests/shared/dictionary/dictionary-pack-fallback.test.js rename to tests/shared/dictionary/pack-fallback.test.js diff --git a/tests/shared/diffs-registry.test.js b/tests/shared/diffs-registry.test.js index 422acc5b7..c7db41033 100644 --- a/tests/shared/diffs-registry.test.js +++ b/tests/shared/diffs-registry.test.js @@ -3,7 +3,7 @@ import { applyTestEnv } from '../helpers/test-env.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { acquireIndexLock } from '../../src/index/build/lock.js'; +import { acquireRegistryLock } from '../../src/index/registry-lock.js'; import { createEmptyDiffsManifest, loadDiffInputs, @@ -67,13 +67,13 @@ await writeDiffSummary(repoCacheRoot, 'diff_test123', { assert.equal(loadDiffInputs(repoCacheRoot, 'diff_test123')?.id, 'diff_test123'); assert.equal(loadDiffSummary(repoCacheRoot, 'diff_test123')?.id, 'diff_test123'); -const lock = await acquireIndexLock({ repoCacheRoot, waitMs: 0 }); -assert.ok(lock, 'expected to acquire index lock'); +const lock = await acquireRegistryLock({ repoCacheRoot, domain: 'diffs', waitMs: 0 }); +assert.ok(lock, 'expected to acquire diff lock'); try { await assert.rejects( () => writeDiffsManifest(repoCacheRoot, createEmptyDiffsManifest(), { waitMs: 0 }), (err) => err?.code === 'QUEUE_OVERLOADED', - 'diff manifest writes should fail fast when lock is held' + 'diff manifest writes should fail fast when diff lock is held' ); } finally { await lock.release(); diff --git a/tests/shared/direct-execution-contract.test.js b/tests/shared/direct-execution-contract.test.js new file mode 100644 index 000000000..f51f99330 --- /dev/null +++ b/tests/shared/direct-execution-contract.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isDirectExecution } from '../../src/shared/direct-execution.js'; + +const selfPath = fileURLToPath(import.meta.url); + +assert.equal(isDirectExecution(import.meta.url, selfPath), true, 'expected current file path to be treated as direct execution'); +assert.equal(isDirectExecution(import.meta.url, path.join(path.dirname(selfPath), 'not-this-file.js')), false); +assert.equal(isDirectExecution(import.meta.url, null), false); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-direct-execution-')); +try { + const realFile = path.join(tempRoot, 'real-entry.js'); + const linkFile = path.join(tempRoot, 'link-entry.js'); + await fs.writeFile(realFile, 'export {};\n', 'utf8'); + + try { + await fs.symlink(realFile, linkFile); + const moduleUrl = new URL(`file://${realFile.replace(/\\/g, '/')}`); + assert.equal(isDirectExecution(moduleUrl.href, linkFile), true, 'expected symlinked executed path to resolve to the module realpath'); + } catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES' && error?.code !== 'UNKNOWN') { + throw error; + } + console.log(`direct execution symlink coverage skipped: ${error.code}`); + } +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('direct execution contract test passed'); diff --git a/tests/shared/encoding/contract-matrix.test.js b/tests/shared/encoding/contract-matrix.test.js new file mode 100644 index 000000000..8da738a17 --- /dev/null +++ b/tests/shared/encoding/contract-matrix.test.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { truncateByBytes } from '../../../src/index/build/file-processor/read.js'; +import { buildFileMeta } from '../../../src/index/build/artifacts/file-meta.js'; +import { reuseCachedBundle } from '../../../src/index/build/file-processor/cached-bundle.js'; +import { readTextFileWithHash } from '../../../src/shared/encoding.js'; +import { sha1 } from '../../../src/shared/hash.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'encoding-contract-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +{ + const filePath = path.join(tempRoot, 'hash-invalid.txt'); + const buffer = Buffer.from([0xff, 0xfe, 0xfd, 0x41]); + await fs.writeFile(filePath, buffer); + const info = await readTextFileWithHash(filePath); + assert.equal(info.hash, sha1(buffer)); + assert.equal(info.usedFallback, true); +} + +{ + const caseRoot = path.join(tempRoot, 'matrix'); + await fs.mkdir(caseRoot, { recursive: true }); + const cases = [ + { + name: 'utf8-valid.txt', + buffer: Buffer.from('hello café 😀', 'utf8'), + expect: { usedFallback: false, encoding: 'utf8', encodingFallbackClass: null, encodingFallbackRisk: null, includes: 'café' } + }, + { + name: 'utf8-invalid.txt', + buffer: Buffer.from([0xff, 0xfe, 0xfd, 0x41]), + expect: { usedFallback: true, encodingFallbackClass: 'document', encodingFallbackRisk: 'low' } + }, + { + name: 'latin1.txt', + buffer: Buffer.from([0x63, 0x61, 0x66, 0xe9]), + expect: { + usedFallback: true, + encodingSet: new Set(['latin1', 'iso-8859-1', 'iso-8859-2', 'windows-1252']), + text: 'café', + encodingFallbackClass: 'document', + encodingFallbackRisk: 'low' + } + }, + { + name: 'windows-1252.txt', + buffer: Buffer.from([0x93, 0x48, 0x69, 0x94]), + expect: { usedFallback: true, encoding: 'windows-1252', text: '“Hi”', encodingFallbackClass: 'document', encodingFallbackRisk: 'low' } + }, + { + name: 'legacy-source.js', + buffer: Buffer.from([0x63, 0x61, 0x66, 0xe9]), + expect: { usedFallback: true, text: 'café', encodingFallbackClass: 'source', encodingFallbackRisk: 'high' } + }, + { + name: 'vendor-lib.js', + dir: 'vendor', + buffer: Buffer.from([0x63, 0x61, 0x66, 0xe9]), + expect: { usedFallback: true, text: 'café', encodingFallbackClass: 'vendor', encodingFallbackRisk: 'low' } + }, + { + name: 'settings.yaml', + buffer: Buffer.from([0xff, 0xfe, 0xfd, 0x41]), + expect: { usedFallback: true, encodingFallbackClass: 'configuration', encodingFallbackRisk: 'medium' } + } + ]; + + for (const testCase of cases) { + const filePath = testCase.dir ? path.join(caseRoot, testCase.dir, testCase.name) : path.join(caseRoot, testCase.name); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, testCase.buffer); + const info = await readTextFileWithHash(filePath); + assert.equal(info.hash, sha1(testCase.buffer), testCase.name); + assert.equal(info.usedFallback, testCase.expect.usedFallback, `${testCase.name} usedFallback`); + if (testCase.expect.encoding) { + assert.equal(info.encoding, testCase.expect.encoding, `${testCase.name} encoding`); + } + if (testCase.expect.encodingSet) { + assert.ok(testCase.expect.encodingSet.has(info.encoding), `${testCase.name} encoding set`); + } + if (testCase.expect.text) { + assert.equal(info.text, testCase.expect.text, `${testCase.name} text`); + } + assert.equal(info.encodingFallbackClass || null, testCase.expect.encodingFallbackClass || null, `${testCase.name} fallback class`); + assert.equal(info.encodingFallbackRisk || null, testCase.expect.encodingFallbackRisk || null, `${testCase.name} fallback risk`); + if (testCase.expect.includes) { + assert.ok(info.text.includes(testCase.expect.includes), `${testCase.name} includes`); + } + } + + const emoji = '😀'; + const sample = `start ${emoji} end`; + const limit = Buffer.byteLength('start ', 'utf8') + 2; + const truncated = truncateByBytes(sample, limit); + assert.ok(!truncated.text.includes('\uFFFD')); + assert.ok(Buffer.byteLength(truncated.text, 'utf8') <= limit); +} + +{ + const repoRoot = path.join(tempRoot, 'repo'); + await fs.mkdir(repoRoot, { recursive: true }); + const targetPath = path.join(repoRoot, 'encoded.txt'); + await fs.writeFile(targetPath, 'demo'); + const stat = await fs.stat(targetPath); + + const cachedBundle = { + chunks: [ + { + file: 'encoded.txt', + ext: '.txt', + start: 0, + end: 4, + startLine: 1, + endLine: 1, + kind: 'text', + tokens: ['demo'], + chunkUid: 'ck:encoded', + virtualPath: 'encoded.txt' + } + ], + fileRelations: {}, + encoding: 'windows-1252', + encodingFallback: true, + encodingFallbackClass: 'source', + encodingFallbackRisk: 'high', + encodingConfidence: 0.42 + }; + + const { result, skip } = reuseCachedBundle({ + abs: targetPath, + relKey: 'encoded.txt', + fileIndex: 0, + fileStat: stat, + fileHash: 'hash', + fileHashAlgo: 'sha1', + ext: '.txt', + fileCaps: {}, + cachedBundle, + incrementalState: { + manifest: { + files: { + 'encoded.txt': { + bundle: 'encoded.json', + hash: 'hash', + encoding: 'windows-1252', + encodingFallback: true, + encodingFallbackClass: 'source', + encodingFallbackRisk: 'high', + encodingConfidence: 0.42 + } + } + } + }, + fileStructural: null, + toolInfo: null, + fileStart: Date.now(), + knownLines: 1, + fileLanguageId: null + }); + + assert.equal(skip, null); + assert(result); + assert.equal(result.fileInfo.encoding, 'windows-1252'); + assert.equal(result.fileInfo.encodingFallback, true); + assert.equal(result.fileInfo.encodingFallbackClass, 'source'); + assert.equal(result.fileInfo.encodingFallbackRisk, 'high'); + assert.equal(result.fileInfo.encodingConfidence, 0.42); + + const { fileMeta } = buildFileMeta({ + chunks: result.chunks, + fileInfoByPath: new Map([[result.relKey, result.fileInfo]]) + }); + const entry = fileMeta.find((item) => item.file === 'encoded.txt'); + assert(entry); + assert.equal(entry.encoding, 'windows-1252'); + assert.equal(entry.encodingFallback, true); + assert.equal(entry.encodingFallbackClass, 'source'); + assert.equal(entry.encodingFallbackRisk, 'high'); + assert.equal(entry.encodingConfidence, 0.42); +} + +console.log('encoding contract matrix test passed'); diff --git a/tests/shared/encoding/encoding-fallback.test.js b/tests/shared/encoding/encoding-fallback.test.js deleted file mode 100644 index 89c349322..000000000 --- a/tests/shared/encoding/encoding-fallback.test.js +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { readTextFile } from '../../../src/shared/encoding.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'encoding'); -const cacheRoot = resolveTestCachePath(root, 'encoding-fallback'); -const sourcePath = path.join(fixtureRoot, 'latin1.js'); - -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const { text, usedFallback, encoding } = await readTextFile(sourcePath); -if (!text.includes('café')) { - console.error('Encoding fallback did not decode latin1.js correctly.'); - process.exit(1); -} -if (!usedFallback) { - console.error('Expected encoding fallback to be used for latin1.js.'); - process.exit(1); -} -const allowedEncodings = new Set(['latin1', 'iso-8859-1', 'iso-8859-2', 'windows-1252']); -if (encoding && !allowedEncodings.has(encoding)) { - console.error(`Unexpected fallback encoding for latin1.js: ${encoding}`); - process.exit(1); -} - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub', - PAIROFCLEATS_WORKER_POOL: 'off' -}; - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', fixtureRoot], - { cwd: fixtureRoot, env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - console.error('Failed: build_index'); - process.exit(buildResult.status ?? 1); -} - -const searchResult = spawnSync( - process.execPath, - [path.join(root, 'search.js'), '--json', '--repo', fixtureRoot, 'café'], - { cwd: fixtureRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('Failed: search'); - process.exit(searchResult.status ?? 1); -} -let payload = null; -try { - payload = JSON.parse(searchResult.stdout || '{}'); -} catch { - console.error('Search output is not valid JSON.'); - process.exit(1); -} -const hits = Array.isArray(payload?.code) ? payload.code : []; -const hit = hits.find((entry) => typeof entry?.file === 'string' && entry.file.endsWith('latin1.js')); -if (!hit) { - console.error('Expected search hit for latin1.js in encoding fixture.'); - process.exit(1); -} - -console.log('encoding fallback test passed'); - diff --git a/tests/shared/encoding/encoding-hash.test.js b/tests/shared/encoding/encoding-hash.test.js deleted file mode 100644 index 99091af3c..000000000 --- a/tests/shared/encoding/encoding-hash.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { readTextFileWithHash } from '../../../src/shared/encoding.js'; -import { sha1 } from '../../../src/shared/hash.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'encoding-hash'); -const filePath = path.join(tempRoot, 'latin1.txt'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const buffer = Buffer.from([0xff, 0xfe, 0xfd, 0x41]); -await fsPromises.writeFile(filePath, buffer); - -const info = await readTextFileWithHash(filePath); -const expectedHash = sha1(buffer); - -if (info.hash !== expectedHash) { - console.error('encoding hash test failed: hash did not match raw bytes.'); - process.exit(1); -} -if (!info.usedFallback) { - console.error('encoding hash test failed: expected fallback decoding for invalid UTF-8.'); - process.exit(1); -} - -console.log('encoding hash tests passed'); - diff --git a/tests/shared/encoding/encoding-matrix.test.js b/tests/shared/encoding/encoding-matrix.test.js deleted file mode 100644 index ab1f9949b..000000000 --- a/tests/shared/encoding/encoding-matrix.test.js +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { readTextFileWithHash } from '../../../src/shared/encoding.js'; -import { sha1 } from '../../../src/shared/hash.js'; -import { truncateByBytes } from '../../../src/index/build/file-processor/read.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'encoding-matrix'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const cases = [ - { - name: 'utf8-valid.txt', - buffer: Buffer.from('hello café 😀', 'utf8'), - expect: { - usedFallback: false, - encoding: 'utf8', - includes: 'café' - } - }, - { - name: 'utf8-invalid.txt', - buffer: Buffer.from([0xff, 0xfe, 0xfd, 0x41]), - expect: { - usedFallback: true - } - }, - { - name: 'latin1.txt', - buffer: Buffer.from([0x63, 0x61, 0x66, 0xe9]), - expect: { - usedFallback: true, - encodingSet: new Set(['latin1', 'iso-8859-1', 'iso-8859-2', 'windows-1252']), - text: 'café' - } - }, - { - name: 'windows-1252.txt', - buffer: Buffer.from([0x93, 0x48, 0x69, 0x94]), - expect: { - usedFallback: true, - encoding: 'windows-1252', - text: '“Hi”' - } - } -]; - -for (const testCase of cases) { - const filePath = path.join(tempRoot, testCase.name); - await fsPromises.writeFile(filePath, testCase.buffer); - const info = await readTextFileWithHash(filePath); - const expectedHash = sha1(testCase.buffer); - if (info.hash !== expectedHash) { - console.error(`Encoding matrix failed for ${testCase.name}: hash mismatch.`); - process.exit(1); - } - if (info.usedFallback !== testCase.expect.usedFallback) { - console.error(`Encoding matrix failed for ${testCase.name}: usedFallback mismatch.`); - process.exit(1); - } - if (testCase.expect.encoding && info.encoding !== testCase.expect.encoding) { - console.error(`Encoding matrix failed for ${testCase.name}: encoding ${info.encoding}.`); - process.exit(1); - } - if (testCase.expect.encodingSet && !testCase.expect.encodingSet.has(info.encoding)) { - console.error(`Encoding matrix failed for ${testCase.name}: encoding ${info.encoding}.`); - process.exit(1); - } - if (testCase.expect.text && info.text !== testCase.expect.text) { - console.error(`Encoding matrix failed for ${testCase.name}: text mismatch.`); - process.exit(1); - } - if (testCase.expect.includes && !info.text.includes(testCase.expect.includes)) { - console.error(`Encoding matrix failed for ${testCase.name}: missing text segment.`); - process.exit(1); - } -} - -const emoji = '😀'; -const sample = `start ${emoji} end`; -const limit = Buffer.byteLength('start ', 'utf8') + 2; -const truncated = truncateByBytes(sample, limit); -if (truncated.text.includes('\uFFFD')) { - console.error('Encoding matrix failed: truncation split multi-byte sequence.'); - process.exit(1); -} -if (Buffer.byteLength(truncated.text, 'utf8') > limit) { - console.error('Encoding matrix failed: truncation exceeded byte limit.'); - process.exit(1); -} - -console.log('encoding matrix tests passed'); - diff --git a/tests/shared/encoding/fallback.test.js b/tests/shared/encoding/fallback.test.js new file mode 100644 index 000000000..ea1b68318 --- /dev/null +++ b/tests/shared/encoding/fallback.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { readTextFile } from '../../../src/shared/encoding.js'; + +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'encoding'); +const cacheRoot = resolveTestCachePath(root, 'encoding-fallback'); +const sourcePath = path.join(fixtureRoot, 'latin1.js'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +const { text, usedFallback, encoding } = await readTextFile(sourcePath); +if (!text.includes('café')) { + console.error('Encoding fallback did not decode latin1.js correctly.'); + process.exit(1); +} +if (!usedFallback) { + console.error('Expected encoding fallback to be used for latin1.js.'); + process.exit(1); +} +const allowedEncodings = new Set(['latin1', 'iso-8859-1', 'iso-8859-2', 'windows-1252']); +if (encoding && !allowedEncodings.has(encoding)) { + console.error(`Unexpected fallback encoding for latin1.js: ${encoding}`); + process.exit(1); +} + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + syncProcess: false +}); + +const buildResult = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--scm-provider', + 'none', + '--repo', + fixtureRoot + ], + 'encoding fallback build index', + fixtureRoot, + env, + { stdio: 'inherit' } +); + +const searchResult = runNode( + [path.join(root, 'search.js'), '--json', '--mode', 'code', '--repo', fixtureRoot, 'café'], + 'encoding fallback search', + fixtureRoot, + env, + { stdio: 'pipe', encoding: 'utf8' } +); +let payload = null; +try { + payload = JSON.parse(searchResult.stdout || '{}'); +} catch { + console.error('Search output is not valid JSON.'); + process.exit(1); +} +const hits = Array.isArray(payload?.code) ? payload.code : []; +const hit = hits.find((entry) => typeof entry?.file === 'string' && entry.file.endsWith('latin1.js')); +if (!hit) { + console.error('Expected search hit for latin1.js in encoding fixture.'); + process.exit(1); +} + +console.log('encoding fallback test passed'); + diff --git a/tests/shared/encoding/metadata-plumbed-and-reused.test.js b/tests/shared/encoding/metadata-plumbed-and-reused.test.js deleted file mode 100644 index 3ce86572e..000000000 --- a/tests/shared/encoding/metadata-plumbed-and-reused.test.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import assert from 'node:assert/strict'; -import { reuseCachedBundle } from '../../../src/index/build/file-processor/cached-bundle.js'; -import { buildFileMeta } from '../../../src/index/build/artifacts/file-meta.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'encoding-meta'); -const repoRoot = path.join(tempRoot, 'repo'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); - -const targetPath = path.join(repoRoot, 'encoded.txt'); -await fs.writeFile(targetPath, 'demo'); -const stat = await fs.stat(targetPath); - -const cachedBundle = { - chunks: [ - { - file: 'encoded.txt', - ext: '.txt', - start: 0, - end: 4, - startLine: 1, - endLine: 1, - kind: 'text', - tokens: ['demo'], - chunkUid: 'ck:encoded', - virtualPath: 'encoded.txt' - } - ], - fileRelations: {}, - encoding: 'windows-1252', - encodingFallback: true, - encodingConfidence: 0.42 -}; - -const { result, skip } = reuseCachedBundle({ - abs: targetPath, - relKey: 'encoded.txt', - fileIndex: 0, - fileStat: stat, - fileHash: 'hash', - fileHashAlgo: 'sha1', - ext: '.txt', - fileCaps: {}, - cachedBundle, - incrementalState: { - manifest: { - files: { - 'encoded.txt': { - bundle: 'encoded.json', - hash: 'hash', - encoding: 'windows-1252', - encodingFallback: true, - encodingConfidence: 0.42 - } - } - } - }, - fileStructural: null, - toolInfo: null, - fileStart: Date.now(), - knownLines: 1, - fileLanguageId: null -}); - -assert.equal(skip, null); -assert(result, 'expected cached bundle reuse result'); -assert.equal(result.fileInfo.encoding, 'windows-1252'); -assert.equal(result.fileInfo.encodingFallback, true); -assert.equal(result.fileInfo.encodingConfidence, 0.42); - -const { fileMeta } = buildFileMeta({ - chunks: result.chunks, - fileInfoByPath: new Map([[result.relKey, result.fileInfo]]) -}); -const entry = fileMeta.find((item) => item.file === 'encoded.txt'); -assert(entry, 'expected file meta entry'); -assert.equal(entry.encoding, 'windows-1252'); -assert.equal(entry.encodingFallback, true); -assert.equal(entry.encodingConfidence, 0.42); - -console.log('encoding metadata plumbed and reused ok'); - diff --git a/tests/shared/encoding/unicode-offset.test.js b/tests/shared/encoding/unicode-offset.test.js index 176518087..c6f6ce97c 100644 --- a/tests/shared/encoding/unicode-offset.test.js +++ b/tests/shared/encoding/unicode-offset.test.js @@ -1,11 +1,9 @@ #!/usr/bin/env node -import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { loadChunkMeta, MAX_JSON_BYTES } from '../../../src/shared/artifact-io.js'; import { getIndexDir, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; +import { createStage1CodeBuildEnv, runStage1CodeBuildOrExit } from '../../helpers/build-index-fixture.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -35,41 +33,9 @@ const content = [ const sourcePath = path.join(repoRoot, 'unicode.js'); await fsPromises.writeFile(sourcePath, content); -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); +const env = createStage1CodeBuildEnv({ cacheRoot }); -const buildResult = spawnSync( - process.execPath, - [ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--stage', - 'stage2', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - if (buildResult.error) { - console.error('build_index spawn error:', buildResult.error); - } - const crashLogPath = path.join(repoRoot, 'logs', 'index-crash.log'); - if (fs.existsSync(crashLogPath)) { - const crashLog = await fsPromises.readFile(crashLogPath, 'utf8'); - const tail = crashLog.length > 2000 ? crashLog.slice(-2000) : crashLog; - console.error('build_index crash log (tail):\n' + tail); - } - console.error('Failed: build_index'); - process.exit(buildResult.status ?? 1); -} +await runStage1CodeBuildOrExit({ root, repoRoot, env, printCrashLog: true }); const userConfig = loadUserConfig(repoRoot); const codeDir = getIndexDir(repoRoot, 'code', userConfig); diff --git a/tests/shared/env-boolean-case-insensitive.test.js b/tests/shared/env-boolean-case-insensitive.test.js index aec6b9f85..29419f309 100644 --- a/tests/shared/env-boolean-case-insensitive.test.js +++ b/tests/shared/env-boolean-case-insensitive.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { getEnvConfig } from '../../src/shared/env.js'; +import { getEnvConfig } from '../../src/shared/env/runtime.js'; const config = getEnvConfig({ PAIROFCLEATS_CACHE_REBUILD: 'TRUE', diff --git a/tests/shared/files-json-lines-falsy.test.js b/tests/shared/files-json-lines-falsy.test.js index 19153228a..4094916ae 100644 --- a/tests/shared/files-json-lines-falsy.test.js +++ b/tests/shared/files-json-lines-falsy.test.js @@ -2,7 +2,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { ensureTestingEnv } from '../helpers/test-env.js'; -import { readJsonLinesSyncSafe } from '../../src/shared/files.js'; +import { readJsonLinesSyncSafe } from '../../src/shared/file-read.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; diff --git a/tests/shared/files/read-file-range-fd-zero-guard.test.js b/tests/shared/files/read-file-range-fd-zero-guard.test.js index 861540626..7bb0694f9 100644 --- a/tests/shared/files/read-file-range-fd-zero-guard.test.js +++ b/tests/shared/files/read-file-range-fd-zero-guard.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { readFileRangeSync } from '../../../src/shared/files.js'; +import { readFileRangeSync } from '../../../src/shared/file-read.js'; const originalOpenSync = fs.openSync; const originalReadSync = fs.readSync; diff --git a/tests/shared/files/read-json-safe-on-error.test.js b/tests/shared/files/read-json-safe-on-error.test.js new file mode 100644 index 000000000..d365a39e4 --- /dev/null +++ b/tests/shared/files/read-json-safe-on-error.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import { readJsonFileSafe, readJsonFileSyncSafe } from '../../../src/shared/file-read.js'; + +const originalStatSync = fs.statSync; +const originalReadFileSync = fs.readFileSync; +const originalStat = fsPromises.stat; +const originalReadFile = fsPromises.readFile; + +try { + const syncErrors = []; + fs.statSync = () => ({ size: 10 }); + fs.readFileSync = () => '{broken'; + const syncFallback = { ok: false }; + const syncOut = readJsonFileSyncSafe('sync.json', { + fallback: syncFallback, + maxBytes: 1024, + onError: (info) => syncErrors.push(info) + }); + assert.equal(syncOut, syncFallback); + assert.equal(syncErrors.length, 1, 'expected exactly one sync parse error'); + assert.equal(syncErrors[0]?.phase, 'parse'); + assert.equal(syncErrors[0]?.sync, true); + assert.equal(syncErrors[0]?.path, 'sync.json'); + + const asyncErrors = []; + fsPromises.stat = async () => ({ size: 10 }); + fsPromises.readFile = async () => '{oops'; + const asyncFallback = { ok: false }; + const asyncOut = await readJsonFileSafe('async.json', { + fallback: asyncFallback, + maxBytes: 1024, + onError: (info) => asyncErrors.push(info) + }); + assert.equal(asyncOut, asyncFallback); + assert.equal(asyncErrors.length, 1, 'expected exactly one async parse error'); + assert.equal(asyncErrors[0]?.phase, 'parse'); + assert.equal(asyncErrors[0]?.sync, false); + assert.equal(asyncErrors[0]?.path, 'async.json'); + + const sentinelFallback = Symbol('parse-failed'); + const syncSentinelOut = readJsonFileSyncSafe('sync.json', sentinelFallback); + assert.equal(syncSentinelOut, sentinelFallback, 'expected legacy sync fallback arg to be preserved'); + + const asyncSentinelOut = await readJsonFileSafe('async.json', sentinelFallback); + assert.equal(asyncSentinelOut, sentinelFallback, 'expected legacy async fallback arg to be preserved'); + + const syncNullOut = readJsonFileSyncSafe('sync.json', null); + assert.equal(syncNullOut, null, 'expected explicit null fallback arg to remain valid'); + + const asyncNullOut = await readJsonFileSafe('async.json', null); + assert.equal(asyncNullOut, null, 'expected explicit null async fallback arg to remain valid'); +} finally { + fs.statSync = originalStatSync; + fs.readFileSync = originalReadFileSync; + fsPromises.stat = originalStat; + fsPromises.readFile = originalReadFile; +} + +console.log('read json safe on-error test passed'); diff --git a/tests/shared/fs/atomic-replace-cleans-bak.test.js b/tests/shared/fs/atomic-replace-cleans-bak.test.js deleted file mode 100644 index c558fa705..000000000 --- a/tests/shared/fs/atomic-replace-cleans-bak.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { replaceFile } from '../../../src/shared/json-stream.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'atomic-replace'); -await fsPromises.rm(outDir, { recursive: true, force: true }); -await fsPromises.mkdir(outDir, { recursive: true }); - -const finalPath = path.join(outDir, 'target.json'); -const tempPath = path.join(outDir, 'target.tmp'); - -await fsPromises.writeFile(finalPath, 'before', 'utf8'); -await fsPromises.writeFile(tempPath, 'after', 'utf8'); - -await replaceFile(tempPath, finalPath); - -const contents = await fsPromises.readFile(finalPath, 'utf8'); -assert.equal(contents, 'after'); -assert.ok(!fs.existsSync(`${finalPath}.bak`), 'expected .bak to be removed after replace'); - -console.log('atomic replace cleans .bak test passed'); - diff --git a/tests/shared/fs/atomic-replace-contract-matrix.test.js b/tests/shared/fs/atomic-replace-contract-matrix.test.js new file mode 100644 index 000000000..c8cb80154 --- /dev/null +++ b/tests/shared/fs/atomic-replace-contract-matrix.test.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { replaceFile as replaceJsonFile } from '../../../src/shared/json-stream/atomic.js'; +import { replaceFile as replacePersistentFile, replaceFileSync } from '../../../src/shared/io/replace-file.js'; + +const cases = [ + { + name: 'async replace cleans backup after success', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-success-')); + const finalPath = path.join(outDir, 'target.json'); + const tempPath = path.join(outDir, 'target.tmp'); + try { + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + await replaceJsonFile(tempPath, finalPath); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'after'); + assert.equal(fs.existsSync(`${finalPath}.bak`), false); + } finally { + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + }, + { + name: 'async replace restores backup when promoted temp disappears', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-restore-')); + const finalPath = path.join(outDir, 'target.json'); + const tempPath = path.join(outDir, 'target.tmp'); + const bakPath = `${finalPath}.bak`; + const originalRename = fsPromises.rename; + try { + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + fsPromises.rename = async (from, to) => { + if (from === finalPath && to === bakPath) return originalRename(from, to); + if (from === tempPath && to === finalPath) { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + throw err; + } + return originalRename(from, to); + }; + + let failed = null; + try { + await replaceJsonFile(tempPath, finalPath); + } catch (error) { + failed = error; + } + assert.ok(failed); + assert.equal(fs.existsSync(finalPath), true); + assert.equal(fs.existsSync(bakPath), false); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); + assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); + } finally { + fsPromises.rename = originalRename; + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + }, + { + name: 'stale backup does not mask missing temp path', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-stale-')); + const finalPath = path.join(outDir, 'target.json'); + const bakPath = `${finalPath}.bak`; + const missingTempPath = path.join(outDir, 'target.tmp'); + try { + await fsPromises.writeFile(finalPath, 'current-final', 'utf8'); + await fsPromises.writeFile(bakPath, 'stale-backup', 'utf8'); + const staleAt = new Date(Date.now() - 60_000); + await fsPromises.utimes(finalPath, staleAt, staleAt); + + let failed = null; + try { + await replaceJsonFile(missingTempPath, finalPath, { keepBackup: false }); + } catch (error) { + failed = error; + } + assert.ok(failed); + assert.equal(failed?.code, 'ERR_TEMP_MISSING'); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'current-final'); + assert.equal(fs.existsSync(bakPath), true); + } finally { + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + }, + { + name: 'sync stale backup collision guard preserves original files', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-sync-')); + const finalPath = path.join(outDir, 'target.json'); + const staleBakPath = `${finalPath}.bak`; + const tempPath = path.join(outDir, 'target.tmp'); + const originalRenameSync = fs.renameSync; + const originalCopyFileSync = fs.copyFileSync; + try { + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(staleBakPath, 'stale-backup', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + fs.renameSync = (from, to) => { + if (from === tempPath && to === finalPath) { + const err = new Error('EPERM'); + err.code = 'EPERM'; + throw err; + } + return originalRenameSync(from, to); + }; + fs.copyFileSync = () => { + const err = new Error('EACCES'); + err.code = 'EACCES'; + throw err; + }; + + let failed = null; + try { + replaceFileSync(tempPath, finalPath); + } catch (error) { + failed = error; + } + assert.ok(failed instanceof Error); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); + assert.equal(await fsPromises.readFile(staleBakPath, 'utf8'), 'stale-backup'); + assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); + } finally { + fs.renameSync = originalRenameSync; + fs.copyFileSync = originalCopyFileSync; + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + }, + { + name: 'committed final survives missing temp for async and sync persistence helpers', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-committed-')); + const finalPath = path.join(outDir, 'final.txt'); + const backupPath = path.join(outDir, 'final.txt.bak'); + const missingTempPath = path.join(outDir, 'temp.txt'); + try { + await fsPromises.writeFile(finalPath, 'committed\n', 'utf8'); + await replacePersistentFile(missingTempPath, finalPath, { keepBackup: false }); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'committed\n'); + + await fsPromises.writeFile(backupPath, 'stale\n', 'utf8'); + replaceFileSync(missingTempPath, finalPath, { keepBackup: false }); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'committed\n'); + } finally { + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + }, + { + name: 'cross-device fallback copies temp into final and removes backups', + async run() { + const outDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-atomic-replace-exdev-')); + const finalPath = path.join(outDir, 'target.json'); + const tempPath = path.join(outDir, 'target.tmp'); + const originalRename = fsPromises.rename; + try { + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + fsPromises.rename = async () => { + const err = new Error('EXDEV'); + err.code = 'EXDEV'; + throw err; + }; + + await replaceJsonFile(tempPath, finalPath); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'after'); + assert.equal(fs.existsSync(tempPath), false); + assert.equal(fs.existsSync(`${finalPath}.bak`), false); + } finally { + fsPromises.rename = originalRename; + await fsPromises.rm(outDir, { recursive: true, force: true }); + } + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log('atomic replace contract matrix test passed'); diff --git a/tests/shared/fs/atomic-replace-cross-device-fallback.test.js b/tests/shared/fs/atomic-replace-cross-device-fallback.test.js deleted file mode 100644 index a8c050a38..000000000 --- a/tests/shared/fs/atomic-replace-cross-device-fallback.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { replaceFile } from '../../../src/shared/json-stream.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'atomic-replace-exdev'); -await fsPromises.rm(outDir, { recursive: true, force: true }); -await fsPromises.mkdir(outDir, { recursive: true }); - -const finalPath = path.join(outDir, 'target.json'); -const tempPath = path.join(outDir, 'target.tmp'); - -await fsPromises.writeFile(finalPath, 'before', 'utf8'); -await fsPromises.writeFile(tempPath, 'after', 'utf8'); - -const originalRename = fsPromises.rename; -fsPromises.rename = async () => { - const err = new Error('EXDEV'); - err.code = 'EXDEV'; - throw err; -}; - -try { - await replaceFile(tempPath, finalPath); -} finally { - fsPromises.rename = originalRename; -} - -const contents = await fsPromises.readFile(finalPath, 'utf8'); -assert.equal(contents, 'after'); -assert.ok(!fs.existsSync(tempPath), 'expected temp file cleaned up after copy fallback'); -assert.ok(!fs.existsSync(`${finalPath}.bak`), 'expected .bak removed after fallback'); - -console.log('atomic replace cross-device fallback test passed'); - diff --git a/tests/shared/fs/atomic-replace-restores-backup-on-missing-temp.test.js b/tests/shared/fs/atomic-replace-restores-backup-on-missing-temp.test.js deleted file mode 100644 index 66585f9cd..000000000 --- a/tests/shared/fs/atomic-replace-restores-backup-on-missing-temp.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { replaceFile } from '../../../src/shared/json-stream.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'atomic-replace-restore-backup'); -await fsPromises.rm(outDir, { recursive: true, force: true }); -await fsPromises.mkdir(outDir, { recursive: true }); - -const finalPath = path.join(outDir, 'target.json'); -const tempPath = path.join(outDir, 'target.tmp'); -const bakPath = `${finalPath}.bak`; - -await fsPromises.writeFile(finalPath, 'before', 'utf8'); -await fsPromises.writeFile(tempPath, 'after', 'utf8'); - -const originalRename = fsPromises.rename; -fsPromises.rename = async (from, to) => { - if (from === finalPath && to === bakPath) { - return originalRename(from, to); - } - if (from === tempPath && to === finalPath) { - const err = new Error('ENOENT'); - err.code = 'ENOENT'; - throw err; - } - return originalRename(from, to); -}; - -let failed = null; -try { - await replaceFile(tempPath, finalPath); -} catch (err) { - failed = err; -} finally { - fsPromises.rename = originalRename; -} - -assert.ok(failed, 'expected replaceFile to fail when temp cannot be promoted'); -assert.ok(fs.existsSync(finalPath), 'expected original file to be restored from backup'); -assert.ok(!fs.existsSync(bakPath), 'expected backup to be consumed during restore'); -assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); -assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); - -console.log('atomic replace restores backup on missing temp test passed'); diff --git a/tests/shared/fs/atomic-replace-stale-bak-does-not-mask-missing-temp.test.js b/tests/shared/fs/atomic-replace-stale-bak-does-not-mask-missing-temp.test.js deleted file mode 100644 index d628f4957..000000000 --- a/tests/shared/fs/atomic-replace-stale-bak-does-not-mask-missing-temp.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { replaceFile } from '../../../src/shared/json-stream.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'atomic-replace-stale-bak-missing-temp'); -await fsPromises.rm(outDir, { recursive: true, force: true }); -await fsPromises.mkdir(outDir, { recursive: true }); - -const finalPath = path.join(outDir, 'target.json'); -const bakPath = `${finalPath}.bak`; -const missingTempPath = path.join(outDir, 'target.tmp'); - -await fsPromises.writeFile(finalPath, 'current-final', 'utf8'); -await fsPromises.writeFile(bakPath, 'stale-backup', 'utf8'); -const staleAt = new Date(Date.now() - 60_000); -await fsPromises.utimes(finalPath, staleAt, staleAt); - -let failed = null; -try { - await replaceFile(missingTempPath, finalPath, { keepBackup: false }); -} catch (err) { - failed = err; -} - -assert.ok(failed, 'expected replaceFile to fail when temp path is missing'); -assert.equal(failed?.code, 'ERR_TEMP_MISSING', 'expected ERR_TEMP_MISSING for missing temp path'); -assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'current-final', 'expected final file to remain unchanged'); -assert.equal(fs.existsSync(bakPath), true, 'expected stale backup to remain untouched on failure'); - -console.log('atomic replace stale .bak missing-temp guard test passed'); diff --git a/tests/shared/h33-hard-cutover-boundaries.test.js b/tests/shared/h33-hard-cutover-boundaries.test.js new file mode 100644 index 000000000..97067bb51 --- /dev/null +++ b/tests/shared/h33-hard-cutover-boundaries.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); + +const removedPaths = [ + 'src/retrieval/cli/run-search.js', + 'src/shared/dispatch/manifest.js', + 'tools/shared/search-request.js' +]; + +const allowedReferenceFiles = new Set([ + 'tests/shared/h33-hard-cutover-boundaries.test.js', + 'tests/tooling/shared-module-migration.test.js' +]); + +for (const relPath of removedPaths) { + try { + await fs.access(path.join(root, relPath)); + assert.fail(`expected removed hard-cutover path to stay deleted: ${relPath}`); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + +const scanRoots = ['src', 'tools', 'bin', 'extensions', 'sublime', 'tests', 'docs']; +const sourceExtensions = new Set(['.js', '.mjs', '.cjs', '.json', '.md']); + +const listFilesRecursive = async (dir) => { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === 'node_modules' + || entry.name === '.git' + || entry.name === '.testLogs' + || entry.name === '.cache' + || fullPath === path.join(root, 'docs', 'archived') + || fullPath === path.join(root, 'docs', 'tooling') + ) { + continue; + } + files.push(...await listFilesRecursive(fullPath)); + continue; + } + if (sourceExtensions.has(path.extname(entry.name))) files.push(fullPath); + } + return files; +}; + +for (const relativeRoot of scanRoots) { + const absoluteRoot = path.join(root, relativeRoot); + let files = []; + try { + files = await listFilesRecursive(absoluteRoot); + } catch { + continue; + } + for (const filePath of files) { + const contents = await fs.readFile(filePath, 'utf8'); + const relPath = path.relative(root, filePath).replace(/\\/g, '/'); + if (allowedReferenceFiles.has(relPath)) continue; + for (const removedPath of removedPaths) { + assert.equal( + contents.includes(removedPath), + false, + `expected live code to stay off removed hard-cutover path ${removedPath} (${relPath})` + ); + } + } +} + +console.log('H33 hard cutover boundary test passed'); diff --git a/tests/shared/index-artifact-helpers-chunk-meta-presence.test.js b/tests/shared/index-artifact-helpers-chunk-meta-presence.test.js index 8553eee98..b70489c30 100644 --- a/tests/shared/index-artifact-helpers-chunk-meta-presence.test.js +++ b/tests/shared/index-artifact-helpers-chunk-meta-presence.test.js @@ -5,7 +5,7 @@ import path from 'node:path'; import { hasChunkMetaArtifactsAsync, hasChunkMetaArtifactsSync -} from '../../src/shared/index-artifact-helpers.js'; +} from '../../src/shared/artifact-io/chunk-meta-presence.js'; import { writePiecesManifest } from '../helpers/artifact-io-fixture.js'; import { resolveTestCachePath } from '../helpers/test-cache.js'; diff --git a/tests/shared/indexing/build-pointer.test.js b/tests/shared/indexing/build-pointer.test.js new file mode 100644 index 000000000..123361ec4 --- /dev/null +++ b/tests/shared/indexing/build-pointer.test.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + BUILD_ROOT_SELECTION_SCOPES, + buildGenerationKey, + findLatestBuildRootWithIndexes, + readCurrentBuildGeneration, + resolveCanonicalBuildRoot, + resolveCacheScopedBuildIdRoot, + resolveCacheScopedBuildPointerRoot, + resolveCurrentBuildRoots +} from '../../../src/shared/indexing/build-pointer.js'; +import { normalizeIdentityPath } from '../../../src/workspace/identity.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const normalizePath = (value) => normalizeIdentityPath(path.resolve(value)); + +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('build-pointer', { root }); +const repoCacheRoot = path.join(tempRoot, 'repo-cache'); +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const currentJsonPath = path.join(buildsRoot, 'current.json'); +const validRoot = path.join(buildsRoot, '20260211T000000Z-valid'); +const missingRoot = path.join(buildsRoot, '20260211T010000Z-missing'); +const rogueRoot = path.join(repoCacheRoot, '20260211T000000Z-valid'); + +await fs.mkdir(path.join(validRoot, 'index-code'), { recursive: true }); +await fs.writeFile(path.join(validRoot, 'index-code', 'chunk_meta.jsonl.gz'), '', 'utf8'); +await fs.mkdir(path.join(missingRoot, 'index-code'), { recursive: true }); +await fs.mkdir(path.join(rogueRoot, 'index-code'), { recursive: true }); +await fs.writeFile(path.join(rogueRoot, 'index-code', 'chunk_meta.jsonl.gz'), '', 'utf8'); + +assert.equal( + resolveCacheScopedBuildPointerRoot(path.join(tempRoot, 'outside-build'), repoCacheRoot, buildsRoot), + null, + 'expected external buildRoot pointers to be rejected' +); +assert.equal( + resolveCacheScopedBuildIdRoot(path.relative(buildsRoot, path.join(tempRoot, 'outside-build')), repoCacheRoot, buildsRoot), + null, + 'expected traversal buildIds to be rejected' +); +assert.equal( + normalizePath(resolveCacheScopedBuildIdRoot('20260211T000000Z-valid', repoCacheRoot, buildsRoot)), + normalizePath(validRoot), + 'expected buildId fallback to stay under builds/' +); +assert.equal( + normalizePath(findLatestBuildRootWithIndexes(buildsRoot, 'code')), + normalizePath(validRoot), + 'expected latest-build fallback to choose the indexed build root' +); + +const resolved = resolveCurrentBuildRoots( + { + buildId: '20260211T010000Z-missing', + buildRoot: missingRoot + }, + { + repoCacheRoot, + buildsRoot, + preferredMode: 'code' + } +); +assert.equal( + normalizePath(resolved.activeRoot), + normalizePath(validRoot), + 'expected active root fallback to skip empty index dirs' +); +assert.equal( + buildGenerationKey(resolved), + JSON.stringify({ + buildId: resolved.buildId, + buildRoot: resolved.buildRoot, + activeRoot: resolved.activeRoot, + buildRoots: {} + }), + 'expected build generation key to normalize canonical build roots' +); + +const buildIdOnly = resolveCurrentBuildRoots( + { + buildId: '20260211T000000Z-valid', + modes: ['code'] + }, + { + repoCacheRoot, + buildsRoot, + preferredMode: 'code' + } +); +assert.equal( + normalizePath(buildIdOnly.buildRoot), + normalizePath(validRoot), + 'expected buildId fallback to prefer builds/' +); +assert.notEqual( + normalizePath(buildIdOnly.buildRoot), + normalizePath(rogueRoot), + 'expected rogue repo-cache sibling root to be ignored' +); + +const canonical = resolveCanonicalBuildRoot({ + repoCacheRoot, + buildsRoot, + buildInfo: { + buildId: buildIdOnly.buildId, + buildRoot: repoCacheRoot, + activeRoot: validRoot, + buildRoots: { code: repoCacheRoot } + }, + preferredMode: 'code', + requireArtifacts: true, + allowLegacyRepoRootFallback: false +}); +assert.equal(canonical.ok, true, 'expected canonical build root resolution to succeed'); +assert.equal( + canonical.scope, + BUILD_ROOT_SELECTION_SCOPES.ACTIVE_GENERATION, + 'expected canonical resolver to prefer the active generation root' +); +assert.equal( + normalizePath(canonical.root), + normalizePath(validRoot), + 'expected canonical resolver to avoid repo-root pointers when active generation exists' +); + +await fs.writeFile(currentJsonPath, JSON.stringify({ + buildId: '20260211T000000Z-valid', + buildRoot: validRoot +}, null, 2), 'utf8'); +const generation = readCurrentBuildGeneration({ + currentJsonPath, + repoCacheRoot, + buildsRoot, + preferredMode: 'code' +}); +assert.equal(generation.currentJsonExists, true, 'expected current build pointer to exist'); +assert.equal(generation.parseOk, true, 'expected current build pointer to parse'); +assert.equal( + normalizePath(generation.activeRoot), + normalizePath(validRoot), + 'expected current build generation to resolve the active root' +); +assert.equal( + generation.generationKey, + buildGenerationKey(generation), + 'expected current build generation reader to use the canonical generation key' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('shared build pointer test passed'); diff --git a/tests/shared/indexing/progress-timeout-policy.test.js b/tests/shared/indexing/progress-timeout-policy.test.js new file mode 100644 index 000000000..1daa36ff3 --- /dev/null +++ b/tests/shared/indexing/progress-timeout-policy.test.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildProgressTimeoutBudget, + evaluateProgressTimeout, + normalizeProgressTimeoutOwnerPolicy, + PROGRESS_TIMEOUT_CLASSES, + PROGRESS_TIMEOUT_OUTCOMES, + PROGRESS_TIMEOUT_POLICY_VERSION, + resolveProgressTimeoutRepoTier +} from '../../../src/shared/indexing/progress-timeout-policy.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv({ testing: '1' }); + +assert.equal( + resolveProgressTimeoutRepoTier({ repoFileCount: 60_000 }), + 'xlarge', + 'expected xlarge repo tier' +); + +const ownerPolicy = normalizeProgressTimeoutOwnerPolicy({ + ownerId: 'tooling-orchestrator', + ladderId: 'provider-bootstrap', + phase: 'provider_bootstrap', + queueExpected: false, + byteProgressExpected: false, + optionalPhase: true, + skippedWork: ['provider-requests', 'provider-ladder', 'provider-requests'], + partialSuccess: true +}); +assert.equal(ownerPolicy?.phase, 'provider_bootstrap', 'expected normalized timeout owner phase'); +assert.deepEqual( + ownerPolicy?.skippedWork, + ['provider-ladder', 'provider-requests'], + 'expected timeout owner skipped work to be normalized and deduplicated' +); +assert.equal(ownerPolicy?.partialSuccess, true, 'expected timeout owner partial success flag'); + +const budget = buildProgressTimeoutBudget({ + phase: 'stage1-ordered-backpressure', + baseTimeoutMs: 30_000, + repoFileCount: 25_000, + scheduledFileCount: 18_000, + activeBatchCount: 6, + languages: ['protobuf', 'starlark', 'javascript'], + completedUnits: 500, + totalUnits: 18_000, + elapsedMs: 180_000 +}); +assert.equal(budget.schemaVersion, PROGRESS_TIMEOUT_POLICY_VERSION, 'expected policy version'); +assert.equal(budget.repoTier, 'large', 'expected large repo tier'); +assert.ok(budget.budgetMs > budget.baseTimeoutMs, 'expected workload-aware timeout multiplier'); + +const abortBudget = buildProgressTimeoutBudget({ + phase: 'stage1-ordered-backpressure', + baseTimeoutMs: 30_000, + maxTimeoutMs: 30_000, + repoFileCount: 25_000, + scheduledFileCount: 18_000, + activeBatchCount: 0, + completedUnits: 0, + totalUnits: 18_000, + elapsedMs: 180_000 +}); + +const queueDecision = evaluateProgressTimeout({ + budget: abortBudget, + heartbeatAgeMs: abortBudget.budgetMs + 5000, + queueMovementAgeMs: abortBudget.budgetMs + 5000, + byteProgressAgeMs: abortBudget.budgetMs + 5000, + queueExpected: true, + byteProgressExpected: true +}); +assert.equal(queueDecision.timedOut, true, 'expected queue-stall timeout'); +assert.equal(queueDecision.timeoutClass, PROGRESS_TIMEOUT_CLASSES.noQueueMovement, 'expected no_queue_movement'); +assert.equal(queueDecision.outcome, PROGRESS_TIMEOUT_OUTCOMES.hardAbort, 'expected hard-abort outcome'); + +const byteDecision = evaluateProgressTimeout({ + budget: abortBudget, + heartbeatAgeMs: abortBudget.budgetMs + 5000, + queueMovementAgeMs: 1000, + byteProgressAgeMs: abortBudget.budgetMs + 5000, + queueExpected: false, + byteProgressExpected: true +}); +assert.equal(byteDecision.timedOut, true, 'expected byte-progress timeout'); +assert.equal(byteDecision.timeoutClass, PROGRESS_TIMEOUT_CLASSES.noByteProgress, 'expected no_byte_progress'); + +const heartbeatDecision = evaluateProgressTimeout({ + budget: abortBudget, + heartbeatAgeMs: abortBudget.budgetMs + 5000, + queueMovementAgeMs: null, + byteProgressAgeMs: null +}); +assert.equal(heartbeatDecision.timedOut, true, 'expected heartbeat timeout'); +assert.equal(heartbeatDecision.timeoutClass, PROGRESS_TIMEOUT_CLASSES.noHeartbeat, 'expected no_heartbeat'); + +const hardDecision = evaluateProgressTimeout({ + budget: buildProgressTimeoutBudget({ + phase: 'bench-process-wall-clock', + baseTimeoutMs: 1000, + maxTimeoutMs: 1000, + wallClockCapMs: 1000 + }), + wallClockElapsedMs: 1200 +}); +assert.equal(hardDecision.timedOut, true, 'expected wall-clock timeout'); +assert.equal(hardDecision.timeoutClass, PROGRESS_TIMEOUT_CLASSES.globalWallClockCap, 'expected global wall clock cap'); + +const externalDecision = evaluateProgressTimeout({ + budget, + externalToolTimedOut: true +}); +assert.equal(externalDecision.timedOut, true, 'expected external tool timeout'); +assert.equal( + externalDecision.timeoutClass, + PROGRESS_TIMEOUT_CLASSES.externalToolTimeout, + 'expected external tool timeout class' +); + +const ensemblReplayDecision = evaluateProgressTimeout({ + budget, + heartbeatAgeMs: budget.budgetMs + 10_000, + queueMovementAgeMs: 1500, + byteProgressAgeMs: budget.budgetMs + 10_000, + queueExpected: true, + byteProgressExpected: false +}); +assert.equal( + ensemblReplayDecision.timedOut, + false, + 'expected Ensembl-style ordered queue movement to suppress false idle timeout' +); +assert.equal(ensemblReplayDecision.outcome, PROGRESS_TIMEOUT_OUTCOMES.continueWait); + +const envoyReplayDecision = evaluateProgressTimeout({ + budget, + heartbeatAgeMs: budget.budgetMs + 10_000, + queueMovementAgeMs: budget.budgetMs + 10_000, + byteProgressAgeMs: 1200, + queueExpected: true, + byteProgressExpected: true +}); +assert.equal( + envoyReplayDecision.timedOut, + false, + 'expected Envoy-style byte progress to suppress false idle timeout' +); +assert.equal(envoyReplayDecision.outcome, PROGRESS_TIMEOUT_OUTCOMES.continueWait); + +const extensionBudget = buildProgressTimeoutBudget({ + phase: 'bench-process-idle', + baseTimeoutMs: 100, + maxTimeoutMs: 400, + scheduledFileCount: 18_000, + activeBatchCount: 4, + completedUnits: 250, + totalUnits: 500, + elapsedMs: 90_000, + languages: ['cpp', 'protobuf'] +}); +const extensionDecision = evaluateProgressTimeout({ + budget: extensionBudget, + heartbeatAgeMs: extensionBudget.budgetMs + 10, + queueMovementAgeMs: extensionBudget.budgetMs + 20, + byteProgressAgeMs: extensionBudget.budgetMs + 15, + queueExpected: true, + byteProgressExpected: true +}); +assert.equal(extensionDecision.timedOut, false, 'expected progress extension to suppress false timeout'); +assert.equal(extensionDecision.outcome, PROGRESS_TIMEOUT_OUTCOMES.extendBudget, 'expected explicit budget extension'); +assert.equal(extensionDecision.candidateTimeoutClass, PROGRESS_TIMEOUT_CLASSES.noQueueMovement); +assert.ok( + Number(extensionDecision.effectiveBudgetMs) > Number(extensionBudget.budgetMs), + 'expected effective budget to extend beyond the base budget' +); +assert.equal(extensionDecision.trace?.policyVersion, PROGRESS_TIMEOUT_POLICY_VERSION, 'expected replay trace policy version'); + +const basedosdadosReplayDecision = evaluateProgressTimeout({ + budget: buildProgressTimeoutBudget({ + phase: 'bench-process-wall-clock', + baseTimeoutMs: 120_000, + maxTimeoutMs: 120_000, + wallClockCapMs: 120_000, + repoFileCount: 15_000, + scheduledFileCount: 12_000, + activeBatchCount: 6, + languages: ['sql', 'python'], + completedUnits: 20, + totalUnits: 400, + elapsedMs: 120_000 + }), + wallClockElapsedMs: 120_500 +}); +assert.equal( + basedosdadosReplayDecision.timeoutClass, + PROGRESS_TIMEOUT_CLASSES.globalWallClockCap, + 'expected Basedosdados-style heavy run to classify as a real wall clock cap' +); + +console.log('progress timeout policy test passed'); diff --git a/tests/shared/indexing/stage1-watchdog-policy.test.js b/tests/shared/indexing/stage1-watchdog-policy.test.js new file mode 100644 index 000000000..3c1c804ac --- /dev/null +++ b/tests/shared/indexing/stage1-watchdog-policy.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + resolveStage1HangPolicy, + resolveStage1StallAction, + resolveStage1StallSoftKickTimeoutMs +} from '../../../src/shared/indexing/stage1-watchdog-policy.js'; + +const runtime = { + indexingConfig: { + stage1: { + softKickMaxAttempts: 7, + watchdog: { + stages: { + processing: { + heartbeatMs: 1500, + snapshotMs: 2000, + softKickMs: 3500, + softKickCooldownMs: 1000, + softKickMaxAttempts: 2, + stuckThresholdMs: 9000 + } + } + } + } + }, + stage1Queues: { + watchdog: { + progressHeartbeatMs: 9000, + stallSnapshotMs: 2600, + hardTimeoutMs: 4000 + } + } +}; + +const policy = resolveStage1HangPolicy(runtime, { hardTimeoutMs: 4000 }); +assert.equal(policy.progressHeartbeatMs, 1500, 'expected stage-specific heartbeat override'); +assert.equal(policy.stallSnapshotMs, 2000, 'expected stage-specific snapshot override'); +assert.equal(policy.stallSoftKickMs, 3500, 'expected stage-specific soft-kick override'); +assert.equal(policy.stallSoftKickCooldownMs, 1000, 'expected stage-specific cooldown override'); +assert.equal(policy.stallSoftKickMaxAttempts, 2, 'expected stage-specific soft-kick attempts override'); +assert.equal(policy.stallAbortMs, 9000, 'expected stage-specific stuck threshold override'); + +assert.equal( + resolveStage1StallSoftKickTimeoutMs({ configuredSoftKickMs: null, stallAbortMs: 12000 }), + 6000, + 'expected derived soft-kick threshold to track half the abort budget' +); +assert.equal( + resolveStage1StallSoftKickTimeoutMs({ configuredSoftKickMs: null, stallAbortMs: 0 }), + 0, + 'expected soft-kick to disable when abort is disabled' +); + +assert.deepEqual( + resolveStage1StallAction({ + idleMs: 4500, + hardAbortMs: policy.stallAbortMs, + softKickMs: policy.stallSoftKickMs, + softKickAttempts: 1, + softKickMaxAttempts: policy.stallSoftKickMaxAttempts, + lastSoftKickAtMs: 9500, + softKickCooldownMs: policy.stallSoftKickCooldownMs, + nowMs: 10000 + }), + { action: 'none', idleMs: 4500, reason: 'soft_kick_cooldown' }, + 'expected cooldown to suppress repeated soft-kicks' +); +assert.deepEqual( + resolveStage1StallAction({ + idleMs: 9200, + hardAbortMs: policy.stallAbortMs, + softKickMs: policy.stallSoftKickMs, + softKickAttempts: 1, + softKickMaxAttempts: policy.stallSoftKickMaxAttempts, + nowMs: 20000 + }), + { action: 'abort', idleMs: 9200 }, + 'expected hard abort once the idle threshold is crossed' +); + +console.log('shared stage1 watchdog policy test passed'); diff --git a/tests/shared/io/append-writer-flush-after-close.test.js b/tests/shared/io/append-writer-flush-after-close.test.js new file mode 100644 index 000000000..b8cf48e13 --- /dev/null +++ b/tests/shared/io/append-writer-flush-after-close.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createQueuedAppendWriter } from '../../../src/shared/io/append-writer.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'append-writer-flush-after-close'); +const filePath = path.join(tempRoot, 'events.log'); +const errors = []; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const writer = createQueuedAppendWriter({ + filePath, + syncOnFlush: true, + onError(stage, err) { + errors.push({ + stage, + code: err?.code || null, + message: err?.message || String(err) + }); + } +}); + +await writer.enqueue('first\n'); +await writer.close(); +await writer.flush(); +await writer.enqueue('late\n'); +await writer.flush(); + +const text = await fs.readFile(filePath, 'utf8'); +assert.equal(text, 'first\n', 'writer should ignore flush/enqueue calls after close'); +assert.deepEqual(errors, [], 'flush after close should not touch a closed handle'); + +console.log('append writer flush after close test passed'); diff --git a/tests/shared/io/atomic-persistence-contract.test.js b/tests/shared/io/atomic-persistence-contract.test.js new file mode 100644 index 000000000..95780aa4f --- /dev/null +++ b/tests/shared/io/atomic-persistence-contract.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { replaceFile, replaceFileSync } from '../../../src/shared/io/replace-file.js'; +import { replaceDir } from '../../../src/shared/io/replace-dir.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-atomic-persistence-')); + +try { + const finalFile = path.join(tempRoot, 'final.txt'); + const missingTempFile = path.join(tempRoot, 'missing-temp.txt'); + + await assert.rejects( + replaceFile(missingTempFile, finalFile), + (error) => error?.code === 'ERR_TEMP_MISSING' && /Temp file missing before replace/i.test(error.message), + 'replaceFile should fail closed with ERR_TEMP_MISSING when the temp file is absent' + ); + + const syncError = (() => { + try { + replaceFileSync(missingTempFile, finalFile); + return null; + } catch (error) { + return error; + } + })(); + assert.ok(syncError instanceof Error, 'replaceFileSync should throw when the temp file is absent'); + assert.equal(syncError?.code, 'ERR_TEMP_MISSING'); + assert.match(syncError?.message || '', /Temp file missing before replace/i); + + const directoryTarget = path.join(tempRoot, 'directory-target'); + const tempPayload = path.join(tempRoot, 'payload.txt'); + await fs.mkdir(directoryTarget, { recursive: true }); + await fs.writeFile(tempPayload, 'payload', 'utf8'); + await assert.rejects( + replaceFile(tempPayload, directoryTarget), + (error) => error?.code === 'EISDIR' && /file replace requires a file target/i.test(error.message), + 'replaceFile should preserve the EISDIR contract for directory targets' + ); + assert.equal(await fs.readFile(tempPayload, 'utf8'), 'payload', 'temp payload should remain after EISDIR rejection'); + + const missingTempDir = path.join(tempRoot, 'missing-temp-dir'); + const finalDir = path.join(tempRoot, 'final-dir'); + await assert.rejects( + replaceDir(missingTempDir, finalDir), + (error) => error?.code === 'ERR_TEMP_MISSING' && /Temp dir missing before replace/i.test(error.message), + 'replaceDir should fail closed with ERR_TEMP_MISSING when the temp dir is absent' + ); + + console.log('atomic persistence contract test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/shared/io/atomic-write-contract.test.js b/tests/shared/io/atomic-write-contract.test.js index 13595e7d0..bdc751b23 100644 --- a/tests/shared/io/atomic-write-contract.test.js +++ b/tests/shared/io/atomic-write-contract.test.js @@ -4,11 +4,15 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { + getAtomicWriteDurabilityStatus, atomicWriteJson, atomicWriteJsonSync, atomicWriteText, - atomicWriteTextSync + atomicWriteTextSync, + getAtomicWriteRuntimeMetrics, + resetAtomicWriteRuntimeMetricsForTests } from '../../../src/shared/io/atomic-write.js'; +import { getMetricsText } from '../../../src/shared/metrics/core.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -72,6 +76,7 @@ assert.equal(emfileAttempts, 2, 'expected EMFILE retry path to be exercised'); const exdevPath = path.join(tempRoot, 'exdev.txt'); const originalRename = fsPromises.rename; let exdevAttempts = 0; +resetAtomicWriteRuntimeMetricsForTests(); fsPromises.rename = async (...args) => { const [, targetPath] = args; if (String(targetPath || '').includes('exdev.txt')) { @@ -89,6 +94,34 @@ try { } assert.equal(fs.readFileSync(exdevPath, 'utf8'), 'exdev-ok'); assert.ok(exdevAttempts >= 1, 'expected EXDEV rename fallback path to be exercised'); +assert.equal( + getAtomicWriteRuntimeMetrics().exdevRenameFallbackCount >= 1, + true, + 'expected EXDEV rename fallback metric to be incremented' +); +assert.equal( + getAtomicWriteRuntimeMetrics().degradedDurability, + true, + 'expected EXDEV rename fallback to mark degraded durability' +); +assert.equal( + typeof getAtomicWriteRuntimeMetrics().lastExdevFallbackAt, + 'string', + 'expected EXDEV fallback timestamp to be recorded' +); +const durabilityStatus = getAtomicWriteDurabilityStatus({ + repoPath: tempRoot, + cacheRoot: tempRoot, + repoCacheRoot: tempRoot +}); +assert.equal(durabilityStatus.runtime?.degradedDurability, true, 'expected durability status to expose degraded runtime state'); +assert.equal(durabilityStatus.layout?.crossDeviceRisk, false, 'expected same-root durability status to remain non-risky'); +const metricsText = await getMetricsText(); +assert.match( + metricsText, + /pairofcleats_atomic_persistence_fallbacks_total\{reason="exdev"\} 1/, + 'expected EXDEV fallback to surface in Prometheus metrics' +); const epermPath = path.join(tempRoot, 'eperm.txt'); const originalRenameEperm = fsPromises.rename; @@ -111,6 +144,45 @@ try { assert.equal(fs.readFileSync(epermPath, 'utf8'), 'eperm-ok'); assert.equal(epermAttempts, 5, 'expected EPERM rename retries to be exercised'); +const preservePath = path.join(tempRoot, 'preserve-existing-on-rename-failure.txt'); +await fsPromises.writeFile(preservePath, 'original', 'utf8'); +const originalRenamePreserve = fsPromises.rename; +let preserveRenameCalls = 0; +let preserveBackupPath = null; +fsPromises.rename = async (...args) => { + const [fromPath, targetPath] = args; + const from = String(fromPath || ''); + if (String(fromPath || '') === preservePath) { + preserveBackupPath = String(targetPath || ''); + } + if (String(targetPath || '') === preservePath && from.startsWith(`${preservePath}.tmp-`)) { + preserveRenameCalls += 1; + const err = new Error(preserveRenameCalls === 1 ? 'already exists' : 'invalid rename state'); + err.code = preserveRenameCalls === 1 ? 'EEXIST' : 'EINVAL'; + throw err; + } + return originalRenamePreserve(...args); +}; +let preserveError = null; +try { + await atomicWriteText(preservePath, 'new-content'); +} catch (err) { + preserveError = err; +} finally { + fsPromises.rename = originalRenamePreserve; +} +assert.ok(preserveError, 'expected rename failure path to propagate error'); +assert.equal( + fs.readFileSync(preservePath, 'utf8'), + 'original', + 'expected existing target content to be restored when replacement fails' +); +assert.equal( + path.dirname(String(preserveBackupPath || '')), + path.dirname(preservePath), + 'expected backup swap path to remain in target directory' +); + let mkdirError = null; try { await atomicWriteText(path.join(tempRoot, 'nested', 'missing.txt'), 'x', { mkdir: false }); diff --git a/tests/shared/io/bundle-io-checksum-fail-closed.test.js b/tests/shared/io/bundle-io-checksum-fail-closed.test.js new file mode 100644 index 000000000..e9f55cd4a --- /dev/null +++ b/tests/shared/io/bundle-io-checksum-fail-closed.test.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Packr, Unpackr } from 'msgpackr'; +import { + readBundleFile, + writeBundleFile +} from '../../../src/shared/bundle-io.js'; +import { MAX_BUNDLE_CHECKSUM_BYTES } from '../../../src/shared/bundle-contract.js'; +import { sha1 } from '../../../src/shared/hash.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { stableStringify } from '../../../src/shared/stable-json.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `bundle-io-checksum-fail-closed-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const packr = new Packr({ useRecords: false, structuredClone: true }); +const unpackr = new Unpackr({ useRecords: false }); +const bundlePath = path.join(tempRoot, 'bundle.mpk'); +const jsonBundlePath = path.join(tempRoot, 'bundle.json'); +const bundle = { + file: 'src/sample.ts', + chunks: [{ chunkUid: 'ck:test:1', text: 'export const answer = 42;' }] +}; +const largeText = 'x'.repeat(MAX_BUNDLE_CHECKSUM_BYTES + 1024); +const oversizedBundle = { + file: 'src/oversized.ts', + chunks: [{ chunkUid: 'ck:oversized:1', text: largeText }] +}; +const checksumFor = (value) => sha1(stableStringify(value)); + +const writeTamperedEnvelope = async (mutate) => { + const raw = await fs.readFile(bundlePath); + const envelope = unpackr.unpack(raw); + mutate(envelope); + await fs.writeFile(bundlePath, Buffer.from(packr.pack(envelope))); +}; + +try { + await writeBundleFile({ + bundlePath, + bundle, + format: 'msgpack' + }); + + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { schemaVersion: 2, algo: 'sha512', value: 'cafef00d' }; + }); + const unsupported = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(unsupported?.ok, false, 'expected unsupported checksum algo to fail closed'); + assert.equal(unsupported?.reason, 'unsupported bundle checksum algo'); + + await writeBundleFile({ + bundlePath, + bundle, + format: 'msgpack' + }); + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { schemaVersion: 2, algo: 'sha1', value: 'deadbeef' }; + }); + const mismatch = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(mismatch?.ok, false, 'expected checksum mismatch to fail closed'); + assert.equal(mismatch?.reason, 'bundle checksum mismatch'); + + await writeBundleFile({ + bundlePath, + bundle, + format: 'msgpack' + }); + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { schemaVersion: 2, algo: 'xxh64' }; + }); + const invalid = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(invalid?.ok, false, 'expected invalid checksum envelope to fail closed'); + assert.equal(invalid?.reason, 'invalid bundle checksum'); + + await writeBundleFile({ + bundlePath, + bundle, + format: 'msgpack' + }); + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { schemaVersion: 1, algo: 'xxh64', value: 'cafef00d' }; + }); + const schemaMismatch = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(schemaMismatch?.ok, false, 'expected unsupported checksum schema to fail closed'); + assert.equal(schemaMismatch?.reason, 'unsupported bundle checksum schema'); + + await writeBundleFile({ + bundlePath, + bundle, + format: 'msgpack' + }); + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { algo: 'sha1', value: checksumFor(bundle) }; + }); + const legacyMsgpack = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(legacyMsgpack?.ok, true, 'expected legacy msgpack checksum envelope to remain readable'); + + await writeBundleFile({ + bundlePath, + bundle: oversizedBundle, + format: 'msgpack' + }); + await writeTamperedEnvelope((envelope) => { + envelope.checksum = { schemaVersion: 2, algo: 'sha1', value: checksumFor(oversizedBundle) }; + }); + const oversizedMsgpack = await readBundleFile(bundlePath, { format: 'msgpack' }); + assert.equal(oversizedMsgpack?.ok, true, 'expected oversized msgpack bundle to load when checksum verification is skipped'); + + const jsonWrite = await writeBundleFile({ + bundlePath: jsonBundlePath, + bundle, + format: 'json' + }); + assert.equal(typeof jsonWrite?.checksum, 'string', 'expected json bundle checksum sidecar'); + const jsonChecksumPath = `${jsonBundlePath}.checksum.json`; + const checksumPayload = JSON.parse(await fs.readFile(jsonChecksumPath, 'utf8')); + checksumPayload.checksum.value = 'deadbeef'; + await fs.writeFile(jsonChecksumPath, `${JSON.stringify(checksumPayload)}\n`, 'utf8'); + const jsonMismatch = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(jsonMismatch?.ok, false, 'expected json checksum mismatch to fail closed'); + assert.equal(jsonMismatch?.reason, 'bundle checksum mismatch'); + + checksumPayload.checksum = { schemaVersion: 2, algo: 'sha512', value: 'abc' }; + await fs.writeFile(jsonChecksumPath, `${JSON.stringify(checksumPayload)}\n`, 'utf8'); + const jsonUnsupported = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(jsonUnsupported?.ok, false, 'expected json unsupported checksum algo to fail closed'); + assert.equal(jsonUnsupported?.reason, 'unsupported bundle checksum algo'); + + checksumPayload.checksumSchemaVersion = 1; + checksumPayload.checksum = { schemaVersion: 1, algo: 'xxh64', value: jsonWrite.checksum }; + await fs.writeFile(jsonChecksumPath, `${JSON.stringify(checksumPayload)}\n`, 'utf8'); + const jsonSchemaMismatch = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(jsonSchemaMismatch?.ok, false, 'expected json unsupported checksum schema to fail closed'); + assert.equal(jsonSchemaMismatch?.reason, 'unsupported bundle checksum schema'); + + checksumPayload.checksumSchemaVersion = undefined; + checksumPayload.checksum = { algo: 'sha1', value: checksumFor(bundle) }; + await fs.writeFile(jsonChecksumPath, `${JSON.stringify(checksumPayload)}\n`, 'utf8'); + const legacyJson = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(legacyJson?.ok, true, 'expected legacy json checksum envelope to remain readable'); + + await writeBundleFile({ + bundlePath: jsonBundlePath, + bundle: oversizedBundle, + format: 'json' + }); + const oversizedChecksumPayload = { + format: 'pairofcleats.bundle', + version: 1, + checksumSchemaVersion: 2, + checksum: { + schemaVersion: 2, + algo: 'sha1', + value: checksumFor(oversizedBundle) + } + }; + await fs.writeFile(jsonChecksumPath, `${JSON.stringify(oversizedChecksumPayload)}\n`, 'utf8'); + const oversizedJson = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(oversizedJson?.ok, true, 'expected oversized json bundle to load when checksum verification is skipped'); + + await writeBundleFile({ + bundlePath: jsonBundlePath, + bundle, + format: 'json' + }); + const checksumBeforeFailedRewrite = await fs.readFile(jsonChecksumPath, 'utf8'); + const circularBundle = { + file: 'src/circular.ts', + chunks: [] + }; + circularBundle.self = circularBundle; + await assert.rejects( + writeBundleFile({ + bundlePath: jsonBundlePath, + bundle: circularBundle, + format: 'json' + }), + /circular|cyclic/i, + 'expected circular json bundle rewrite to fail' + ); + const checksumAfterFailedRewrite = await fs.readFile(jsonChecksumPath, 'utf8'); + assert.equal( + checksumAfterFailedRewrite, + checksumBeforeFailedRewrite, + 'expected prior checksum sidecar to remain after failed json rewrite' + ); + const preservedRead = await readBundleFile(jsonBundlePath, { format: 'json' }); + assert.equal(preservedRead?.ok, true, 'expected prior json bundle to stay readable after failed rewrite'); + + const typedBundle = { + file: 'src/vector.ts', + chunks: [{ chunkUid: 'ck:typed:1', embedding_u8: Uint8Array.from([1, 2, 3]) }] + }; + const typedJsonBundlePath = path.join(tempRoot, 'typed-bundle.json'); + const typedMsgpackBundlePath = path.join(tempRoot, 'typed-bundle.mpk'); + await writeBundleFile({ + bundlePath: typedJsonBundlePath, + bundle: typedBundle, + format: 'json' + }); + await writeBundleFile({ + bundlePath: typedMsgpackBundlePath, + bundle: typedBundle, + format: 'msgpack' + }); + const typedJsonRead = await readBundleFile(typedJsonBundlePath, { format: 'json' }); + const typedMsgpackRead = await readBundleFile(typedMsgpackBundlePath, { format: 'msgpack' }); + assert.equal(typedJsonRead?.ok, true, 'expected typed-array json bundle checksum verification to pass'); + assert.equal(typedMsgpackRead?.ok, true, 'expected typed-array msgpack bundle checksum verification to pass'); + + console.log('bundle io checksum fail-closed test passed'); +} finally { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/shared/io/bundle-transform-worker-checksum.test.js b/tests/shared/io/bundle-transform-worker-checksum.test.js new file mode 100644 index 000000000..fedd0565e --- /dev/null +++ b/tests/shared/io/bundle-transform-worker-checksum.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { Worker } from 'node:worker_threads'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { MAX_BUNDLE_CHECKSUM_BYTES } from '../../../src/shared/bundle-contract.js'; +import { + checksumBundlePayloadLocal, + normalizeBundlePayload +} from '../../../src/shared/bundle-io-checksum.js'; + +applyTestEnv({ testing: '1' }); + +const workerUrl = new URL('../../../src/shared/workers/bundle-transform-worker.js', import.meta.url); + +const runNormalizeChecksumWorker = (bundle) => new Promise((resolve, reject) => { + const worker = new Worker(workerUrl, { + type: 'module', + workerData: { + operation: 'normalize-checksum', + payload: { bundle } + } + }); + const timer = setTimeout(() => { + reject(new Error('bundle transform worker checksum test timed out')); + void worker.terminate(); + }, 5000); + timer.unref?.(); + worker.once('message', (message) => { + clearTimeout(timer); + resolve(message); + }); + worker.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + worker.once('exit', (code) => { + if (code === 0) return; + clearTimeout(timer); + reject(new Error(`bundle transform worker exited with ${code}`)); + }); +}); + +const bundle = { + z: 'last', + a: { + d: 4, + b: 2 + }, + chunks: [ + { + text: 'alpha', + chunkId: 'c1', + file: 'src/a.js' + } + ] +}; +const expectedNormalized = normalizeBundlePayload(bundle); +const expectedChecksum = await checksumBundlePayloadLocal(expectedNormalized, { + maxChecksumBytes: MAX_BUNDLE_CHECKSUM_BYTES +}); + +const message = await runNormalizeChecksumWorker(bundle); + +assert.equal(message?.ok, true, message?.error || 'expected worker checksum normalization to succeed'); +assert.deepEqual(message.result?.normalized, expectedNormalized, 'expected worker normalized payload parity'); +assert.deepEqual(message.result?.checksum, expectedChecksum, 'expected worker checksum parity'); +assert.equal(typeof message.result?.checksum?.value, 'string', 'expected cloneable checksum value'); + +console.log('bundle transform worker checksum parity ok'); diff --git a/tests/shared/json-file.test.js b/tests/shared/json-file.test.js new file mode 100644 index 000000000..3f753ce37 --- /dev/null +++ b/tests/shared/json-file.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + readJsonFile, + readJsonFileResolved, + readJsonFileResolvedSafe, + readJsonFileSyncSafeResolved, + writeJsonFile, + writeJsonFileResolved, + writeJsonFileSyncResolved +} from '../../src/shared/json-file.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-json-file-')); + +try { + const targetPath = path.join(tempRoot, 'nested', 'payload.json'); + await writeJsonFile(targetPath, { alpha: 1, beta: ['x', 'y'] }); + const raw = await fs.readFile(targetPath, 'utf8'); + assert.match(raw, /\n$/); + assert.deepEqual(await readJsonFile(targetPath), { alpha: 1, beta: ['x', 'y'] }); + + const compactPath = path.join(tempRoot, 'compact.json'); + await writeJsonFile(compactPath, { ok: true }, { spaces: 0, finalNewline: false }); + assert.equal(await fs.readFile(compactPath, 'utf8'), '{"ok":true}'); + assert.deepEqual(await readJsonFile(compactPath), { ok: true }); + + const resolvedPath = await writeJsonFileResolved(path.join(tempRoot, 'resolved', 'payload.json'), { gamma: 3 }, { + finalNewline: true + }); + assert.equal(path.isAbsolute(resolvedPath), true); + assert.deepEqual(await readJsonFileResolved(resolvedPath), { gamma: 3 }); + assert.deepEqual(await readJsonFileResolvedSafe(resolvedPath), { gamma: 3 }); + assert.deepEqual(await readJsonFileResolvedSafe(path.join(tempRoot, 'missing.json'), { missing: true }), { missing: true }); + + const invalidPath = path.join(tempRoot, 'invalid.json'); + await fs.writeFile(invalidPath, '{"broken":', 'utf8'); + await assert.rejects( + readJsonFileResolved(invalidPath), + SyntaxError, + 'readJsonFileResolved should preserve raw parse failures' + ); + assert.deepEqual( + await readJsonFileResolvedSafe(invalidPath, { parseFallback: true }), + { parseFallback: true }, + 'readJsonFileResolvedSafe should keep the safe fallback contract on parse errors' + ); + + const syncPath = writeJsonFileSyncResolved(path.join(tempRoot, 'sync', 'payload.json'), { delta: 4 }, { + spaces: 0, + finalNewline: false + }); + assert.equal(fsSync.readFileSync(syncPath, 'utf8'), '{"delta":4}'); + assert.deepEqual(readJsonFileSyncSafeResolved(syncPath), { delta: 4 }); + assert.deepEqual(readJsonFileSyncSafeResolved(path.join(tempRoot, 'missing-sync.json'), { fallback: true }), { fallback: true }); + + console.log('json file helper test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/shared/json-stream/abort-closes-stream.test.js b/tests/shared/json-stream/abort-closes-stream.test.js index 7ad3b9660..ca7940b3b 100644 --- a/tests/shared/json-stream/abort-closes-stream.test.js +++ b/tests/shared/json-stream/abort-closes-stream.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/atomic-dir-replace-fallback-rollback.test.js b/tests/shared/json-stream/atomic-dir-replace-fallback-rollback.test.js new file mode 100644 index 000000000..ab9268f77 --- /dev/null +++ b/tests/shared/json-stream/atomic-dir-replace-fallback-rollback.test.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { replaceDir } from '../../../src/shared/json-stream/atomic.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'json-stream-atomic-dir-replace-fallback-rollback'); +await fsPromises.rm(outDir, { recursive: true, force: true }); +await fsPromises.mkdir(outDir, { recursive: true }); + +const createDirWithFile = async (dirPath, relPath, content) => { + await fsPromises.mkdir(path.dirname(path.join(dirPath, relPath)), { recursive: true }); + await fsPromises.writeFile(path.join(dirPath, relPath), content, 'utf8'); +}; + +// Case 1: fallback copy failure must not delete the original final directory. +{ + const finalPath = path.join(outDir, 'case1-final'); + const tempPath = path.join(outDir, 'case1-temp'); + await createDirWithFile(finalPath, 'keep.txt', 'old-value'); + await createDirWithFile(tempPath, 'keep.txt', 'new-value'); + + const originalRename = fsPromises.rename; + const originalCp = fsPromises.cp; + fsPromises.rename = async (from, to) => { + if (from === tempPath && to === finalPath) { + const err = new Error('cross-device rename blocked'); + err.code = 'EXDEV'; + throw err; + } + return originalRename(from, to); + }; + fsPromises.cp = async (from, to, options) => { + if (from === tempPath) { + const err = new Error('staged copy failed'); + err.code = 'EIO'; + throw err; + } + return originalCp(from, to, options); + }; + + let failure = null; + try { + await replaceDir(tempPath, finalPath, { keepBackup: false }); + } catch (err) { + failure = err; + } finally { + fsPromises.rename = originalRename; + fsPromises.cp = originalCp; + } + + assert.ok(failure, 'expected replaceDir to fail when fallback copy fails'); + assert.equal(failure?.code, 'EXDEV', 'expected original rename failure to surface'); + assert.equal( + await fsPromises.readFile(path.join(finalPath, 'keep.txt'), 'utf8'), + 'old-value', + 'expected final directory to remain unchanged when fallback fails' + ); + assert.equal(fs.existsSync(tempPath), true, 'expected temp directory to remain for retry/diagnostics'); + assert.equal(fs.existsSync(`${finalPath}.bak`), false, 'expected transient backup to be restored and cleared'); +} + +// Case 2: pre-existing stale backup must not overwrite a healthy final directory. +{ + const finalPath = path.join(outDir, 'case2-final'); + const tempPath = path.join(outDir, 'case2-temp'); + const bakPath = `${finalPath}.bak`; + await createDirWithFile(finalPath, 'keep.txt', 'current-final'); + await createDirWithFile(tempPath, 'keep.txt', 'next-final'); + await createDirWithFile(bakPath, 'keep.txt', 'stale-backup'); + + const originalRename = fsPromises.rename; + fsPromises.rename = async (from, to) => { + if (from === tempPath && to === finalPath) { + const err = new Error('non-retryable rename failure'); + err.code = 'EINVAL'; + throw err; + } + return originalRename(from, to); + }; + + let failure = null; + try { + await replaceDir(tempPath, finalPath, { keepBackup: false }); + } catch (err) { + failure = err; + } finally { + fsPromises.rename = originalRename; + } + + assert.ok(failure, 'expected replaceDir to fail on non-retryable rename error'); + assert.equal(failure?.code, 'EINVAL', 'expected non-retryable error to surface'); + assert.equal( + await fsPromises.readFile(path.join(finalPath, 'keep.txt'), 'utf8'), + 'current-final', + 'expected stale backup to not overwrite a healthy final directory' + ); + assert.equal( + await fsPromises.readFile(path.join(bakPath, 'keep.txt'), 'utf8'), + 'stale-backup', + 'expected stale backup to remain untouched' + ); +} + +console.log('atomic dir replace fallback rollback test passed'); diff --git a/tests/shared/json-stream/atomic-replace.test.js b/tests/shared/json-stream/atomic-replace.test.js index 93ff61904..2db2c393c 100644 --- a/tests/shared/json-stream/atomic-replace.test.js +++ b/tests/shared/json-stream/atomic-replace.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { replaceFile, createTempPath } from '../../../src/shared/json-stream.js'; +import { replaceFile, createTempPath } from '../../../src/shared/json-stream/atomic.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/atomic-stale-backup-protection.test.js b/tests/shared/json-stream/atomic-stale-backup-protection.test.js index 9c14f71c4..69a766165 100644 --- a/tests/shared/json-stream/atomic-stale-backup-protection.test.js +++ b/tests/shared/json-stream/atomic-stale-backup-protection.test.js @@ -5,7 +5,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { replaceFile } from '../../../src/shared/json-stream.js'; +import { replaceFile } from '../../../src/shared/json-stream/atomic.js'; applyTestEnv(); diff --git a/tests/shared/json-stream/codec-contract.test.js b/tests/shared/json-stream/codec-contract.test.js new file mode 100644 index 000000000..89cbd03c1 --- /dev/null +++ b/tests/shared/json-stream/codec-contract.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { readJsonFile } from '../../../src/shared/artifact-io.js'; +import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { tryRequire } from '../../../src/shared/optional-deps.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'json-stream'); +await fs.rm(outDir, { recursive: true, force: true }); +await fs.mkdir(outDir, { recursive: true }); + +const arrayPath = path.join(outDir, 'array.json'); +const arrayInput = [ + { id: 1, name: 'alpha' }, + { id: 2, name: 'beta' } +]; +await writeJsonArrayFile(arrayPath, arrayInput); +const arrayParsed = JSON.parse(await fs.readFile(arrayPath, 'utf8')); +if (JSON.stringify(arrayParsed) !== JSON.stringify(arrayInput)) { + console.error('json-stream array test failed: parsed output mismatch.'); + process.exit(1); +} + +const objPath = path.join(outDir, 'object.json'); +const fields = { model: 'test', dims: 2, scale: 1 }; +const arrays = { + vectors: [ + [1, 2], + [3, 4] + ], + vocab: ['foo', 'bar'] +}; +await writeJsonObjectFile(objPath, { fields, arrays }); +const objParsed = JSON.parse(await fs.readFile(objPath, 'utf8')); +if (objParsed.model !== fields.model || objParsed.dims !== fields.dims || objParsed.scale !== fields.scale) { + console.error('json-stream object test failed: fields mismatch.'); + process.exit(1); +} +if (!Array.isArray(objParsed.vectors) || objParsed.vectors.length !== arrays.vectors.length) { + console.error('json-stream object test failed: vectors mismatch.'); + process.exit(1); +} +if (!Array.isArray(objParsed.vocab) || objParsed.vocab.length !== arrays.vocab.length) { + console.error('json-stream object test failed: vocab mismatch.'); + process.exit(1); +} + +const zstdAvailable = tryRequire('@mongodb-js/zstd').ok; +if (zstdAvailable) { + const zstdPath = path.join(outDir, 'array-zstd.json.zst'); + await writeJsonArrayFile(zstdPath, arrayInput, { compression: 'zstd' }); + const zstdParsed = readJsonFile(path.join(outDir, 'array-zstd.json')); + if (JSON.stringify(zstdParsed) !== JSON.stringify(arrayInput)) { + console.error('json-stream zstd test failed: parsed output mismatch.'); + process.exit(1); + } + console.log('json-stream zstd test passed'); +} + +console.log('json-stream test passed'); + diff --git a/tests/shared/json-stream/compress-options.test.js b/tests/shared/json-stream/compress-options.test.js index ac7df3982..f405cfee9 100644 --- a/tests/shared/json-stream/compress-options.test.js +++ b/tests/shared/json-stream/compress-options.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/gzip-options-forwarded.test.js b/tests/shared/json-stream/gzip-options-forwarded.test.js index 519fdfdbf..9b2e1b3a6 100644 --- a/tests/shared/json-stream/gzip-options-forwarded.test.js +++ b/tests/shared/json-stream/gzip-options-forwarded.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/json-stream-typedarray-sharded.test.js b/tests/shared/json-stream/json-stream-typedarray-sharded.test.js deleted file mode 100644 index 587428ba1..000000000 --- a/tests/shared/json-stream/json-stream-typedarray-sharded.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { writeJsonLinesSharded } from '../../../src/shared/json-stream.js'; -import { fromPosix } from '../../../src/shared/files.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const cacheRoot = resolveTestCachePath(root, 'json-stream-typedarray-sharded'); -await fs.rm(cacheRoot, { recursive: true, force: true }); -await fs.mkdir(cacheRoot, { recursive: true }); - -const items = [ - { id: 1, vec: new Uint8Array([1, 2, 3]) }, - { id: 2, vec: new Uint8Array([4, 5, 6]) } -]; - -const result = await writeJsonLinesSharded({ - dir: cacheRoot, - partsDirName: 'typed.parts', - partPrefix: 'typed.part-', - items, - maxBytes: 1024, - atomic: true -}); - -assert.ok(result.parts.length >= 1, 'expected at least one sharded output'); -const partPath = path.join(cacheRoot, fromPosix(result.parts[0])); -const raw = await fs.readFile(partPath, 'utf8'); -const line = raw.trim().split(/\r?\n/)[0]; -const parsed = JSON.parse(line); -assert.deepEqual(parsed.vec, [1, 2, 3], 'expected typed arrays serialized as JSON arrays'); - -console.log('json-stream typed array sharded test passed'); - diff --git a/tests/shared/json-stream/json-stream.test.js b/tests/shared/json-stream/json-stream.test.js deleted file mode 100644 index 565d2212b..000000000 --- a/tests/shared/json-stream/json-stream.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { readJsonFile } from '../../../src/shared/artifact-io.js'; -import { writeJsonArrayFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { tryRequire } from '../../../src/shared/optional-deps.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'json-stream'); -await fs.rm(outDir, { recursive: true, force: true }); -await fs.mkdir(outDir, { recursive: true }); - -const arrayPath = path.join(outDir, 'array.json'); -const arrayInput = [ - { id: 1, name: 'alpha' }, - { id: 2, name: 'beta' } -]; -await writeJsonArrayFile(arrayPath, arrayInput); -const arrayParsed = JSON.parse(await fs.readFile(arrayPath, 'utf8')); -if (JSON.stringify(arrayParsed) !== JSON.stringify(arrayInput)) { - console.error('json-stream array test failed: parsed output mismatch.'); - process.exit(1); -} - -const objPath = path.join(outDir, 'object.json'); -const fields = { model: 'test', dims: 2, scale: 1 }; -const arrays = { - vectors: [ - [1, 2], - [3, 4] - ], - vocab: ['foo', 'bar'] -}; -await writeJsonObjectFile(objPath, { fields, arrays }); -const objParsed = JSON.parse(await fs.readFile(objPath, 'utf8')); -if (objParsed.model !== fields.model || objParsed.dims !== fields.dims || objParsed.scale !== fields.scale) { - console.error('json-stream object test failed: fields mismatch.'); - process.exit(1); -} -if (!Array.isArray(objParsed.vectors) || objParsed.vectors.length !== arrays.vectors.length) { - console.error('json-stream object test failed: vectors mismatch.'); - process.exit(1); -} -if (!Array.isArray(objParsed.vocab) || objParsed.vocab.length !== arrays.vocab.length) { - console.error('json-stream object test failed: vocab mismatch.'); - process.exit(1); -} - -const zstdAvailable = tryRequire('@mongodb-js/zstd').ok; -if (zstdAvailable) { - const zstdPath = path.join(outDir, 'array-zstd.json.zst'); - await writeJsonArrayFile(zstdPath, arrayInput, { compression: 'zstd' }); - const zstdParsed = readJsonFile(path.join(outDir, 'array-zstd.json')); - if (JSON.stringify(zstdParsed) !== JSON.stringify(arrayInput)) { - console.error('json-stream zstd test failed: parsed output mismatch.'); - process.exit(1); - } - console.log('json-stream zstd test passed'); -} - -console.log('json-stream test passed'); - diff --git a/tests/shared/json-stream/jsonl-utf8.test.js b/tests/shared/json-stream/jsonl-utf8.test.js index c2a1c6f5b..acad8a0b4 100644 --- a/tests/shared/json-stream/jsonl-utf8.test.js +++ b/tests/shared/json-stream/jsonl-utf8.test.js @@ -2,7 +2,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { readJsonLinesArray } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/jsonl-worker-order.test.js b/tests/shared/json-stream/jsonl-worker-order.test.js index b82fd1072..558f6bf0a 100644 --- a/tests/shared/json-stream/jsonl-worker-order.test.js +++ b/tests/shared/json-stream/jsonl-worker-order.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { readJsonLinesArray } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { tryRequire } from '../../../src/shared/optional-deps.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/large-array-stream.test.js b/tests/shared/json-stream/large-array-stream.test.js index 3059cc5f9..e63158911 100644 --- a/tests/shared/json-stream/large-array-stream.test.js +++ b/tests/shared/json-stream/large-array-stream.test.js @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { readJsonFile } from '../../../src/shared/artifact-io.js'; -import { writeJsonArrayFile } from '../../../src/shared/json-stream.js'; +import { writeJsonArrayFile } from '../../../src/shared/json-stream/json-writers.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/maxbytes-enforced.test.js b/tests/shared/json-stream/maxbytes-enforced.test.js index acf19c1e8..ae8ac7172 100644 --- a/tests/shared/json-stream/maxbytes-enforced.test.js +++ b/tests/shared/json-stream/maxbytes-enforced.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesSharded } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/tombstone-cleanup-target-scope.test.js b/tests/shared/json-stream/tombstone-cleanup-target-scope.test.js index 5f80d3c91..9d9437e13 100644 --- a/tests/shared/json-stream/tombstone-cleanup-target-scope.test.js +++ b/tests/shared/json-stream/tombstone-cleanup-target-scope.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; diff --git a/tests/shared/json-stream/typedarray-sharded.test.js b/tests/shared/json-stream/typedarray-sharded.test.js new file mode 100644 index 000000000..314fe6933 --- /dev/null +++ b/tests/shared/json-stream/typedarray-sharded.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { fromPosix } from '../../../src/shared/file-paths.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const cacheRoot = resolveTestCachePath(root, 'json-stream-typedarray-sharded'); +await fs.rm(cacheRoot, { recursive: true, force: true }); +await fs.mkdir(cacheRoot, { recursive: true }); + +const items = [ + { id: 1, vec: new Uint8Array([1, 2, 3]) }, + { id: 2, vec: new Uint8Array([4, 5, 6]) } +]; + +const result = await writeJsonLinesSharded({ + dir: cacheRoot, + partsDirName: 'typed.parts', + partPrefix: 'typed.part-', + items, + maxBytes: 1024, + atomic: true +}); + +assert.ok(result.parts.length >= 1, 'expected at least one sharded output'); +const partPath = path.join(cacheRoot, fromPosix(result.parts[0])); +const raw = await fs.readFile(partPath, 'utf8'); +const line = raw.trim().split(/\r?\n/)[0]; +const parsed = JSON.parse(line); +assert.deepEqual(parsed.vec, [1, 2, 3], 'expected typed arrays serialized as JSON arrays'); + +console.log('json-stream typed array sharded test passed'); + diff --git a/tests/shared/json-stream/wait-timeout-cleans-listeners.test.js b/tests/shared/json-stream/wait-timeout-cleans-listeners.test.js new file mode 100644 index 000000000..07c8f3740 --- /dev/null +++ b/tests/shared/json-stream/wait-timeout-cleans-listeners.test.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { waitForStreamEvent } from '../../../src/shared/json-stream/streams.js'; + +const stream = new EventEmitter(); + +for (let i = 0; i < 32; i += 1) { + const error = await waitForStreamEvent(stream, 'drain', { + timeoutMs: 5, + label: 'wait-timeout-cleans-listeners' + }).then(() => null, (err) => err); + assert.ok(error instanceof Error, 'expected timeout error from waitForStreamEvent'); + assert.equal(error.code, 'JSON_STREAM_WAIT_TIMEOUT'); +} + +assert.equal(stream.listenerCount('drain'), 0, 'expected drain listeners to be cleaned after timeout'); +assert.equal(stream.listenerCount('error'), 0, 'expected error listeners to be cleaned after timeout'); + +console.log('json-stream wait timeout listener cleanup test passed'); diff --git a/tests/shared/jsonrpc/jsonrpc-parser.test.js b/tests/shared/jsonrpc/parser.test.js similarity index 100% rename from tests/shared/jsonrpc/jsonrpc-parser.test.js rename to tests/shared/jsonrpc/parser.test.js diff --git a/tests/shared/kill-tree.windows-orphan-descendant-fallback.test.js b/tests/shared/kill-tree.windows-orphan-descendant-fallback.test.js new file mode 100644 index 000000000..a8a8912e8 --- /dev/null +++ b/tests/shared/kill-tree.windows-orphan-descendant-fallback.test.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { killProcessTree } from '../../src/shared/kill-tree.js'; +import { skip } from '../helpers/skip.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { sleep } from '../../src/shared/sleep.js'; + +if (process.platform !== 'win32') { + skip('windows orphan descendant fallback test skipped on non-windows'); +} + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `kill-tree-windows-orphan-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +const childPidPath = path.join(tempRoot, 'child.pid'); + +const parentScript = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + "const pidPath = process.argv[1];", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000);'], {", + " detached: true,", + " stdio: 'ignore'", + "});", + 'child.unref();', + 'fs.writeFileSync(pidPath, String(child.pid));', + 'process.exit(0);' +].join('\n'); + +const parent = spawn(process.execPath, ['-e', parentScript, childPidPath], { + stdio: 'ignore', + detached: false +}); + +if (!parent.pid) { + throw new Error('missing parent pid'); +} + +await new Promise((resolve, reject) => { + parent.once('error', reject); + parent.once('exit', () => resolve()); +}); + +let childPid = null; +for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const value = String(await fs.readFile(childPidPath, 'utf8')).trim(); + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + childPid = Math.floor(parsed); + break; + } + } catch {} + await sleep(50); +} + +assert.ok(Number.isFinite(childPid) && childPid > 0, 'expected spawned descendant child pid'); + +const assertProcessAlive = (pid) => { + try { + process.kill(pid, 0); + } catch (error) { + throw new Error(`expected process ${pid} alive before fallback kill (${error?.code || error})`); + } +}; + +const assertProcessDead = (pid) => { + try { + process.kill(pid, 0); + throw new Error(`expected process ${pid} to be terminated`); + } catch (error) { + if (error?.code === 'EPERM') { + throw new Error(`expected process ${pid} terminated, but process still alive (EPERM)`); + } + } +}; + +try { + assertProcessAlive(childPid); + const result = await killProcessTree(parent.pid, { + killTree: true, + detached: false, + graceMs: 0, + awaitGrace: true + }); + assert.equal(result?.terminated, true, 'expected orphan descendant fallback to terminate process tree'); + assert.equal( + Number(result?.fallbackTerminated || 0) > 0 || result?.fallbackAttempted === false, + true, + 'expected fallback kill metadata when parent already exited' + ); + + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertProcessDead(childPid); + break; + } catch (error) { + if (attempt === 19) throw error; + await sleep(50); + } + } +} finally { + if (Number.isFinite(childPid) && childPid > 0) { + await killProcessTree(childPid, { + killTree: true, + detached: true, + graceMs: 0 + }); + } + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('kill-tree windows orphan descendant fallback test passed'); diff --git a/tests/shared/lifecycle/contract-matrix.test.js b/tests/shared/lifecycle/contract-matrix.test.js new file mode 100644 index 000000000..cbd819754 --- /dev/null +++ b/tests/shared/lifecycle/contract-matrix.test.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { spawn } from 'node:child_process'; + +import { createLifecycleRegistry } from '../../../src/shared/lifecycle/registry.js'; +import { runNode } from '../../helpers/run-node.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +{ + const asyncCloseError = new Error('async disposer close failed'); + const reportedErrors = []; + const unhandledRejections = []; + const onUnhandledRejection = (reason) => unhandledRejections.push(reason); + process.on('unhandledRejection', onUnhandledRejection); + try { + const registry = createLifecycleRegistry({ + name: 'lifecycle-disposer-async-close', + onError: (err) => reportedErrors.push(err) + }); + const unregister = registry.register(null, { + label: 'async-close', + close: async () => { + throw asyncCloseError; + } + }); + unregister(); + await sleep(20); + assert.equal(reportedErrors.length, 1); + assert.equal(reportedErrors[0], asyncCloseError); + assert.equal(unhandledRejections.length, 0); + await registry.close(); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } +} + +{ + const registry = createLifecycleRegistry({ name: 'lifecycle-contract' }); + let ticks = 0; + const interval = setInterval(() => { + ticks += 1; + }, 5); + registry.registerTimer(interval, { label: 'contract-interval' }); + let cleanupCalls = 0; + registry.registerCleanup(() => { + cleanupCalls += 1; + }, { label: 'contract-cleanup' }); + let resolved = false; + registry.registerPromise((async () => { + await sleep(20); + resolved = true; + })(), { label: 'contract-promise' }); + await registry.drain(); + assert.equal(resolved, true); + await sleep(20); + const beforeCloseTicks = ticks; + await registry.close(); + await sleep(20); + assert.equal(ticks, beforeCloseTicks); + assert.equal(cleanupCalls, 1); + assert.throws(() => registry.registerCleanup(() => {}, { label: 'after-close' })); + + const workerRegistry = createLifecycleRegistry({ name: 'worker-contract' }); + let terminated = false; + workerRegistry.registerWorker({ terminate: async () => { terminated = true; } }, { label: 'worker' }); + await workerRegistry.close(); + assert.equal(terminated, true); +} + +{ + const childScript = [ + "import { createLifecycleRegistry } from './src/shared/lifecycle/registry.js';", + "const registry = createLifecycleRegistry({ name: 'registry-pending-keepalive' });", + "registry.registerPromise(new Promise(() => {}), { label: 'never-settles' });", + 'await registry.drain();' + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '-e', childScript], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + await sleep(200); + assert.equal(child.exitCode, null, `expected child to remain alive; stderr=${stderr || ''}`); + child.kill(); + const closeResult = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => resolve({ exitCode, signal })); + }); + assert.notEqual(closeResult.exitCode, 13); +} + +{ + ensureTestingEnv(process.env); + const root = process.cwd(); + const tempDir = path.join(root, '.testLogs', 'lifecycle'); + await fs.mkdir(tempDir, { recursive: true }); + const scriptPath = path.join(tempDir, `registry-unref-promise-drain-${process.pid}-${Date.now()}.mjs`); + const registryUrl = pathToFileURL(path.join(root, 'src', 'shared', 'lifecycle', 'registry.js')).href; + await fs.writeFile(scriptPath, [ + `import { createLifecycleRegistry } from ${JSON.stringify(registryUrl)};`, + 'const registry = createLifecycleRegistry({ name: "registry-unref-promise-drain" });', + 'registry.registerPromise(new Promise((resolve) => {', + ' const timer = setTimeout(resolve, 25);', + ' timer.unref?.();', + '}), { label: "unref-promise" });', + 'await registry.drain();', + 'console.log("registry keepalive ok");' + ].join('\n'), 'utf8'); + const result = runNode([scriptPath], 'registry unref promise drain', root, process.env, { + stdio: 'pipe', + timeoutMs: 5000, + allowFailure: true + }); + assert.equal(result.status, 0, `expected child to exit cleanly, stderr=${result.stderr}`); + assert.match(result.stdout, /registry keepalive ok/); + assert.doesNotMatch(`${result.stderr || ''}${result.stdout || ''}`, /Detected unsettled top-level await/); + await fs.rm(scriptPath, { force: true }); +} + +console.log('shared lifecycle contract matrix test passed'); diff --git a/tests/shared/lifecycle/lifecycle-registry-contract.test.js b/tests/shared/lifecycle/lifecycle-registry-contract.test.js deleted file mode 100644 index 8a2daaf94..000000000 --- a/tests/shared/lifecycle/lifecycle-registry-contract.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createLifecycleRegistry } from '../../../src/shared/lifecycle/registry.js'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const registry = createLifecycleRegistry({ name: 'lifecycle-contract' }); - -let ticks = 0; -const interval = setInterval(() => { - ticks += 1; -}, 5); -registry.registerTimer(interval, { label: 'contract-interval' }); - -let cleanupCalls = 0; -registry.registerCleanup(() => { - cleanupCalls += 1; -}, { label: 'contract-cleanup' }); - -let resolved = false; -registry.registerPromise((async () => { - await sleep(20); - resolved = true; -})(), { label: 'contract-promise' }); - -await registry.drain(); -assert.equal(resolved, true, 'expected drain to wait for registered promises'); - -await sleep(20); -const beforeCloseTicks = ticks; -await registry.close(); -await sleep(20); -assert.equal(ticks, beforeCloseTicks, 'expected registered timers to stop after close'); -assert.equal(cleanupCalls, 1, 'expected cleanup hook to run once'); - -let registerAfterCloseError = null; -try { - registry.registerCleanup(() => {}, { label: 'after-close' }); -} catch (err) { - registerAfterCloseError = err; -} -assert.ok(registerAfterCloseError, 'expected register after close to throw'); - -const workerRegistry = createLifecycleRegistry({ name: 'worker-contract' }); -let terminated = false; -workerRegistry.registerWorker({ - terminate: async () => { - terminated = true; - } -}, { label: 'worker' }); -await workerRegistry.close(); -assert.equal(terminated, true, 'expected registerWorker to terminate worker during close'); - -console.log('lifecycle registry contract ok.'); - diff --git a/tests/shared/limits/limits-normalization.test.js b/tests/shared/limits/normalization.test.js similarity index 100% rename from tests/shared/limits/limits-normalization.test.js rename to tests/shared/limits/normalization.test.js diff --git a/tests/shared/locks/file-lock-contract.test.js b/tests/shared/locks/file-lock-contract.test.js index 4ef498ce1..852647665 100644 --- a/tests/shared/locks/file-lock-contract.test.js +++ b/tests/shared/locks/file-lock-contract.test.js @@ -3,16 +3,21 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { acquireFileLock, readLockInfo } from '../../../src/shared/locks/file-lock.js'; +import { + acquireFileLock, + getFileLockRuntimeMetrics, + readLockInfo, + resetFileLockRuntimeMetricsForTests, + withFileLock +} from '../../../src/shared/locks/file-lock.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'file-lock-contract'); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('file-lock-contract', { root }); const lockPath = path.join(tempRoot, 'contract.lock'); -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); +resetFileLockRuntimeMetricsForTests(); const lock = await acquireFileLock({ lockPath }); assert.ok(lock, 'expected lock to be acquired'); @@ -21,15 +26,170 @@ assert.equal(Number(ownedInfo?.pid), process.pid); assert.ok(await lock.release(), 'expected lock release to succeed'); assert.equal(fs.existsSync(lockPath), false); +const reservedMetadataLock = await acquireFileLock({ + lockPath, + metadata: { + pid: 123, + lockId: 'spoofed-lock-id', + startedAt: '1970-01-01T00:00:00.000Z', + scope: 'metadata-preserved' + } +}); +assert.ok(reservedMetadataLock, 'expected lock acquisition with metadata'); +assert.equal(Number(reservedMetadataLock.payload?.pid), process.pid, 'metadata pid must not override lock owner pid'); +assert.notEqual( + String(reservedMetadataLock.payload?.lockId || ''), + 'spoofed-lock-id', + 'metadata lockId must not override generated lock id' +); +assert.notEqual( + String(reservedMetadataLock.payload?.startedAt || ''), + '1970-01-01T00:00:00.000Z', + 'metadata startedAt must not override generated startedAt' +); +assert.equal(reservedMetadataLock.payload?.scope, 'metadata-preserved', 'non-reserved metadata should be preserved'); +const reservedMetadataInfo = await readLockInfo(lockPath); +assert.equal(Number(reservedMetadataInfo?.pid), process.pid, 'lockfile pid must remain process pid'); +assert.equal(reservedMetadataInfo?.scope, 'metadata-preserved', 'expected non-reserved metadata persisted to lockfile'); +await reservedMetadataLock.release(); + await fsPromises.writeFile( lockPath, JSON.stringify({ pid: 999999, startedAt: new Date().toISOString() }), 'utf8' ); +await assert.rejects( + () => acquireFileLock({ + lockPath, + staleMs: 24 * 60 * 60 * 1000, + staleRemovalImpl: async () => { + const error = new Error('mock stale lock removal denied'); + error.code = 'EACCES'; + throw error; + } + }), + (error) => ( + error?.code === 'ERR_FILE_LOCK_STALE_REMOVE_FAILED' + && error?.causeCode === 'EACCES' + && String(error?.message || '').includes('stale lock cleanup failed') + ), + 'expected stale lock cleanup failures to propagate with structured metadata' +); const deadPidLock = await acquireFileLock({ lockPath, staleMs: 24 * 60 * 60 * 1000 }); assert.ok(deadPidLock, 'expected dead-pid lock to be replaced'); await deadPidLock.release(); +await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 999999, startedAt: new Date().toISOString() }), + 'utf8' +); +let benignRaceCleanupCalls = 0; +let benignRaceRecoveredLock = null; +for (let attempt = 0; attempt < 3 && !benignRaceRecoveredLock; attempt += 1) { + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 999999, startedAt: new Date().toISOString() }), + 'utf8' + ); + benignRaceRecoveredLock = await acquireFileLock({ + lockPath, + waitMs: 400, + pollMs: 10, + staleMs: 24 * 60 * 60 * 1000, + staleRemovalImpl: async ({ lockPath: candidatePath }) => { + benignRaceCleanupCalls += 1; + await fsPromises.rm(candidatePath, { force: true }); + const raceError = new Error('simulated stale cleanup race'); + raceError.code = 'ENOENT'; + throw raceError; + } + }); +} +assert.ok(benignRaceRecoveredLock, 'expected benign stale cleanup race to be retried'); +assert.equal(benignRaceCleanupCalls >= 1, true, 'expected stale cleanup race path to run'); +await benignRaceRecoveredLock.release(); + +await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 999999, startedAt: new Date().toISOString() }), + 'utf8' +); +const deadPidWithThrowingStaleHook = await acquireFileLock({ + lockPath, + staleMs: 24 * 60 * 60 * 1000, + onStale: () => { + throw new Error('intentional stale hook failure'); + } +}); +assert.ok( + deadPidWithThrowingStaleHook, + 'expected stale lock cleanup to proceed even when onStale hook throws' +); +await deadPidWithThrowingStaleHook.release(); + +await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 999999, startedAt: new Date().toISOString() }), + 'utf8' +); +const structuredCleanupCause = new Error('already structured cleanup failure'); +structuredCleanupCause.code = 'EACCES'; +const structuredCleanupError = new Error('wrapped stale cleanup failure', { cause: structuredCleanupCause }); +structuredCleanupError.code = 'ERR_FILE_LOCK_STALE_REMOVE_FAILED'; +structuredCleanupError.phase = 'acquire'; +await assert.rejects( + () => acquireFileLock({ + lockPath, + staleMs: 24 * 60 * 60 * 1000, + staleRemovalImpl: async () => { + throw structuredCleanupError; + } + }), + (error) => error === structuredCleanupError, + 'expected structured stale cleanup errors to propagate without re-wrapping' +); +await fsPromises.rm(lockPath, { force: true }); + +await fsPromises.writeFile(lockPath, 'not-json-lock'); +await fsPromises.utimes(lockPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000)); +let corruptStaleEvent = null; +const corruptStaleLock = await acquireFileLock({ + lockPath, + staleMs: 1_000, + waitMs: 0, + onStale: (event) => { + corruptStaleEvent = event; + } +}); +assert.ok(corruptStaleLock, 'expected stale corrupt lock to be replaced even without owner metadata'); +assert.equal(corruptStaleEvent?.removalMode, 'force', 'expected ownerless stale lock cleanup to use force mode'); +await corruptStaleLock.release(); + +await fsPromises.writeFile(lockPath, 'not-json-lock'); +const corruptFreshLock = await acquireFileLock({ + lockPath, + staleMs: 60_000, + waitMs: 0, + invalidLockGraceMs: 3_000 +}); +assert.equal(corruptFreshLock, null, 'expected fresh corrupt lock to remain busy until stale'); +await fsPromises.rm(lockPath, { force: true }); + +await fsPromises.writeFile(lockPath, 'not-json-lock'); +await fsPromises.utimes(lockPath, new Date(Date.now() - 5_000), new Date(Date.now() - 5_000)); +const corruptGraceExpiredLock = await acquireFileLock({ + lockPath, + staleMs: 60_000, + waitMs: 0, + invalidLockGraceMs: 3_000 +}); +assert.ok( + corruptGraceExpiredLock, + 'expected invalid lock metadata to be reclaimed after invalid-lock grace even when not stale by max age' +); +await corruptGraceExpiredLock.release(); + await fsPromises.writeFile( lockPath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), @@ -38,6 +198,20 @@ await fsPromises.writeFile( const activeLock = await acquireFileLock({ lockPath, waitMs: 50, pollMs: 10 }); assert.equal(activeLock, null, 'expected active lock acquisition to return null'); +const activeWithThrowingBusyHook = await acquireFileLock({ + lockPath, + waitMs: 20, + pollMs: 5, + onBusy: () => { + throw new Error('intentional busy hook failure'); + } +}); +assert.equal( + activeWithThrowingBusyHook, + null, + 'expected busy hook failure to not change lock acquisition decision' +); + let threwTimeout = false; try { await acquireFileLock({ @@ -73,5 +247,92 @@ const abortElapsedMs = Date.now() - abortStartedAt; assert.ok(abortElapsedMs < 2500, `expected lock wait abort to short-circuit quickly (elapsed=${abortElapsedMs}ms)`); await heldLock.release(); +await assert.rejects( + () => withFileLock({ lockPath }, async () => { + await fsPromises.rm(lockPath, { force: true }); + return 'worker-value'; + }), + (error) => ( + error?.code === 'ERR_FILE_LOCK_RELEASE_FAILED' + && error?.releaseResult === false + && error?.causeCode === '' + ), + 'expected withFileLock to throw structured error when release returns false' +); +await fsPromises.rm(lockPath, { force: true }); + +await assert.rejects( + () => withFileLock({ lockPath }, async (lockHandle) => { + lockHandle.release = async () => { + const releaseError = new Error('simulated release I/O failure'); + releaseError.code = 'EIO'; + throw releaseError; + }; + return 'worker-value'; + }), + (error) => ( + error?.code === 'ERR_FILE_LOCK_RELEASE_FAILED' + && error?.causeCode === 'EIO' + ), + 'expected withFileLock to throw structured error when release throws' +); +await fsPromises.rm(lockPath, { force: true }); + +await assert.rejects( + () => withFileLock({ lockPath }, async () => { + await fsPromises.rm(lockPath, { force: true }); + throw new Error('worker exploded'); + }), + (error) => ( + error?.code === 'ERR_FILE_LOCK_RELEASE_FAILED' + && error?.releaseResult === false + && error?.workerError?.message === 'worker exploded' + ), + 'expected release failure to surface even when worker also throws' +); +await fsPromises.rm(lockPath, { force: true }); + +const raceLockPath = path.join(tempRoot, 'race-parent', 'race.lock'); +let observedParentMissingRetry = false; +for (let attempt = 0; attempt < 8 && !observedParentMissingRetry; attempt += 1) { + let keepDeleting = true; + const disruptor = (async () => { + const endAt = Date.now() + 120; + while (keepDeleting && Date.now() < endAt) { + await fsPromises.rm(path.dirname(raceLockPath), { recursive: true, force: true }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 2)); + } + })(); + const lockWithRace = await acquireFileLock({ + lockPath: raceLockPath, + waitMs: 1000, + pollMs: 5 + }); + keepDeleting = false; + await disruptor; + assert.ok(lockWithRace, 'expected lock acquisition to survive parent directory deletion race'); + observedParentMissingRetry = Number(lockWithRace?.diagnostics?.parentMissingRetries || 0) > 0; + await lockWithRace.release(); +} +if (!observedParentMissingRetry) { + console.warn('[file-lock-contract] parent-missing retry race did not reproduce; continuing.'); +} +assert.ok( + Number(getFileLockRuntimeMetrics().parentMissingRetries) >= 0, + 'expected runtime metrics to expose parent-missing retry counter' +); + +const noWaitParentLockPath = path.join(tempRoot, 'missing-parent-no-wait', 'contract.lock'); +await fsPromises.rm(path.dirname(noWaitParentLockPath), { recursive: true, force: true }).catch(() => {}); +const noWaitParentLock = await acquireFileLock({ + lockPath: noWaitParentLockPath, + waitMs: 0 +}); +assert.ok( + noWaitParentLock, + 'expected immediate lock acquisition to recreate a missing parent directory without requiring wait time' +); +await noWaitParentLock.release(); + await fsPromises.rm(lockPath, { force: true }); console.log('file-lock contract ok.'); diff --git a/tests/shared/logging/contract-matrix.test.js b/tests/shared/logging/contract-matrix.test.js new file mode 100644 index 000000000..1007c7e48 --- /dev/null +++ b/tests/shared/logging/contract-matrix.test.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { createWarnOnce, normalizeWarnOnceKey } from '../../../src/shared/logging/warn-once.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +applyTestEnv(); + +{ + const messages = []; + const warnOnce = createWarnOnce({ logger: (message) => messages.push(message) }); + assert.equal(warnOnce('dedupe-key', 'first warning'), true); + assert.equal(warnOnce('dedupe-key', 'second warning'), false); + assert.deepEqual(messages, ['first warning']); + warnOnce.reset(); + messages.length = 0; + assert.equal(warnOnce('message-only warning'), true); + assert.equal(warnOnce('message-only warning'), false); + assert.deepEqual(messages, ['message-only warning']); + warnOnce.reset(); + messages.length = 0; + const keyA = { b: 2, a: 1 }; + const keyB = { a: 1, b: 2 }; + assert.equal(normalizeWarnOnceKey(keyA), normalizeWarnOnceKey(keyB)); + assert.equal(warnOnce(keyA, 'stable-key warning'), true); + assert.equal(warnOnce(keyB, 'duplicate stable-key warning'), false); + assert.deepEqual(messages, ['stable-key warning']); +} + +{ + const root = process.cwd(); + const binPath = path.join(root, 'bin', 'pairofcleats.js'); + const result = runNode( + [binPath, 'version'], + 'pairofcleats version warning contract', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', allowFailure: true } + ); + assert.equal(result.status, 0); + assert.equal((result.stdout || '').trim(), ''); + assert.ok((result.stderr || '').trim().length > 0); +} + +console.log('shared logging contract matrix test passed'); diff --git a/tests/shared/logging/stdout-contract.test.js b/tests/shared/logging/stdout-contract.test.js deleted file mode 100644 index 84d35dddd..000000000 --- a/tests/shared/logging/stdout-contract.test.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { spawnSubprocessSync } from '../../../src/shared/subprocess.js'; -applyTestEnv(); -const root = process.cwd(); -const binPath = path.join(root, 'bin', 'pairofcleats.js'); - -const result = spawnSubprocessSync(process.execPath, [binPath, 'version'], { - env: { ...process.env }, - captureStdout: true, - captureStderr: true, - outputMode: 'string', - rejectOnNonZeroExit: false -}); - -assert.equal(result.exitCode, 0); -assert.equal((result.stdout || '').trim(), '', 'expected no stdout for non-json output'); -assert.ok((result.stderr || '').trim().length > 0, 'expected stderr to contain version'); - -console.log('stdout contract test passed'); diff --git a/tests/shared/logging/warn-once.test.js b/tests/shared/logging/warn-once.test.js deleted file mode 100644 index 3d0992300..000000000 --- a/tests/shared/logging/warn-once.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createWarnOnce, normalizeWarnOnceKey } from '../../../src/shared/logging/warn-once.js'; - -const messages = []; -const warnOnce = createWarnOnce({ - logger: (message) => { - messages.push(message); - } -}); - -assert.equal(warnOnce('dedupe-key', 'first warning'), true); -assert.equal(warnOnce('dedupe-key', 'second warning'), false); -assert.deepEqual(messages, ['first warning']); - -warnOnce.reset(); -messages.length = 0; - -assert.equal(warnOnce('message-only warning'), true); -assert.equal(warnOnce('message-only warning'), false); -assert.deepEqual(messages, ['message-only warning']); - -warnOnce.reset(); -messages.length = 0; -const keyA = { b: 2, a: 1 }; -const keyB = { a: 1, b: 2 }; -assert.equal(normalizeWarnOnceKey(keyA), normalizeWarnOnceKey(keyB)); -assert.equal(warnOnce(keyA, 'stable-key warning'), true); -assert.equal(warnOnce(keyB, 'duplicate stable-key warning'), false); -assert.deepEqual(messages, ['stable-key warning']); - -console.log('warn-once helper ok.'); diff --git a/tests/shared/merge/benchmark-contract.test.js b/tests/shared/merge/benchmark-contract.test.js new file mode 100644 index 000000000..f2e2918a9 --- /dev/null +++ b/tests/shared/merge/benchmark-contract.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const script = path.join(root, 'tools', 'bench', 'merge', 'merge-core-throughput.js'); +const result = runNode( + [script, '--runs', '4', '--run-size', '50'], + 'merge benchmark contract', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe' } +); + +const output = `${result.stdout || ''}${result.stderr || ''}`; +assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); +assert.ok(output.includes('[bench] current'), 'missing current output'); +assert.ok(output.includes('[bench] delta'), 'missing delta output'); + +console.log('merge benchmark contract test passed'); diff --git a/tests/shared/merge/contract-matrix.test.js b/tests/shared/merge/contract-matrix.test.js new file mode 100644 index 000000000..2b12a1dde --- /dev/null +++ b/tests/shared/merge/contract-matrix.test.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; +import { createRowSpillCollector } from '../../../src/index/build/artifacts/helpers.js'; +import { createSpillSorter } from '../../../src/map/build-map/io.js'; +import { + createMergeRunManifest, + mergeRunsWithPlanner, + mergeSortedRuns, + mergeSortedRunsToFile, + readJsonlRows, + writeJsonlRunFile, + writeMergeRunManifest +} from '../../../src/shared/merge.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'merge-contract-matrix'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +{ + const caseRoot = path.join(tempRoot, 'stream-and-manifest'); + await fsPromises.mkdir(caseRoot, { recursive: true }); + const runA = path.join(caseRoot, 'run-a.jsonl'); + const runB = path.join(caseRoot, 'run-b.jsonl'); + const invalidRun = path.join(caseRoot, 'invalid.jsonl'); + const mergedPath = path.join(caseRoot, 'merged.jsonl'); + const manifestPath = path.join(caseRoot, 'merged.manifest.json'); + + await writeJsonlRunFile(runA, [{ key: 'a', rank: 1 }, { key: 'c', rank: 3 }], { atomic: true }); + await writeJsonlRunFile(runB, [{ key: 'b', rank: 2 }, { key: 'd', rank: 4 }], { atomic: true }); + await fsPromises.writeFile(invalidRun, '{"ok":1}\n{"bad":\n', 'utf8'); + + const loaded = []; + for await (const row of readJsonlRows(runA)) { + loaded.push(row); + } + assert.deepEqual(loaded, [{ key: 'a', rank: 1 }, { key: 'c', rank: 3 }]); + + await assert.rejects( + async () => { + for await (const _row of readJsonlRows(invalidRun)) { + // no-op + } + }, + /Invalid JSONL at/ + ); + + const mergedStats = await mergeSortedRunsToFile({ + runs: [runA, runB], + outputPath: mergedPath, + compare: (left, right) => left.rank - right.rank, + validateComparator: true, + atomic: true + }); + assert.equal(mergedStats.rows, 4); + + const mergedRanks = []; + for await (const row of readJsonlRows(mergedPath)) { + mergedRanks.push(row.rank); + } + assert.deepEqual(mergedRanks, [1, 2, 3, 4]); + + const manifest = createMergeRunManifest({ + runPath: mergedPath, + rows: mergedStats.rows, + bytes: mergedStats.bytes, + compareId: 'rank-asc' + }); + await writeMergeRunManifest(manifestPath, manifest); + const writtenManifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); + assert.equal(writtenManifest.compareId, 'rank-asc'); + assert.equal(writtenManifest.rows, 4); +} + +{ + const caseRoot = path.join(tempRoot, 'planner-and-cleanup'); + await fsPromises.mkdir(caseRoot, { recursive: true }); + const runsDir = path.join(caseRoot, 'runs'); + const mergeDir = path.join(caseRoot, 'merge'); + await fsPromises.mkdir(runsDir, { recursive: true }); + const runPaths = []; + for (let i = 0; i < 3; i += 1) { + const runPath = path.join(runsDir, `run-${i}.jsonl`); + await writeJsonlRunFile(runPath, [{ token: `t${i}`, postings: [i] }], { atomic: true }); + runPaths.push(runPath); + } + + const compareRows = (a, b) => { + const left = String(a?.token || ''); + const right = String(b?.token || ''); + if (left < right) return -1; + if (left > right) return 1; + return 0; + }; + const outputPath = path.join(mergeDir, 'merged.jsonl'); + const checkpointPath = path.join(mergeDir, 'merge.checkpoint.json'); + const result = await mergeRunsWithPlanner({ + runs: runPaths, + outputPath, + compare: compareRows, + tempDir: mergeDir, + runPrefix: 'merge', + checkpointPath, + maxOpenRuns: 2 + }); + assert.ok(fs.existsSync(outputPath)); + assert.ok(fs.readdirSync(mergeDir).some((name) => name.includes('.run-'))); + await result.cleanup(); + const afterCleanup = fs.existsSync(mergeDir) ? fs.readdirSync(mergeDir) : []; + assert.ok(!afterCleanup.some((name) => name.includes('.run-'))); + assert.ok(!fs.existsSync(checkpointPath)); +} + +{ + const caseRoot = path.join(tempRoot, 'core-and-determinism'); + await fsPromises.mkdir(caseRoot, { recursive: true }); + const runA = path.join(caseRoot, 'run-a.jsonl'); + const runB = path.join(caseRoot, 'run-b.jsonl'); + const runC = path.join(caseRoot, 'run-c.jsonl'); + await writeJsonLinesFile(runA, [{ v: 1 }, { v: 3 }, { v: 5 }], { atomic: true }); + await writeJsonLinesFile(runB, [{ v: 1 }, { v: 2 }, { v: 4 }], { atomic: true }); + await writeJsonLinesFile(runC, [{ v: 0 }, { v: 6 }], { atomic: true }); + + const compare = (a, b) => a.v - b.v; + const merged = []; + for await (const row of mergeSortedRuns([runA, runB], { compare })) { + merged.push(row.v); + } + assert.deepEqual(merged, [1, 1, 2, 3, 4, 5]); + + await assert.rejects( + async () => { + for await (const _row of mergeSortedRuns([runA, runB], { + compare: () => 1, + validateComparator: true + })) { + // force comparator execution + } + }, + /antisymmetric/i + ); + + const plannerOutputPath = path.join(caseRoot, 'planner-merged.jsonl'); + const plannerResult = await mergeRunsWithPlanner({ + runs: [runA, runB, runC], + outputPath: plannerOutputPath, + compare, + tempDir: path.join(caseRoot, 'planner-runs'), + maxOpenRuns: 2, + runPrefix: 'core' + }); + + const outputRows = []; + const outputText = await fsPromises.readFile(plannerOutputPath, 'utf8'); + for (const line of outputText.split('\n')) { + if (!line.trim()) continue; + outputRows.push(JSON.parse(line).v); + } + assert.deepEqual(outputRows, [0, 1, 1, 2, 3, 4, 5, 6]); + await plannerResult.cleanup(); + + const deterministicRuns = [ + path.join(caseRoot, 'det-0.jsonl'), + path.join(caseRoot, 'det-1.jsonl'), + path.join(caseRoot, 'det-2.jsonl') + ]; + await writeJsonlRunFile(deterministicRuns[0], [{ token: 'a', src: 'r0-0' }, { token: 'c', src: 'r0-1' }], { atomic: true }); + await writeJsonlRunFile(deterministicRuns[1], [{ token: 'a', src: 'r1-0' }, { token: 'b', src: 'r1-1' }], { atomic: true }); + await writeJsonlRunFile(deterministicRuns[2], [{ token: 'a', src: 'r2-0' }, { token: 'd', src: 'r2-1' }], { atomic: true }); + + const collect = async () => { + const out = []; + for await (const row of mergeSortedRuns(deterministicRuns, { + compare: (left, right) => String(left.token).localeCompare(String(right.token)) + })) { + out.push(row.src); + } + return out; + }; + const first = await collect(); + const second = await collect(); + assert.deepEqual(first, second); + assert.deepEqual(first, ['r0-0', 'r1-0', 'r2-0', 'r1-1', 'r0-1', 'r2-1']); +} + +{ + const caseRoot = path.join(tempRoot, 'adopters'); + await fsPromises.mkdir(caseRoot, { recursive: true }); + const badCompare = () => 1; + + const collector = createRowSpillCollector({ + outDir: caseRoot, + runPrefix: 'collector', + compare: badCompare, + maxBufferRows: 2, + maxBufferBytes: 0 + }); + await collector.append({ token: 'a', postings: [0] }); + await assert.rejects(() => collector.append({ token: 'b', postings: [1] }), /Comparator is not antisymmetric/); + + const sorter = createSpillSorter({ + label: 'map-sorter', + compare: badCompare, + maxInMemory: 2, + tempDir: caseRoot + }); + await sorter.push({ id: 1 }); + await assert.rejects(() => sorter.push({ id: 2 }), /Comparator is not antisymmetric/); + + const adopterChecks = [ + 'src/index/build/postings/spill.js', + 'src/index/build/artifacts/graph-relations.js', + 'src/index/build/artifacts/writers/chunk-meta/writer.js', + 'src/index/build/artifacts/writers/symbol-edges.js', + 'src/index/build/artifacts/writers/symbol-occurrences.js', + 'src/index/build/artifacts/writers/vfs-manifest.js', + 'src/map/build-map/io.js' + ]; + for (const relPath of adopterChecks) { + const text = await fsPromises.readFile(path.join(root, relPath), 'utf8'); + assert.ok( + text.includes('validateComparator: true') || text.includes('compareWithAntisymmetryInvariant'), + `${relPath} should enforce comparator contract checks` + ); + } +} + +console.log('merge contract matrix test passed'); diff --git a/tests/shared/merge/merge-benchmark-contract.test.js b/tests/shared/merge/merge-benchmark-contract.test.js deleted file mode 100644 index 398d62f96..000000000 --- a/tests/shared/merge/merge-benchmark-contract.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const script = path.join(root, 'tools', 'bench', 'merge', 'merge-core-throughput.js'); -const result = spawnSync(process.execPath, [script, '--runs', '4', '--run-size', '50'], { - cwd: root, - encoding: 'utf8' -}); - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(1); -} - -const output = `${result.stdout || ''}${result.stderr || ''}`; -assert.ok(output.includes('[bench] baseline'), 'missing baseline output'); -assert.ok(output.includes('[bench] current'), 'missing current output'); -assert.ok(output.includes('[bench] delta'), 'missing delta output'); - -console.log('merge benchmark contract test passed'); diff --git a/tests/shared/merge/merge-cleanup-regression.test.js b/tests/shared/merge/merge-cleanup-regression.test.js deleted file mode 100644 index c9679b815..000000000 --- a/tests/shared/merge/merge-cleanup-regression.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { mergeRunsWithPlanner, writeJsonlRunFile } from '../../../src/shared/merge.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'merge-cleanup-regression'); - -const compareRows = (a, b) => { - const left = String(a?.token || ''); - const right = String(b?.token || ''); - if (left < right) return -1; - if (left > right) return 1; - return 0; -}; - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -const runsDir = path.join(tempRoot, 'runs'); -await fsPromises.mkdir(runsDir, { recursive: true }); - -const runPaths = []; -for (let i = 0; i < 3; i += 1) { - const runPath = path.join(runsDir, `run-${i}.jsonl`); - await writeJsonlRunFile(runPath, [{ token: `t${i}`, postings: [i] }], { atomic: true }); - runPaths.push(runPath); -} - -const mergeDir = path.join(tempRoot, 'merge'); -const outputPath = path.join(mergeDir, 'merged.jsonl'); -const checkpointPath = path.join(mergeDir, 'merge.checkpoint.json'); -const result = await mergeRunsWithPlanner({ - runs: runPaths, - outputPath, - compare: compareRows, - tempDir: mergeDir, - runPrefix: 'merge', - checkpointPath, - maxOpenRuns: 2 -}); - -assert.ok(fs.existsSync(outputPath), 'expected merged output'); -const beforeCleanup = fs.existsSync(mergeDir) ? fs.readdirSync(mergeDir) : []; -assert.ok(beforeCleanup.some((name) => name.includes('.run-')), 'expected intermediate runs'); - -await result.cleanup(); -const afterCleanup = fs.existsSync(mergeDir) ? fs.readdirSync(mergeDir) : []; -assert.ok(!afterCleanup.some((name) => name.includes('.run-')), 'expected spill runs cleaned'); -assert.ok(!fs.existsSync(checkpointPath), 'expected checkpoint removed'); - -console.log('merge cleanup regression test passed'); diff --git a/tests/shared/merge/merge-comparator-adopters.test.js b/tests/shared/merge/merge-comparator-adopters.test.js deleted file mode 100644 index f4edf8a1b..000000000 --- a/tests/shared/merge/merge-comparator-adopters.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { createRowSpillCollector } from '../../../src/index/build/artifacts/helpers.js'; -import { createSpillSorter } from '../../../src/map/build-map/io.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'merge-comparator-adopters'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const badCompare = () => 1; - -const collector = createRowSpillCollector({ - outDir: tempRoot, - runPrefix: 'collector', - compare: badCompare, - maxBufferRows: 2, - maxBufferBytes: 0 -}); -await collector.append({ token: 'a', postings: [0] }); -await assert.rejects( - () => collector.append({ token: 'b', postings: [1] }), - /Comparator is not antisymmetric/, - 'row spill collector should fail fast on invalid comparator' -); - -const sorter = createSpillSorter({ - label: 'map-sorter', - compare: badCompare, - maxInMemory: 2, - tempDir: tempRoot -}); -await sorter.push({ id: 1 }); -await assert.rejects( - () => sorter.push({ id: 2 }), - /Comparator is not antisymmetric/, - 'map spill sorter should fail fast on invalid comparator' -); - -const adopterChecks = [ - 'src/index/build/postings/spill.js', - 'src/index/build/artifacts/graph-relations.js', - 'src/index/build/artifacts/writers/chunk-meta/writer.js', - 'src/index/build/artifacts/writers/symbol-edges.js', - 'src/index/build/artifacts/writers/symbol-occurrences.js', - 'src/index/build/artifacts/writers/vfs-manifest.js', - 'src/map/build-map/io.js' -]; -for (const relPath of adopterChecks) { - const fullPath = path.join(root, relPath); - const text = await fs.readFile(fullPath, 'utf8'); - assert.ok( - text.includes('validateComparator: true') || text.includes('compareWithAntisymmetryInvariant'), - `${relPath} should enforce comparator contract checks` - ); -} - -console.log('merge comparator adopters test passed'); diff --git a/tests/shared/merge/merge-contract.test.js b/tests/shared/merge/merge-contract.test.js deleted file mode 100644 index bd661a938..000000000 --- a/tests/shared/merge/merge-contract.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { readJsonlRows, writeJsonlRunFile, mergeSortedRuns } from '../../../src/shared/merge.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'merge-contract'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const runA = path.join(tempRoot, 'run-a.jsonl'); -const runB = path.join(tempRoot, 'run-b.jsonl'); -const invalidRun = path.join(tempRoot, 'invalid.jsonl'); - -await writeJsonlRunFile(runA, [{ key: 'a', rank: 1 }, { key: 'c', rank: 3 }], { atomic: true }); -await writeJsonlRunFile(runB, [{ key: 'b', rank: 2 }, { key: 'd', rank: 4 }], { atomic: true }); -await fs.writeFile(invalidRun, '{"ok":1}\n{"bad":\n', 'utf8'); - -const loaded = []; -for await (const row of readJsonlRows(runA)) { - loaded.push(row); -} -assert.equal(loaded.length, 2, 'readJsonlRows should stream all rows in a run file'); -assert.deepEqual(loaded[0], { key: 'a', rank: 1 }); - -await assert.rejects( - async () => { - for await (const _row of readJsonlRows(invalidRun)) { - // no-op - } - }, - /Invalid JSONL at/ -); - -const merged = []; -for await (const row of mergeSortedRuns([{ path: runA }, { path: runB }], { - compare: (left, right) => left.rank - right.rank -})) { - merged.push(row.rank); -} -assert.deepEqual(merged, [1, 2, 3, 4], 'mergeSortedRuns should accept run objects with path fields'); - -console.log('merge contract test passed'); diff --git a/tests/shared/merge/merge-core.test.js b/tests/shared/merge/merge-core.test.js deleted file mode 100644 index ddacbbfe5..000000000 --- a/tests/shared/merge/merge-core.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; -import { mergeSortedRuns, mergeRunsWithPlanner } from '../../../src/shared/merge.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'merge-core'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const runA = path.join(tempRoot, 'run-a.jsonl'); -const runB = path.join(tempRoot, 'run-b.jsonl'); -const runC = path.join(tempRoot, 'run-c.jsonl'); - -await writeJsonLinesFile(runA, [{ v: 1 }, { v: 3 }, { v: 5 }], { atomic: true }); -await writeJsonLinesFile(runB, [{ v: 1 }, { v: 2 }, { v: 4 }], { atomic: true }); -await writeJsonLinesFile(runC, [{ v: 0 }, { v: 6 }], { atomic: true }); - -const compare = (a, b) => a.v - b.v; - -const merged = []; -for await (const row of mergeSortedRuns([runA, runB], { compare })) { - merged.push(row.v); -} -assert.deepEqual(merged, [1, 1, 2, 3, 4, 5], 'merge should be deterministic and stable'); - -await assert.rejects( - async () => { - const out = []; - const badCompare = () => 1; - for await (const row of mergeSortedRuns([runA, runB], { compare: badCompare, validateComparator: true })) { - out.push(row); - } - }, - /Comparator is not antisymmetric/ -); - -const outputPath = path.join(tempRoot, 'merged.jsonl'); -const result = await mergeRunsWithPlanner({ - runs: [runA, runB, runC], - outputPath, - compare, - tempDir: path.join(tempRoot, 'runs'), - maxOpenRuns: 2, - runPrefix: 'core' -}); - -const outputRows = []; -const outputText = await fs.readFile(outputPath, 'utf8'); -for (const line of outputText.split('\n')) { - if (!line.trim()) continue; - outputRows.push(JSON.parse(line).v); -} -assert.deepEqual(outputRows, [0, 1, 1, 2, 3, 4, 5, 6], 'planner output should merge all runs'); - -await result.cleanup(); -const leftover = await fs.readdir(path.join(tempRoot, 'runs')).catch(() => []); -assert.equal(leftover.length, 0, 'cleanup should remove intermediate runs'); - -console.log('merge core tests passed'); diff --git a/tests/shared/merge/merge-determinism.test.js b/tests/shared/merge/merge-determinism.test.js deleted file mode 100644 index 836f1007e..000000000 --- a/tests/shared/merge/merge-determinism.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { writeJsonlRunFile, mergeSortedRuns } from '../../../src/shared/merge.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'merge-determinism'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const runs = [ - path.join(tempRoot, 'run-0.jsonl'), - path.join(tempRoot, 'run-1.jsonl'), - path.join(tempRoot, 'run-2.jsonl') -]; - -await writeJsonlRunFile(runs[0], [{ token: 'a', src: 'r0-0' }, { token: 'c', src: 'r0-1' }], { atomic: true }); -await writeJsonlRunFile(runs[1], [{ token: 'a', src: 'r1-0' }, { token: 'b', src: 'r1-1' }], { atomic: true }); -await writeJsonlRunFile(runs[2], [{ token: 'a', src: 'r2-0' }, { token: 'd', src: 'r2-1' }], { atomic: true }); - -const collect = async () => { - const out = []; - for await (const row of mergeSortedRuns(runs, { - compare: (left, right) => String(left.token).localeCompare(String(right.token)) - })) { - out.push(row.src); - } - return out; -}; - -const first = await collect(); -const second = await collect(); - -assert.deepEqual(first, second, 'merge order should be deterministic across repeated runs'); -assert.deepEqual( - first, - ['r0-0', 'r1-0', 'r2-0', 'r1-1', 'r0-1', 'r2-1'], - 'ties must preserve run-order stability before moving to later tokens' -); - -console.log('merge determinism test passed'); diff --git a/tests/shared/merge/spill-merge-contract.test.js b/tests/shared/merge/spill-merge-contract.test.js deleted file mode 100644 index dc05c6b62..000000000 --- a/tests/shared/merge/spill-merge-contract.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createMergeRunManifest, - mergeSortedRuns, - mergeSortedRunsToFile, - readJsonlRows, - writeJsonlRunFile, - writeMergeRunManifest -} from '../../../src/shared/merge.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const testRoot = resolveTestCachePath(root, 'spill-merge-contract'); -await fs.rm(testRoot, { recursive: true, force: true }); -await fs.mkdir(testRoot, { recursive: true }); - -const runA = path.join(testRoot, 'run-a.jsonl'); -const runB = path.join(testRoot, 'run-b.jsonl'); -const mergedPath = path.join(testRoot, 'merged.jsonl'); -const manifestPath = path.join(testRoot, 'merged.manifest.json'); - -await writeJsonlRunFile(runA, [{ rank: 1 }, { rank: 3 }], { atomic: true }); -await writeJsonlRunFile(runB, [{ rank: 2 }, { rank: 4 }], { atomic: true }); - -const mergedStats = await mergeSortedRunsToFile({ - runs: [runA, runB], - outputPath: mergedPath, - compare: (left, right) => left.rank - right.rank, - validateComparator: true, - atomic: true -}); -assert.equal(mergedStats.rows, 4, 'expected merged row count'); - -const mergedRanks = []; -for await (const row of readJsonlRows(mergedPath)) { - mergedRanks.push(row.rank); -} -assert.deepEqual(mergedRanks, [1, 2, 3, 4], 'expected deterministic merged ordering'); - -const manifest = createMergeRunManifest({ - runPath: mergedPath, - rows: mergedStats.rows, - bytes: mergedStats.bytes, - compareId: 'rank-asc' -}); -await writeMergeRunManifest(manifestPath, manifest); -const writtenManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); -assert.equal(writtenManifest.compareId, 'rank-asc'); -assert.equal(writtenManifest.rows, 4); - -await assert.rejects( - async () => { - for await (const _row of mergeSortedRuns([runA, runB], { - compare: () => 1, - validateComparator: true - })) { - // force comparator execution - } - }, - /antisymmetric/i, - 'expected comparator invariant failure for invalid comparator' -); - -console.log('spill merge contract test passed'); diff --git a/tests/shared/number-coerce.test.js b/tests/shared/number-coerce.test.js index 9835f7faf..d663c6890 100644 --- a/tests/shared/number-coerce.test.js +++ b/tests/shared/number-coerce.test.js @@ -1,7 +1,9 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { + INTEGER_COERCE_MODE_STRICT, coerceClampedFraction, + coerceFiniteNumber, coerceIntAtLeast, coerceNonNegativeInt, coerceNumberAtLeast, @@ -16,6 +18,15 @@ assert.equal(coercePositiveIntMinOne('0.4'), 1); assert.equal(coercePositiveIntMinOne('1.9'), 1); assert.equal(coerceNonNegativeInt('0.9'), 0); assert.equal(coerceNonNegativeInt('-2'), null); +assert.equal(coercePositiveInt('10.9', { mode: INTEGER_COERCE_MODE_STRICT }), null); +assert.equal(coercePositiveInt('10', { mode: INTEGER_COERCE_MODE_STRICT }), 10); +assert.equal(coercePositiveIntMinOne('1.9', { mode: INTEGER_COERCE_MODE_STRICT }), null); +assert.equal(coerceNonNegativeInt('0.9', { mode: INTEGER_COERCE_MODE_STRICT }), null); +assert.equal(coerceNonNegativeInt('0', { mode: INTEGER_COERCE_MODE_STRICT }), 0); + +assert.equal(coerceFiniteNumber('2.5'), 2.5); +assert.equal(coerceFiniteNumber('bad', 7), 7); +assert.equal(coerceFiniteNumber('bad'), null); assert.equal(coerceNumberAtLeast('2.5', 1), 2.5); assert.equal(coerceNumberAtLeast('-5', 1), 1); @@ -24,6 +35,8 @@ assert.equal(coerceNumberAtLeast('not-a-number', 1), null); assert.equal(coerceIntAtLeast('8.9', 4), 8); assert.equal(coerceIntAtLeast('-3', 4), 4); assert.equal(coerceIntAtLeast('bad', 4), null); +assert.equal(coerceIntAtLeast('8.9', 4, { mode: INTEGER_COERCE_MODE_STRICT }), null); +assert.equal(coerceIntAtLeast('-3', 4, { mode: INTEGER_COERCE_MODE_STRICT }), 4); assert.equal(coerceClampedFraction('1.5', { min: 0, max: 1 }), 1); assert.equal(coerceClampedFraction('-0.2', { min: 0, max: 1 }), null); diff --git a/tests/shared/order/contract-matrix.test.js b/tests/shared/order/contract-matrix.test.js new file mode 100644 index 000000000..4331804a3 --- /dev/null +++ b/tests/shared/order/contract-matrix.test.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { compareChunkMetaRows } from '../../../src/index/build/artifacts/helpers.js'; +import { + createOrderingHasher, + orderRepoMapEntries, + stableBucketOrder, + stableOrder, + stableOrderMapEntries, + stableOrderWithComparator +} from '../../../src/shared/order.js'; + +const hashRows = (rows) => { + const hasher = createOrderingHasher(); + for (const row of rows) { + hasher.update(JSON.stringify(row)); + } + return hasher.digest(); +}; + +const cases = [ + { + name: 'stable ordering is deterministic across permutations and ties', + run() { + const rowsA = [ + { file: 'b.js', line: 2, id: 'b2' }, + { file: 'a.js', line: 1, id: 'a1' }, + { file: 'a.js', line: 3, id: 'a3' } + ]; + const rowsB = [ + { file: 'a.js', line: 3, id: 'a3' }, + { file: 'b.js', line: 2, id: 'b2' }, + { file: 'a.js', line: 1, id: 'a1' } + ]; + const selectors = [(row) => row.file, (row) => row.line]; + const orderedA = stableOrder(rowsA, selectors).map((row) => row.id); + const orderedB = stableOrder(rowsB, selectors).map((row) => row.id); + assert.deepEqual(orderedA, ['a1', 'a3', 'b2']); + assert.deepEqual(orderedB, orderedA); + + const tieRows = [ + { key: 'same', value: 1 }, + { key: 'same', value: 2 }, + { key: 'same', value: 3 } + ]; + const tieOrdered = stableOrderWithComparator(tieRows, (left, right) => left.key.localeCompare(right.key)); + assert.deepEqual(tieOrdered.map((row) => row.value), [1, 2, 3]); + } + }, + { + name: 'bucket ordering and repo map ordering stay stable', + run() { + const items = [ + { id: 1, key: 'b', bucket: 'z', value: 2 }, + { id: 2, key: 'a', bucket: 'y', value: 2 }, + { id: 3, key: 'a', bucket: 'y', value: 1 }, + { id: 4, key: 'a', bucket: 'z', value: 2 } + ]; + assert.deepEqual(stableOrder(items, ['key', 'value']).map((item) => item.id), [3, 2, 4, 1]); + assert.deepEqual( + stableOrderWithComparator(items, (left, right) => { + if (left.key !== right.key) return left.key.localeCompare(right.key); + return left.value - right.value; + }).map((item) => item.id), + [3, 2, 4, 1] + ); + assert.deepEqual(stableBucketOrder(items, 'bucket', ['key', 'value']).map((item) => item.id), [3, 2, 4, 1]); + + const map = new Map([ + ['b', 1], + ['a', 2], + ['c', 3] + ]); + assert.deepEqual(stableOrderMapEntries(map).map((entry) => entry.key), ['a', 'b', 'c']); + + const repoEntries = orderRepoMapEntries([ + { file: 'b.js', name: 'z', kind: 'Function', signature: 'b', startLine: 2, endLine: 4 }, + { file: 'a.js', name: 'b', kind: 'Function', signature: 'a', startLine: 1, endLine: 2 }, + { file: 'a.js', name: 'a', kind: 'Function', signature: 'a', startLine: 1, endLine: 2 } + ]); + assert.deepEqual(repoEntries.map((entry) => entry.name), ['a', 'b', 'z']); + } + }, + { + name: 'ordering hashes are deterministic for sorted artifact rows', + run() { + const rowsA = [ + { file: 'b.js', chunkUid: 'ck:b', chunkId: 'b-1', id: 1, start: 20, name: 'Beta' }, + { file: 'a.js', chunkUid: 'ck:a', chunkId: 'a-1', id: 0, start: 10, name: 'Alpha' }, + { file: 'a.js', chunkUid: 'ck:a2', chunkId: 'a-2', id: 2, start: 40, name: 'Gamma' } + ]; + const rowsB = [rowsA[2], rowsA[0], rowsA[1]]; + + const orderedA = stableOrderWithComparator(rowsA, compareChunkMetaRows); + const orderedB = stableOrderWithComparator(rowsB, compareChunkMetaRows); + + const hashA = hashRows(orderedA); + const hashB = hashRows(orderedB); + assert.equal(hashA.hash, hashB.hash); + assert.equal(hashA.count, orderedA.length); + assert.notEqual(hashA.hash, hashRows(rowsB).hash); + } + } +]; + +for (const entry of cases) { + entry.run(); +} + +console.log('shared order contract matrix test passed'); diff --git a/tests/shared/order/deterministic-ordering-contract.test.js b/tests/shared/order/deterministic-ordering-contract.test.js deleted file mode 100644 index 0e96fd4d2..000000000 --- a/tests/shared/order/deterministic-ordering-contract.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - createOrderingHasher, - stableOrder, - stableOrderWithComparator -} from '../../../src/shared/order.js'; - -const rowsA = [ - { file: 'b.js', line: 2, id: 'b2' }, - { file: 'a.js', line: 1, id: 'a1' }, - { file: 'a.js', line: 3, id: 'a3' } -]; -const rowsB = [ - { file: 'a.js', line: 3, id: 'a3' }, - { file: 'b.js', line: 2, id: 'b2' }, - { file: 'a.js', line: 1, id: 'a1' } -]; - -const selectors = [(row) => row.file, (row) => row.line]; -const orderedA = stableOrder(rowsA, selectors).map((row) => row.id); -const orderedB = stableOrder(rowsB, selectors).map((row) => row.id); -assert.deepEqual(orderedA, ['a1', 'a3', 'b2']); -assert.deepEqual(orderedB, orderedA, 'expected stableOrder determinism across input permutations'); - -const tieRows = [ - { key: 'same', value: 1 }, - { key: 'same', value: 2 }, - { key: 'same', value: 3 } -]; -const tieOrdered = stableOrderWithComparator(tieRows, (left, right) => left.key.localeCompare(right.key)); -assert.deepEqual( - tieOrdered.map((row) => row.value), - [1, 2, 3], - 'expected stable comparator tie-break by original index' -); - -const hasherA = createOrderingHasher(); -hasherA.update('row:1'); -hasherA.update('row:2'); -const digestA = hasherA.digest(); - -const hasherB = createOrderingHasher(); -hasherB.update('row:1'); -hasherB.update('row:2'); -const digestB = hasherB.digest(); - -assert.equal(digestA.hash, digestB.hash, 'expected deterministic hash output'); -assert.equal(digestA.count, 2); - -console.log('deterministic ordering contract test passed'); diff --git a/tests/shared/order/order-hash.test.js b/tests/shared/order/order-hash.test.js deleted file mode 100644 index 62e0497e9..000000000 --- a/tests/shared/order/order-hash.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createOrderingHasher, stableOrderWithComparator } from '../../../src/shared/order.js'; -import { compareChunkMetaRows } from '../../../src/index/build/artifacts/helpers.js'; - -const hashRows = (rows) => { - const hasher = createOrderingHasher(); - for (const row of rows) { - hasher.update(JSON.stringify(row)); - } - return hasher.digest(); -}; - -const rowsA = [ - { file: 'b.js', chunkUid: 'ck:b', chunkId: 'b-1', id: 1, start: 20, name: 'Beta' }, - { file: 'a.js', chunkUid: 'ck:a', chunkId: 'a-1', id: 0, start: 10, name: 'Alpha' }, - { file: 'a.js', chunkUid: 'ck:a2', chunkId: 'a-2', id: 2, start: 40, name: 'Gamma' } -]; -const rowsB = [rowsA[2], rowsA[0], rowsA[1]]; - -const orderedA = stableOrderWithComparator(rowsA, compareChunkMetaRows); -const orderedB = stableOrderWithComparator(rowsB, compareChunkMetaRows); - -const hashA = hashRows(orderedA); -const hashB = hashRows(orderedB); - -assert.equal(hashA.hash, hashB.hash, 'ordering hash should be stable across input ordering'); -assert.equal(hashA.count, orderedA.length, 'ordering hash count should match row count'); - -const hashUnordered = hashRows(rowsB); -assert.notEqual(hashA.hash, hashUnordered.hash, 'ordering hash should change when ordering changes'); - -console.log('ordering hash tests passed'); diff --git a/tests/shared/order/order-stability.test.js b/tests/shared/order/order-stability.test.js deleted file mode 100644 index 6a98ebf44..000000000 --- a/tests/shared/order/order-stability.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - stableOrder, - stableOrderWithComparator, - stableBucketOrder, - stableOrderMapEntries, - orderRepoMapEntries -} from '../../../src/shared/order.js'; - -const items = [ - { id: 1, key: 'b', bucket: 'z', value: 2 }, - { id: 2, key: 'a', bucket: 'y', value: 2 }, - { id: 3, key: 'a', bucket: 'y', value: 1 }, - { id: 4, key: 'a', bucket: 'z', value: 2 } -]; - -const ordered = stableOrder(items, ['key', 'value']); -assert.deepEqual(ordered.map((item) => item.id), [3, 2, 4, 1]); - -const comparatorOrdered = stableOrderWithComparator(items, (left, right) => { - if (left.key !== right.key) return left.key.localeCompare(right.key); - return left.value - right.value; -}); -assert.deepEqual(comparatorOrdered.map((item) => item.id), [3, 2, 4, 1]); - -const bucketed = stableBucketOrder(items, 'bucket', ['key', 'value']); -assert.deepEqual(bucketed.map((item) => item.id), [3, 2, 4, 1]); - -const map = new Map([ - ['b', 1], - ['a', 2], - ['c', 3] -]); -const mapEntries = stableOrderMapEntries(map); -assert.deepEqual(mapEntries.map((entry) => entry.key), ['a', 'b', 'c']); - -const repoEntries = orderRepoMapEntries([ - { file: 'b.js', name: 'z', kind: 'Function', signature: 'b', startLine: 2, endLine: 4 }, - { file: 'a.js', name: 'b', kind: 'Function', signature: 'a', startLine: 1, endLine: 2 }, - { file: 'a.js', name: 'a', kind: 'Function', signature: 'a', startLine: 1, endLine: 2 } -]); -assert.deepEqual(repoEntries.map((entry) => entry.name), ['a', 'b', 'z']); - -console.log('ordering helpers tests passed'); diff --git a/tests/shared/path-handling.test.js b/tests/shared/path-handling.test.js index d9e741a5a..231b80eed 100644 --- a/tests/shared/path-handling.test.js +++ b/tests/shared/path-handling.test.js @@ -1,12 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import path from 'node:path'; -import { - toPosix, - fromPosix, - isAbsolutePathAny, - isAbsolutePathNative -} from '../../src/shared/files.js'; +import { toPosix, fromPosix, isAbsolutePathAny, isAbsolutePathNative } from '../../src/shared/file-paths.js'; import { normalizeRepoRelativePath, normalizePathForRepo diff --git a/tests/shared/path-normalize/path-containment-contract.test.js b/tests/shared/path-normalize/path-containment-contract.test.js index 6f9af05b9..632fe9fb6 100644 --- a/tests/shared/path-normalize/path-containment-contract.test.js +++ b/tests/shared/path-normalize/path-containment-contract.test.js @@ -1,8 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import path from 'node:path'; +import { isRootPath } from '../../../src/shared/file-paths.js'; import { isPathUnderDir } from '../../../src/shared/path-normalize.js'; -import { isInside } from '../../../tools/shared/path-utils.js'; const repoRoot = path.resolve('repo-root'); const nestedPath = path.join(repoRoot, 'src', 'main.js'); @@ -18,7 +18,8 @@ assert.equal(isPathUnderDir(repoRoot, outsidePath), false); assert.equal(isPathUnderDir('', nestedPath), false); assert.equal(isPathUnderDir(repoRoot, ''), false); -assert.equal(isInside(repoRoot, nestedPath), true); -assert.equal(isInside(repoRoot, outsidePath), false); +assert.equal(isPathUnderDir(repoRoot, nestedPath), true); +assert.equal(isPathUnderDir(repoRoot, outsidePath), false); +assert.equal(isRootPath(path.parse(repoRoot).root), true); console.log('path containment contract ok.'); diff --git a/tests/shared/perf/percentiles-and-histogram.test.js b/tests/shared/perf/percentiles-and-histogram.test.js new file mode 100644 index 000000000..30f4ed0c0 --- /dev/null +++ b/tests/shared/perf/percentiles-and-histogram.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + normalizeNonNegativeSamples, + resolveInterpolatedPercentile, + resolveNearestRankPercentile +} from '../../../src/shared/perf/percentiles.js'; +import { summarizeBoundedHistogram } from '../../../src/shared/perf/histogram.js'; + +const normalized = normalizeNonNegativeSamples([5.4, -1, '7', Number.NaN, 2.2], { + round: true, + sort: true +}); +assert.deepEqual(normalized, [2, 5, 7], 'expected normalization to filter invalid values and round survivors'); + +assert.equal( + resolveNearestRankPercentile([10, 20, 30], 0.95), + 30, + 'expected nearest-rank percentile to preserve the tail sample' +); +assert.equal( + resolveNearestRankPercentile([10, 20, 30], -1), + 10, + 'expected nearest-rank percentile to clamp to the minimum sample' +); +assert.equal( + resolveInterpolatedPercentile([1, 2, 3, 4], 0.95, { precision: 2 }), + 3.85, + 'expected interpolated percentile to preserve fractional tail placement' +); + +const histogram = summarizeBoundedHistogram([1, 2, 7, 12, 20], { + buckets: [5, 10, 15], + unit: 'ms', + round: true, + percentiles: [ + { ratio: 0.5, key: 'p50Ms' }, + { ratio: 0.95, key: 'p95Ms' } + ] +}); +assert.deepEqual( + histogram, + { + unit: 'ms', + sampleCount: 5, + min: 1, + max: 20, + buckets: [ + { le: 5, count: 2 }, + { le: 10, count: 1 }, + { le: 15, count: 1 } + ], + overflowCount: 1, + p50Ms: 7, + p95Ms: 20 + }, + 'expected bounded histogram summary to preserve stable buckets and percentiles' +); + +console.log('shared percentiles and histogram test passed'); diff --git a/tests/shared/process-signals/cleanup-signal-handlers.test.js b/tests/shared/process-signals/cleanup-signal-handlers.test.js new file mode 100644 index 000000000..c83a6b5b8 --- /dev/null +++ b/tests/shared/process-signals/cleanup-signal-handlers.test.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { attachCleanupSignalHandlers } from '../../../src/shared/process-signals.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +applyTestEnv(); + +const beforeSigterm = process.listenerCount('SIGTERM'); + +let cleanupSignals = []; +let reemitted = []; +const detach = attachCleanupSignalHandlers({ + signals: ['SIGTERM'], + cleanup: (signal) => cleanupSignals.push(signal), + reemitSignal: (signal) => reemitted.push(signal) +}); + +process.emit('SIGTERM', 'SIGTERM'); + +assert.deepEqual(cleanupSignals, ['SIGTERM'], 'expected cleanup to run for SIGTERM'); +assert.deepEqual(reemitted, ['SIGTERM'], 'expected default termination preservation to re-emit SIGTERM'); +assert.equal(process.listenerCount('SIGTERM'), beforeSigterm, 'expected SIGTERM listeners restored after signal handling'); + +detach(); + +const externalListener = () => {}; +process.once('SIGTERM', externalListener); +cleanupSignals = []; +reemitted = []; + +const detachWithExternal = attachCleanupSignalHandlers({ + signals: ['SIGTERM'], + cleanup: (signal) => cleanupSignals.push(signal), + reemitSignal: (signal) => reemitted.push(signal) +}); + +process.emit('SIGTERM', 'SIGTERM'); + +assert.deepEqual(cleanupSignals, ['SIGTERM'], 'expected cleanup to run with existing external listener'); +assert.deepEqual(reemitted, [], 'expected no re-emit when external listeners already own SIGTERM'); +assert.equal(process.listenerCount('SIGTERM'), beforeSigterm, 'expected SIGTERM listeners restored after external-listener run'); + +detachWithExternal(); + +console.log('cleanup signal handler preservation test passed'); diff --git a/tests/shared/progress-events.test.js b/tests/shared/progress-events.test.js new file mode 100644 index 000000000..a503a02fd --- /dev/null +++ b/tests/shared/progress-events.test.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createProgressReporter, createStreamLineProgressForwarder } from '../../src/shared/progress-events.js'; + +const events = []; +const reporter = createProgressReporter({ + progress(event) { + events.push(event); + } +}); + +assert.ok(reporter, 'expected reporter to be created'); +reporter.start('Downloading dictionaries.', { observability: { operation: 'download' } }); +reporter.emit('Still working.', { phase: 'progress' }); +reporter.done('Dictionary download complete.', { observability: { operation: 'download' } }); + +const forwardLine = createStreamLineProgressForwarder({ + progress(event) { + events.push(event); + } +}); +assert.ok(forwardLine, 'expected line forwarder to be created'); +forwardLine({ stream: 'stderr', line: 'fetching shard 1/4' }); + +assert.deepEqual(events, [ + { phase: 'start', message: 'Downloading dictionaries.', observability: { operation: 'download' } }, + { message: 'Still working.', phase: 'progress' }, + { phase: 'done', message: 'Dictionary download complete.', observability: { operation: 'download' } }, + { message: 'fetching shard 1/4', stream: 'stderr' } +]); + +assert.equal(createProgressReporter({}), null); +assert.equal(createStreamLineProgressForwarder({}), null); + +console.log('progress events test passed'); diff --git a/tests/shared/progress/contract-matrix.test.js b/tests/shared/progress/contract-matrix.test.js new file mode 100644 index 000000000..66c0e2b39 --- /dev/null +++ b/tests/shared/progress/contract-matrix.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createDisplay } from '../../../src/shared/cli/display.js'; +import { configureLogger, getRecentLogEvents, log, showProgress } from '../../../src/shared/progress-runtime.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'progress-contract-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +{ + configureLogger({ enabled: false }); + const meta = { name: 'circular' }; + meta.self = meta; + log('circular meta test', meta); + const events = getRecentLogEvents(); + const last = events[events.length - 1]; + assert.ok(last); + assert.notStrictEqual(last.meta, meta); + assert.equal(typeof last.meta, 'string'); + assert.ok(last.meta.includes('[Circular]')); +} + +{ + const writes = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalIsTTY = process.stderr.isTTY; + process.stderr.write = (chunk) => { + writes.push(String(chunk)); + return true; + }; + try { + try { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + } catch {} + showProgress('Test', 0, 0); + } finally { + process.stderr.write = originalWrite; + try { + Object.defineProperty(process.stderr, 'isTTY', { value: originalIsTTY, configurable: true }); + } catch {} + } + const output = writes.join(''); + assert.ok(!output.includes('NaN')); + assert.ok(!output.includes('Infinity')); +} + +{ + const writes = []; + const stream = { + isTTY: false, + write: (chunk) => { + writes.push(String(chunk)); + return true; + } + }; + const display = createDisplay({ + stream, + isTTY: false, + progressMode: 'tty', + json: false + }); + assert.equal(display.progressMode, 'log'); + assert.equal(display.interactive, false); + display.showProgress('Test', 1, 2); + assert.ok(writes.join('').includes('Test')); + display.close(); +} + +{ + const logPath = path.join(tempRoot, 'pretty.log'); + configureLogger({ + enabled: true, + pretty: true, + level: 'info', + destination: logPath, + redact: { paths: ['secret'], censor: '[redacted]' } + }); + log('progress logger test', { secret: 'super-secret', ok: true }); + + let output = ''; + for (let i = 0; i < 10; i += 1) { + try { + output = await fs.readFile(logPath, 'utf8'); + } catch { + output = ''; + } + if (output) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + assert.ok(output.includes('progress logger test')); + assert.ok(!output.includes('super-secret')); + assert.ok(output.includes('[redacted]')); + configureLogger({ enabled: false }); +} + +console.log('progress contract matrix test passed'); diff --git a/tests/shared/progress/embeddings-line-contract.test.js b/tests/shared/progress/embeddings-line-contract.test.js new file mode 100644 index 000000000..e18c1662e --- /dev/null +++ b/tests/shared/progress/embeddings-line-contract.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + EMBEDDINGS_PERF_METRIC_KEYS, + formatEmbeddingsPerfLine, + parseEmbeddingsPerfLine +} from '../../../tools/build/embeddings/perf-progress.js'; + +const metrics = { + files_total: 12, + files_done: 5, + chunks_total: 120, + chunks_done: 48, + cache_attempts: 5, + cache_hits: 3, + cache_misses: 2, + cache_rejected: 1, + cache_fast_rejects: 1, + cache_hit_files: 2, + computed_files: 3, + skipped_files: 0, + texts_scheduled: 90, + texts_resolved: 72, + texts_embedded: 60, + batches_completed: 8, + tokens_processed: 2048, + embed_compute_ms: 321, + elapsed_ms: 1500, + files_per_sec: 3.33333, + chunks_per_sec: 32.125, + embed_resolved_per_sec: 48.25, + writer_pending: 1, + writer_max_pending: 4, + queue_compute_pending: 2, + queue_io_pending: 1 +}; + +const line = formatEmbeddingsPerfLine({ + mode: 'code', + kind: 'perf_progress', + metrics +}); + +assert.match(line, /^\[embeddings\] code: perf_progress /, 'expected perf_progress prefix'); +for (const key of EMBEDDINGS_PERF_METRIC_KEYS) { + assert.match(line, new RegExp(`${key}=`), `expected key ${key} to be present`); +} +assert.match(line, /files_per_sec=3.333/, 'expected rate metrics to use fixed precision'); +assert.match(line, /chunks_per_sec=32.125/, 'expected rate metrics to preserve fixed precision'); + +const parsed = parseEmbeddingsPerfLine(line); +assert.ok(parsed, 'expected line to parse'); +assert.equal(parsed.mode, 'code'); +assert.equal(parsed.kind, 'perf_progress'); +assert.equal(parsed.metrics.files_done, 5); +assert.equal(parsed.metrics.files_total, 12); +assert.equal(parsed.metrics.elapsed_ms, 1500); +assert.equal(parsed.metrics.files_per_sec, 3.333); +assert.equal(parsed.metrics.chunks_per_sec, 32.125); +assert.equal(parsed.metrics.embed_resolved_per_sec, 48.25); +assert.equal(parseEmbeddingsPerfLine('not-a-perf-line'), null, 'expected non-perf line to be ignored'); + +console.log('embeddings progress line contract test passed'); diff --git a/tests/shared/progress/embeddings-progress-line-contract.test.js b/tests/shared/progress/embeddings-progress-line-contract.test.js deleted file mode 100644 index e1f7dab1a..000000000 --- a/tests/shared/progress/embeddings-progress-line-contract.test.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - EMBEDDINGS_PERF_METRIC_KEYS, - formatEmbeddingsPerfLine, - parseEmbeddingsPerfLine -} from '../../../src/shared/embeddings-progress.js'; - -const metrics = { - files_total: 12, - files_done: 5, - chunks_total: 120, - chunks_done: 48, - cache_attempts: 5, - cache_hits: 3, - cache_misses: 2, - cache_rejected: 1, - cache_fast_rejects: 1, - cache_hit_files: 2, - computed_files: 3, - skipped_files: 0, - texts_scheduled: 90, - texts_resolved: 72, - texts_embedded: 60, - batches_completed: 8, - tokens_processed: 2048, - embed_compute_ms: 321, - elapsed_ms: 1500, - files_per_sec: 3.33333, - chunks_per_sec: 32.125, - embed_resolved_per_sec: 48.25, - writer_pending: 1, - writer_max_pending: 4, - queue_compute_pending: 2, - queue_io_pending: 1 -}; - -const line = formatEmbeddingsPerfLine({ - mode: 'code', - kind: 'perf_progress', - metrics -}); - -assert.match(line, /^\[embeddings\] code: perf_progress /, 'expected perf_progress prefix'); -for (const key of EMBEDDINGS_PERF_METRIC_KEYS) { - assert.match(line, new RegExp(`${key}=`), `expected key ${key} to be present`); -} -assert.match(line, /files_per_sec=3.333/, 'expected rate metrics to use fixed precision'); -assert.match(line, /chunks_per_sec=32.125/, 'expected rate metrics to preserve fixed precision'); - -const parsed = parseEmbeddingsPerfLine(line); -assert.ok(parsed, 'expected line to parse'); -assert.equal(parsed.mode, 'code'); -assert.equal(parsed.kind, 'perf_progress'); -assert.equal(parsed.metrics.files_done, 5); -assert.equal(parsed.metrics.files_total, 12); -assert.equal(parsed.metrics.elapsed_ms, 1500); -assert.equal(parsed.metrics.files_per_sec, 3.333); -assert.equal(parsed.metrics.chunks_per_sec, 32.125); -assert.equal(parsed.metrics.embed_resolved_per_sec, 48.25); -assert.equal(parseEmbeddingsPerfLine('not-a-perf-line'), null, 'expected non-perf line to be ignored'); - -console.log('embeddings progress line contract test passed'); diff --git a/tests/shared/progress/progress-configure-logger-pino10-transport.test.js b/tests/shared/progress/progress-configure-logger-pino10-transport.test.js deleted file mode 100644 index a6ace59ad..000000000 --- a/tests/shared/progress/progress-configure-logger-pino10-transport.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { configureLogger, log } from '../../../src/shared/progress.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'progress-logger'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const logPath = path.join(tempRoot, 'pretty.log'); -configureLogger({ - enabled: true, - pretty: true, - level: 'info', - destination: logPath, - redact: { paths: ['secret'], censor: '[redacted]' } -}); - -log('progress logger test', { secret: 'super-secret', ok: true }); - -let output = ''; -for (let i = 0; i < 10; i += 1) { - try { - output = await fs.readFile(logPath, 'utf8'); - } catch { - output = ''; - } - if (output) break; - await new Promise((resolve) => setTimeout(resolve, 50)); -} - -assert.ok(output.includes('progress logger test'), 'expected log output to be written'); -assert.ok(!output.includes('super-secret'), 'expected secret to be redacted'); -assert.ok(output.includes('[redacted]'), 'expected redacted marker'); - -configureLogger({ enabled: false }); - -console.log('progress configure logger pino10 transport test passed'); - diff --git a/tests/shared/progress/progress-ring-buffer-circular-meta.test.js b/tests/shared/progress/progress-ring-buffer-circular-meta.test.js deleted file mode 100644 index 27a76255e..000000000 --- a/tests/shared/progress/progress-ring-buffer-circular-meta.test.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { configureLogger, getRecentLogEvents, log } from '../../../src/shared/progress.js'; - -configureLogger({ enabled: false }); - -const meta = { name: 'circular' }; -meta.self = meta; - -log('circular meta test', meta); - -const events = getRecentLogEvents(); -const last = events[events.length - 1]; -assert.ok(last, 'expected a recent log event'); -assert.notStrictEqual(last.meta, meta, 'meta should not be stored by reference'); -assert.equal(typeof last.meta, 'string', 'expected meta snapshot to be a string'); -assert.ok(last.meta.includes('[Circular]'), 'expected circular marker in snapshot'); - -console.log('progress ring buffer circular meta test passed'); diff --git a/tests/shared/progress/progress-show-total-zero.test.js b/tests/shared/progress/progress-show-total-zero.test.js deleted file mode 100644 index 073a32375..000000000 --- a/tests/shared/progress/progress-show-total-zero.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { configureLogger, showProgress } from '../../../src/shared/progress.js'; - -configureLogger({ enabled: false }); - -const writes = []; -const originalWrite = process.stderr.write.bind(process.stderr); -const originalIsTTY = process.stderr.isTTY; - -process.stderr.write = (chunk) => { - writes.push(String(chunk)); - return true; -}; - -try { - try { - Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); - } catch {} - showProgress('Test', 0, 0); -} finally { - process.stderr.write = originalWrite; - try { - Object.defineProperty(process.stderr, 'isTTY', { value: originalIsTTY, configurable: true }); - } catch {} -} - -const output = writes.join(''); -assert.ok(!output.includes('NaN'), `unexpected NaN in output: ${output}`); -assert.ok(!output.includes('Infinity'), `unexpected Infinity in output: ${output}`); - -console.log('progress show total zero test passed'); diff --git a/tests/shared/progress/total-zero-safe.test.js b/tests/shared/progress/total-zero-safe.test.js deleted file mode 100644 index 88bdf9dea..000000000 --- a/tests/shared/progress/total-zero-safe.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { configureLogger, showProgress } from '../../../src/shared/progress.js'; - -configureLogger({ enabled: false }); - -const writes = []; -const originalWrite = process.stderr.write.bind(process.stderr); -const originalIsTTY = process.stderr.isTTY; - -process.stderr.write = (chunk) => { - writes.push(String(chunk)); - return true; -}; - -try { - try { - Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); - } catch {} - showProgress('Test', 0, 0); -} finally { - process.stderr.write = originalWrite; - try { - Object.defineProperty(process.stderr, 'isTTY', { value: originalIsTTY, configurable: true }); - } catch {} -} - -const output = writes.join(''); -assert.ok(!output.includes('NaN'), `unexpected NaN in output: ${output}`); -assert.ok(!output.includes('Infinity'), `unexpected Infinity in output: ${output}`); - -console.log('progress total zero safe test passed'); diff --git a/tests/shared/progress/tty-normalization.test.js b/tests/shared/progress/tty-normalization.test.js deleted file mode 100644 index 2ff5925cc..000000000 --- a/tests/shared/progress/tty-normalization.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createDisplay } from '../../../src/shared/cli/display.js'; - -const writes = []; -const stream = { - isTTY: false, - write: (chunk) => { - writes.push(String(chunk)); - return true; - } -}; - -const display = createDisplay({ - stream, - isTTY: false, - progressMode: 'tty', - json: false -}); - -assert.equal(display.progressMode, 'log'); -assert.equal(display.interactive, false); - -display.showProgress('Test', 1, 2); -assert.ok(writes.join('').includes('Test'), 'expected progress output in log mode'); - -display.close(); - -console.log('progress tty normalization test passed'); diff --git a/tests/shared/provenance/provenance.test.js b/tests/shared/provenance/contract.test.js similarity index 100% rename from tests/shared/provenance/provenance.test.js rename to tests/shared/provenance/contract.test.js diff --git a/tests/shared/repo-paths-parity.test.js b/tests/shared/repo-paths-parity.test.js new file mode 100644 index 000000000..175786f2d --- /dev/null +++ b/tests/shared/repo-paths-parity.test.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + getCurrentBuildInfo as getCurrentBuildInfoShared, + getIndexDir as getIndexDirShared, + getRepoCacheRoot as getRepoCacheRootShared, + getRepoId as getRepoIdShared, + getRepoRoot as getRepoRootShared, + resolveCurrentBuildModeRoot as resolveCurrentBuildModeRootShared, + resolveIndexRoot as resolveIndexRootShared, + resolveRepoRoot as resolveRepoRootShared +} from '../../src/shared/repo-paths.js'; +import { + getCurrentBuildInfo, + getIndexDir, + getRepoCacheRoot, + getRepoId, + getRepoRoot, + loadUserConfig, + resolveCurrentBuildModeRoot, + resolveIndexRoot, + resolveRepoRoot +} from '../../tools/shared/dict-utils.js'; + +const normalizePath = (value) => { + if (!value) return value; + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +}; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-repo-paths-')); +const repoRoot = path.join(tempRoot, 'repo'); +const nestedRoot = path.join(repoRoot, 'src', 'nested'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fs.mkdir(nestedRoot, { recursive: true }); +await fs.writeFile( + path.join(repoRoot, '.pairofcleats.json'), + JSON.stringify({ cache: { root: cacheRoot } }, null, 2), + 'utf8' +); + +const userConfig = loadUserConfig(repoRoot); +const injectedOptions = { + loadUserConfig: () => userConfig, + getCacheRoot: () => cacheRoot +}; + +assert.equal( + normalizePath(resolveRepoRootShared(nestedRoot)), + normalizePath(resolveRepoRoot(nestedRoot)), + 'shared resolveRepoRoot should match tooling wrapper' +); +assert.equal( + normalizePath(getRepoRootShared(null, nestedRoot)), + normalizePath(getRepoRoot(null, nestedRoot)), + 'shared getRepoRoot should match tooling wrapper for implicit root resolution' +); +assert.equal( + normalizePath(getRepoRootShared(repoRoot)), + normalizePath(getRepoRoot(repoRoot)), + 'shared getRepoRoot should preserve explicit repo roots' +); +assert.equal( + getRepoIdShared(repoRoot), + getRepoId(repoRoot), + 'shared getRepoId should match tooling wrapper' +); + +const repoCacheRootShared = getRepoCacheRootShared(repoRoot, userConfig); +const repoCacheRootWrapped = getRepoCacheRoot(repoRoot, userConfig); +const repoCacheRootInjected = getRepoCacheRootShared(repoRoot, null, injectedOptions); + +assert.equal( + normalizePath(repoCacheRootShared), + normalizePath(repoCacheRootWrapped), + 'shared getRepoCacheRoot should match tooling wrapper' +); +assert.equal( + normalizePath(repoCacheRootInjected), + normalizePath(repoCacheRootWrapped), + 'shared getRepoCacheRoot should support injected config/cache defaults' +); + +const buildsRoot = path.join(repoCacheRootShared, 'builds'); +const buildId = '20260326T000000Z_repo_paths'; +const buildRoot = path.join(buildsRoot, buildId); +await fs.mkdir(path.join(buildRoot, 'index-code'), { recursive: true }); +await fs.writeFile(path.join(buildRoot, 'index-code', 'chunk_meta.jsonl.gz'), '', 'utf8'); +await fs.writeFile( + path.join(buildsRoot, 'current.json'), + JSON.stringify({ + buildId, + buildRoot, + buildRoots: { + code: buildRoot + } + }, null, 2), + 'utf8' +); + +const currentShared = getCurrentBuildInfoShared(repoRoot, userConfig); +const currentWrapped = getCurrentBuildInfo(repoRoot, userConfig); +assert.ok(currentShared, 'shared current build info should resolve'); +assert.ok(currentWrapped, 'tooling current build info should resolve'); +assert.deepEqual( + { + buildId: currentShared.buildId, + buildRoot: normalizePath(currentShared.buildRoot), + activeRoot: normalizePath(currentShared.activeRoot), + codeRoot: normalizePath(currentShared.buildRoots?.code) + }, + { + buildId: currentWrapped.buildId, + buildRoot: normalizePath(currentWrapped.buildRoot), + activeRoot: normalizePath(currentWrapped.activeRoot), + codeRoot: normalizePath(currentWrapped.buildRoots?.code) + }, + 'shared current build info should match tooling wrapper' +); + +const modeResolutionShared = resolveCurrentBuildModeRootShared(repoRoot, userConfig, { mode: 'code' }); +const modeResolutionWrapped = resolveCurrentBuildModeRoot(repoRoot, userConfig, { mode: 'code' }); +assert.deepEqual( + { + ok: modeResolutionShared.ok, + root: normalizePath(modeResolutionShared.root), + source: modeResolutionShared.source, + errorCode: modeResolutionShared.errorCode + }, + { + ok: modeResolutionWrapped.ok, + root: normalizePath(modeResolutionWrapped.root), + source: modeResolutionWrapped.source, + errorCode: modeResolutionWrapped.errorCode + }, + 'shared mode root resolution should match tooling wrapper' +); + +assert.equal( + normalizePath(resolveIndexRootShared(repoRoot, userConfig, { mode: 'code' })), + normalizePath(resolveIndexRoot(repoRoot, userConfig, { mode: 'code' })), + 'shared resolveIndexRoot should match tooling wrapper' +); +assert.equal( + normalizePath(getIndexDirShared(repoRoot, 'code', userConfig)), + normalizePath(getIndexDir(repoRoot, 'code', userConfig)), + 'shared getIndexDir should match tooling wrapper' +); + +console.log('shared repo-path parity test passed'); diff --git a/tests/shared/risk-filter-input.test.js b/tests/shared/risk-filter-input.test.js new file mode 100644 index 000000000..fed02f608 --- /dev/null +++ b/tests/shared/risk-filter-input.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildRiskFilterInput, normalizeRiskFilters } from '../../src/shared/risk-filters.js'; + +const canonical = buildRiskFilterInput({ + rule: 'sink.sql.query', + category: 'sql-injection', + severity: 'high', + tag: 'sql', + source: 'req body', + sink: 'db.query', + flowId: 'sha1:abc', + sourceRule: 'source.req.body', + sinkRule: 'sink.sql.query' +}); + +assert.deepEqual(canonical, { + rule: 'sink.sql.query', + category: 'sql-injection', + severity: 'high', + tag: 'sql', + source: 'req body', + sink: 'db.query', + flowId: 'sha1:abc', + sourceRule: 'source.req.body', + sinkRule: 'sink.sql.query' +}); + +const aliased = buildRiskFilterInput({ + rule: 'sink.sql.query', + category: 'sql-injection', + severity: 'high', + tag: 'sql', + source: 'req body', + sink: 'db.query', + 'flow-id': 'sha1:abc', + 'source-rule': 'source.req.body', + 'sink-rule': 'sink.sql.query' +}); + +assert.deepEqual(aliased, canonical); +assert.deepEqual(normalizeRiskFilters(aliased), { + rule: ['sink.sql.query'], + category: ['sql-injection'], + severity: ['high'], + tag: ['sql'], + source: ['req body'], + sink: ['db.query'], + sourceRule: ['source.req.body'], + sinkRule: ['sink.sql.query'], + flowId: ['sha1:abc'] +}); + +console.log('risk filter input test passed'); diff --git a/tests/shared/risk-filters.test.js b/tests/shared/risk-filters.test.js new file mode 100644 index 000000000..f4a9970dd --- /dev/null +++ b/tests/shared/risk-filters.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + filterRiskPartialFlows, + filterRiskFlows, + normalizeRiskFilters, + validateRiskFilters +} from '../../src/shared/risk-filters.js'; + +const normalized = normalizeRiskFilters({ + rule: 'sink.sql.query,source.req.body', + severity: ['HIGH', 'critical'], + tag: ['sql', 'command-exec'], + source: 'req body', + sink: 'sql query', + sinkRule: 'sink.sql.query', + flowId: 'sha1:abc' +}); + +assert.deepEqual(normalized, { + rule: ['sink.sql.query', 'source.req.body'], + category: [], + severity: ['high', 'critical'], + tag: ['sql', 'command-exec'], + source: ['req body'], + sink: ['sql query'], + sourceRule: [], + sinkRule: ['sink.sql.query'], + flowId: ['sha1:abc'] +}); + +assert.deepEqual(validateRiskFilters(normalized), { ok: true, errors: [] }); +assert.equal(validateRiskFilters({ severity: ['urgent'] }).ok, false); + +const flows = [ + { + flowId: 'sha1:abc', + category: 'sql-injection', + severity: 'high', + source: { ruleId: 'source.req.body', ruleName: 'req body', name: 'request.body', category: 'input', severity: 'low', tags: ['user-input'] }, + sink: { ruleId: 'sink.sql.query', ruleName: 'sql query', name: 'db.query', category: 'sql-injection', severity: 'high', tags: ['sql', 'command-exec'] } + }, + { + flowId: 'sha1:def', + category: 'logging', + severity: 'low', + source: { ruleId: 'source.other', ruleName: 'other', name: 'config.value', category: 'input', severity: 'low', tags: ['config'] }, + sink: { ruleId: 'sink.log', ruleName: 'log', name: 'logger.info', category: 'logging', severity: 'low', tags: ['logging'] } + } +]; + +assert.deepEqual(filterRiskFlows(flows, normalized).map((flow) => flow.flowId), ['sha1:abc']); + +const partialFlows = [ + { + partialFlowId: 'sha1:partial-a', + source: { + ruleId: 'source.req.body', + ruleName: 'req body', + name: 'request.body', + category: 'input', + severity: 'low', + tags: ['user-input'] + }, + frontier: { + chunkUid: 'chunk-frontier-a', + terminalReason: 'budget' + } + } +]; + +assert.deepEqual( + filterRiskPartialFlows(partialFlows, normalizeRiskFilters({ flowId: 'sha1:abc' })).map((flow) => flow.partialFlowId), + ['sha1:partial-a'], + 'expected flowId filters to remain non-applicable for partial flows' +); + +console.log('risk filters test passed'); diff --git a/tests/shared/runtime-capability-manifest-contract.test.js b/tests/shared/runtime-capability-manifest-contract.test.js new file mode 100644 index 000000000..aaa72c67c --- /dev/null +++ b/tests/shared/runtime-capability-manifest-contract.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + getApiWorkflowCapabilities, + getEditorCommandSpecs, + getRuntimeCapabilityManifest, + getTuiSupervisorCapabilities, + RUNTIME_CAPABILITY_MANIFEST_VERSION +} from '../../src/shared/runtime-capability-manifest.js'; +import { describeDispatchCommand, listDispatchManifest } from '../../src/shared/dispatch/registry.js'; + +const manifest = getRuntimeCapabilityManifest({ + runtimeCapabilities: { + mcp: { sdk: true }, + embeddings: { available: true } + } +}); + +assert.equal(manifest.manifestVersion, RUNTIME_CAPABILITY_MANIFEST_VERSION); +assert.equal(manifest.runtimeCapabilities.mcp.sdk, true); +assert.ok(manifest.flags['cache.gc'], 'expected cache.gc flag set to exist'); +assert.ok(manifest.flags['report.compare-models'], 'expected report.compare-models flag set to exist'); +assert.ok(Array.isArray(manifest.surfaces.cli.commands)); +assert.ok(Array.isArray(manifest.surfaces.mcp.tools)); +assert.equal(manifest.surfaces.tui.supervisor.protocol, 'poc.tui@1'); + +manifest.runtimeCapabilities.mcp.sdk = false; +manifest.flags['cache.gc'].flags.push({ name: 'mutated' }); +const manifestAgain = getRuntimeCapabilityManifest({ + runtimeCapabilities: { + mcp: { sdk: true } + } +}); +assert.equal(manifestAgain.runtimeCapabilities.mcp.sdk, true, 'manifest should clone runtime capabilities'); +assert.equal( + manifestAgain.flags['cache.gc'].flags.some((entry) => entry?.name === 'mutated'), + false, + 'flag sets should be cloned per call' +); + +const apiCapabilities = getApiWorkflowCapabilities(); +assert.ok(apiCapabilities.search, 'expected API workflow capabilities to expose search'); + +const tuiCapabilities = getTuiSupervisorCapabilities(); +tuiCapabilities.transport = 'mutated'; +assert.equal(getTuiSupervisorCapabilities().transport, undefined, 'tui capabilities should be cloned'); + +const editorSpecs = getEditorCommandSpecs(); +assert.ok(Array.isArray(editorSpecs)); +editorSpecs[0].title = 'mutated'; +assert.notEqual(getEditorCommandSpecs()[0].title, 'mutated', 'editor command specs should be cloned'); + +const dispatchList = listDispatchManifest(); +assert.ok(dispatchList.length > 0, 'expected dispatch manifest entries'); +const searchEntry = describeDispatchCommand('search'); +assert.ok(searchEntry, 'expected dispatch entry for search'); +assert.deepEqual(describeDispatchCommand(searchEntry.commandPath.join(' ')), searchEntry, 'lookup by path should match lookup by id'); + +dispatchList[0].description = 'mutated'; +const dispatchListAgain = listDispatchManifest(); +assert.notEqual(dispatchListAgain[0].description, 'mutated', 'dispatch manifest should be cloned per call'); + +console.log('runtime capability manifest contract test passed'); diff --git a/tests/shared/runtime/env-envelope-overrides.test.js b/tests/shared/runtime/env-envelope-overrides.test.js new file mode 100644 index 000000000..09227aca5 --- /dev/null +++ b/tests/shared/runtime/env-envelope-overrides.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope/resolve.js'; +import { resolveRuntimeEnv } from '../../../tools/dict-utils/paths/runtime.js'; + +const baseEnv = { ...process.env }; +delete baseEnv.NODE_OPTIONS; +delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; +delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; +delete baseEnv.UV_THREADPOOL_SIZE; +delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; + +const envelope = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: { runtime: { maxOldSpaceMb: 2048, uvThreadpoolSize: 6, nodeOptions: '--trace-warnings' } }, + env: baseEnv, + execArgv: [], + cpuCount: 8, + toolVersion: 'test' +}); + +const runtimeConfig = { + maxOldSpaceMb: envelope.runtime?.maxOldSpaceMb?.requested?.value ?? null, + nodeOptions: envelope.runtime?.nodeOptions?.requested?.value ?? '', + uvThreadpoolSize: envelope.runtime?.uvThreadpoolSize?.requested?.value ?? null, + envelope +}; + +const baseline = resolveRuntimeEnv(runtimeConfig, { ...baseEnv, NODE_OPTIONS: '' }); +assert.ok( + baseline.NODE_OPTIONS?.includes('--max-old-space-size=2048'), + 'expected baseline NODE_OPTIONS to preserve envelope max-old-space-size' +); +assert.strictEqual( + baseline.UV_THREADPOOL_SIZE, + '6', + 'expected baseline UV_THREADPOOL_SIZE from envelope' +); + +const heapOverridden = resolveRuntimeEnv( + { ...runtimeConfig, maxOldSpaceMb: 4096 }, + { ...baseEnv, NODE_OPTIONS: '' } +); +assert.ok( + heapOverridden.NODE_OPTIONS?.includes('--max-old-space-size=4096'), + 'expected explicit maxOldSpace override to apply' +); +assert.ok( + !heapOverridden.NODE_OPTIONS?.includes('--max-old-space-size=2048'), + 'expected old envelope maxOldSpace to be replaced' +); + +const uvOverridden = resolveRuntimeEnv( + { ...runtimeConfig, uvThreadpoolSize: 12 }, + { ...baseEnv, NODE_OPTIONS: '' } +); +assert.strictEqual( + uvOverridden.UV_THREADPOOL_SIZE, + '12', + 'expected explicit uvThreadpool override to apply' +); + +console.log('runtime env envelope override test passed'); diff --git a/tests/shared/runtime/runtime-contract-matrix.test.js b/tests/shared/runtime/runtime-contract-matrix.test.js new file mode 100644 index 000000000..e67c025e5 --- /dev/null +++ b/tests/shared/runtime/runtime-contract-matrix.test.js @@ -0,0 +1,318 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { parseBuildArgs } from '../../../src/index/build/args.js'; +import { createBuildRuntime } from '../../../src/index/build/runtime.js'; +import { resolveDispatchRuntimeEnv } from '../../../bin/dispatch-runtime-env.js'; +import { resolveTuiWrapperEnv } from '../../../bin/tui-wrapper-env.js'; +import { planShardBatches } from '../../../src/index/build/shards.js'; +import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope/resolve.js'; +import { resolveThreadLimits } from '../../../src/shared/threads.js'; +import { runNode } from '../../helpers/run-node.js'; +import { repoRoot } from '../../helpers/root.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { resolveRuntimeEnv } from '../../../tools/shared/dict-utils.js'; + +const cases = [ + { + name: 'node options and max-old-space honor config but respect external NODE_OPTIONS', + run() { + const baseEnv = { ...process.env }; + delete baseEnv.NODE_OPTIONS; + delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; + delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; + + const request = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: { runtime: { nodeOptions: '--trace-warnings', maxOldSpaceMb: 2048 } }, + env: baseEnv, + cpuCount: 4, + toolVersion: 'test' + }); + const patch = request.envPatch.nodeOptions; + assert.ok(patch); + assert.ok(patch.includes('--trace-warnings')); + assert.ok(patch.includes('--max-old-space-size=2048')); + + const externalOverride = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: { runtime: { nodeOptions: '--trace-warnings', maxOldSpaceMb: 2048 } }, + env: { ...baseEnv, NODE_OPTIONS: '--max-old-space-size=1024 --trace-warnings' }, + cpuCount: 4, + toolVersion: 'test' + }); + assert.ok(!externalOverride.envPatch.nodeOptions); + assert.equal(externalOverride.runtime.maxOldSpaceMb.effective.value, 1024); + } + }, + { + name: 'uv threadpool precedence resolves defaults config and env overrides consistently', + run() { + const baseEnv = { ...process.env }; + delete baseEnv.UV_THREADPOOL_SIZE; + delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; + + const baseline = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: {}, + env: baseEnv, + cpuCount: 4, + toolVersion: 'test' + }); + assert.equal(baseline.runtime.uvThreadpoolSize.effective.value, 4); + assert.equal(baseline.envPatch.set.UV_THREADPOOL_SIZE, '4'); + + const configRequest = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: { runtime: { uvThreadpoolSize: 8 } }, + env: baseEnv, + cpuCount: 4, + toolVersion: 'test' + }); + assert.equal(configRequest.runtime.uvThreadpoolSize.effective.value, 8); + assert.equal(configRequest.envPatch.set.UV_THREADPOOL_SIZE, '8'); + + const externalOverride = resolveRuntimeEnvelope({ + argv: {}, + rawArgv: [], + userConfig: { runtime: { uvThreadpoolSize: 8 } }, + env: { ...baseEnv, UV_THREADPOOL_SIZE: '6' }, + cpuCount: 4, + toolVersion: 'test' + }); + assert.equal(externalOverride.runtime.uvThreadpoolSize.effective.value, 6); + assert.ok(!externalOverride.envPatch.set.UV_THREADPOOL_SIZE); + + const envResolved = resolveRuntimeEnv({ uvThreadpoolSize: 8 }, { ...process.env, UV_THREADPOOL_SIZE: undefined }); + assert.equal(envResolved.UV_THREADPOOL_SIZE, '8'); + const noOverrideResolved = resolveRuntimeEnv({ uvThreadpoolSize: 8 }, { ...process.env, UV_THREADPOOL_SIZE: '4' }); + assert.equal(noOverrideResolved.UV_THREADPOOL_SIZE, '4'); + } + }, + { + name: 'wrapper spawn env preserves runtime overrides and external node options precedence', + run() { + const root = repoRoot(); + const wrapperPath = path.join(root, 'bin', 'pairofcleats.js'); + const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); + const buildEnv = (overrides) => { + const env = { ...process.env, ...overrides }; + delete env.UV_THREADPOOL_SIZE; + delete env.NODE_OPTIONS; + delete env.PAIROFCLEATS_NODE_OPTIONS; + return ensureTestingEnv(env); + }; + const runDump = (env) => { + const result = runNode([ + wrapperPath, + 'index', + '--config-dump', + '--repo', + fixtureRoot + ], 'wrapper config dump', root, env, { stdio: 'pipe', allowFailure: true }); + assert.strictEqual(result.status, 0, `wrapper config dump exited with ${result.status}: ${result.stderr || ''}`); + const output = String(result.stdout || '').trim(); + assert.ok(output); + return JSON.parse(output); + }; + const patched = runDump(buildEnv({ + PAIROFCLEATS_UV_THREADPOOL_SIZE: '7', + PAIROFCLEATS_MAX_OLD_SPACE_MB: '2048' + })); + assert.strictEqual(patched.runtime.uvThreadpoolSize.effective.source, 'external-env'); + assert.strictEqual(patched.runtime.maxOldSpaceMb.effective.source, 'external-env'); + const preserved = runDump({ + ...buildEnv({ + PAIROFCLEATS_UV_THREADPOOL_SIZE: '7', + PAIROFCLEATS_MAX_OLD_SPACE_MB: '2048' + }), + NODE_OPTIONS: '--trace-warnings' + }); + assert.strictEqual(preserved.runtime.nodeOptions.effective.source, 'external-env'); + assert.ok(preserved.runtime.nodeOptions.effective.value?.includes('--trace-warnings')); + assert.strictEqual(preserved.runtime.maxOldSpaceMb.effective.value, null); + } + }, + { + name: 'dispatch runtime env keeps repo-configured runtime tuning for heavy commands', + async run() { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'dispatch-runtime-env'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + await fsPromises.writeFile(path.join(tempRoot, '.pairofcleats.json'), JSON.stringify({ + runtime: { + nodeOptions: '--trace-warnings', + maxOldSpaceMb: 1536, + uvThreadpoolSize: 9 + } + }, null, 2)); + + const baseEnv = { ...process.env }; + delete baseEnv.NODE_OPTIONS; + delete baseEnv.UV_THREADPOOL_SIZE; + delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; + delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; + delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; + + const heavyEnv = await resolveDispatchRuntimeEnv({ + root: tempRoot, + scriptPath: 'tools/reports/throughput.js', + baseEnv + }); + assert.equal(heavyEnv.UV_THREADPOOL_SIZE, '9'); + assert.match(String(heavyEnv.NODE_OPTIONS || ''), /--trace-warnings/); + assert.match(String(heavyEnv.NODE_OPTIONS || ''), /--max-old-space-size=1536/); + + const skippedEnv = await resolveDispatchRuntimeEnv({ + root: tempRoot, + scriptPath: 'tools/index/cli-entry.js', + baseEnv + }); + assert.equal(skippedEnv.UV_THREADPOOL_SIZE, '9'); + assert.match(String(skippedEnv.NODE_OPTIONS || ''), /--max-old-space-size=1536/); + } + }, + { + name: 'tui wrapper env layers tui variables over dispatch runtime baseline', + async run() { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'tui-dispatch-runtime-env'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + await fsPromises.writeFile(path.join(tempRoot, '.pairofcleats.json'), JSON.stringify({ + runtime: { + nodeOptions: '--trace-warnings', + maxOldSpaceMb: 1408, + uvThreadpoolSize: 10 + } + }, null, 2)); + + const baseEnv = { ...process.env }; + delete baseEnv.NODE_OPTIONS; + delete baseEnv.UV_THREADPOOL_SIZE; + delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; + delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; + delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; + + const env = await resolveTuiWrapperEnv({ + runtimeRoot: tempRoot, + tuiEnvConfig: { + runId: 'configured-run', + installRoot: path.join(tempRoot, 'configured-install'), + eventLogDir: path.join(tempRoot, 'configured-logs') + }, + installRoot: path.join(tempRoot, 'fallback-install'), + eventLogDir: path.join(tempRoot, 'fallback-logs'), + baseEnv, + runId: 'fallback-run' + }); + assert.equal(env.UV_THREADPOOL_SIZE, '10'); + assert.match(String(env.NODE_OPTIONS || ''), /--trace-warnings/); + assert.match(String(env.NODE_OPTIONS || ''), /--max-old-space-size=1408/); + assert.equal(env.PAIROFCLEATS_TUI_RUN_ID, 'configured-run'); + assert.equal(env.PAIROFCLEATS_TUI_INSTALL_ROOT, path.join(tempRoot, 'configured-install')); + assert.equal(env.PAIROFCLEATS_TUI_EVENT_LOG_DIR, path.join(tempRoot, 'configured-logs')); + + const fallbackEnv = await resolveTuiWrapperEnv({ + runtimeRoot: tempRoot, + tuiEnvConfig: {}, + installRoot: path.join(tempRoot, 'fallback-install'), + eventLogDir: path.join(tempRoot, 'fallback-logs'), + baseEnv, + runId: 'fallback-run' + }); + assert.equal(fallbackEnv.PAIROFCLEATS_TUI_RUN_ID, 'fallback-run'); + assert.equal(fallbackEnv.PAIROFCLEATS_TUI_INSTALL_ROOT, path.join(tempRoot, 'fallback-install')); + assert.equal(fallbackEnv.PAIROFCLEATS_TUI_EVENT_LOG_DIR, path.join(tempRoot, 'fallback-logs')); + } + }, + { + name: 'thread limit precedence and shard planning stay balanced', + run() { + const cliResult = resolveThreadLimits({ + argv: { threads: 8 }, + rawArgv: ['--threads', '8'], + envConfig: { threads: 4 }, + cpuCount: 16 + }); + assert.equal(cliResult.threads, 8); + assert.equal(cliResult.source, 'cli'); + + const configResult = resolveThreadLimits({ + argv: {}, + rawArgv: [], + envConfig: { threads: 4 }, + configConcurrency: 6, + configConcurrencySource: 'config.indexing.concurrency', + configSourceTag: 'config', + cpuCount: 16 + }); + assert.equal(configResult.threads, 6); + assert.equal(configResult.source, 'config'); + + const limits = resolveThreadLimits({ + argv: { threads: 4 }, + rawArgv: ['--threads', '4'], + envConfig: {}, + configConcurrency: null, + importConcurrencyConfig: null, + cpuCount: 8, + uvThreadpoolSize: 4 + }); + assert.equal(limits.fileConcurrency, 8); + assert.equal(limits.cpuConcurrency, limits.threads); + + const items = [ + { id: 'a', weight: 8 }, + { id: 'b', weight: 7 }, + { id: 'c', weight: 6 }, + { id: 'd', weight: 5 } + ]; + const batches = planShardBatches(items, 2, { resolveWeight: (item) => item.weight }); + const sums = batches.map((batch) => batch.reduce((sum, item) => sum + item.weight, 0)).sort((a, b) => b - a); + assert.deepEqual(sums, [13, 13]); + } + }, + { + name: 'build runtime preserves records configuration shape', + async run() { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, 'runtime-records-config'); + const repoDir = path.join(tempRoot, 'repo'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(repoDir, { recursive: true }); + ensureTestingEnv(process.env); + process.env.PAIROFCLEATS_CACHE_ROOT = tempRoot; + process.env.PAIROFCLEATS_EMBEDDINGS = 'off'; + process.env.PAIROFCLEATS_TEST_CONFIG = JSON.stringify({ + indexing: { + scm: { provider: 'none' }, + embeddings: { + enabled: false, + hnsw: { enabled: false }, + lancedb: { enabled: false } + } + } + }); + const defaults = parseBuildArgs([]).argv; + const runtime = await createBuildRuntime({ root: repoDir, argv: defaults, rawArgv: [] }); + assert.ok(Object.prototype.hasOwnProperty.call(runtime, 'recordsDir')); + assert.ok(Object.prototype.hasOwnProperty.call(runtime, 'recordsConfig')); + delete process.env.PAIROFCLEATS_TEST_CONFIG; + } + } +]; + +for (const entry of cases) { + await entry.run(); +} + +console.log('shared runtime contract matrix test passed'); diff --git a/tests/shared/runtime/runtime-env-envelope-overrides.test.js b/tests/shared/runtime/runtime-env-envelope-overrides.test.js deleted file mode 100644 index 3858e289c..000000000 --- a/tests/shared/runtime/runtime-env-envelope-overrides.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope.js'; -import { resolveRuntimeEnv } from '../../../tools/dict-utils/paths/runtime.js'; - -const baseEnv = { ...process.env }; -delete baseEnv.NODE_OPTIONS; -delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; -delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; -delete baseEnv.UV_THREADPOOL_SIZE; -delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; - -const envelope = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: { runtime: { maxOldSpaceMb: 2048, uvThreadpoolSize: 6, nodeOptions: '--trace-warnings' } }, - env: baseEnv, - execArgv: [], - cpuCount: 8, - toolVersion: 'test' -}); - -const runtimeConfig = { - maxOldSpaceMb: envelope.runtime?.maxOldSpaceMb?.requested?.value ?? null, - nodeOptions: envelope.runtime?.nodeOptions?.requested?.value ?? '', - uvThreadpoolSize: envelope.runtime?.uvThreadpoolSize?.requested?.value ?? null, - envelope -}; - -const baseline = resolveRuntimeEnv(runtimeConfig, { ...baseEnv, NODE_OPTIONS: '' }); -assert.ok( - baseline.NODE_OPTIONS?.includes('--max-old-space-size=2048'), - 'expected baseline NODE_OPTIONS to preserve envelope max-old-space-size' -); -assert.strictEqual( - baseline.UV_THREADPOOL_SIZE, - '6', - 'expected baseline UV_THREADPOOL_SIZE from envelope' -); - -const heapOverridden = resolveRuntimeEnv( - { ...runtimeConfig, maxOldSpaceMb: 4096 }, - { ...baseEnv, NODE_OPTIONS: '' } -); -assert.ok( - heapOverridden.NODE_OPTIONS?.includes('--max-old-space-size=4096'), - 'expected explicit maxOldSpace override to apply' -); -assert.ok( - !heapOverridden.NODE_OPTIONS?.includes('--max-old-space-size=2048'), - 'expected old envelope maxOldSpace to be replaced' -); - -const uvOverridden = resolveRuntimeEnv( - { ...runtimeConfig, uvThreadpoolSize: 12 }, - { ...baseEnv, NODE_OPTIONS: '' } -); -assert.strictEqual( - uvOverridden.UV_THREADPOOL_SIZE, - '12', - 'expected explicit uvThreadpool override to apply' -); - -console.log('runtime env envelope override test passed'); diff --git a/tests/shared/runtime/runtime-envelope-node-options-merge.test.js b/tests/shared/runtime/runtime-envelope-node-options-merge.test.js deleted file mode 100644 index 72c377c1a..000000000 --- a/tests/shared/runtime/runtime-envelope-node-options-merge.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope.js'; - -const baseEnv = { ...process.env }; -delete baseEnv.NODE_OPTIONS; -delete baseEnv.PAIROFCLEATS_NODE_OPTIONS; -delete baseEnv.PAIROFCLEATS_MAX_OLD_SPACE_MB; - -const request = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: { runtime: { nodeOptions: '--trace-warnings', maxOldSpaceMb: 2048 } }, - env: baseEnv, - cpuCount: 4, - toolVersion: 'test' -}); - -const patch = request.envPatch.nodeOptions; -assert.ok(patch, 'expected NODE_OPTIONS patch'); -assert.ok(patch.includes('--trace-warnings'), 'expected requested nodeOptions in patch'); -assert.ok(patch.includes('--max-old-space-size=2048'), 'expected max-old-space-size in patch'); -assert.strictEqual( - patch.split('--trace-warnings').length - 1, - 1, - 'nodeOptions should only include requested flag once' -); - -const externalEnv = { ...baseEnv, NODE_OPTIONS: '--max-old-space-size=1024 --trace-warnings' }; -const externalOverride = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: { runtime: { nodeOptions: '--trace-warnings', maxOldSpaceMb: 2048 } }, - env: externalEnv, - cpuCount: 4, - toolVersion: 'test' -}); - -assert.ok(!externalOverride.envPatch.nodeOptions, 'envPatch should not override external NODE_OPTIONS'); -assert.strictEqual( - externalOverride.runtime.maxOldSpaceMb.effective.value, - 1024, - 'effective maxOldSpace should reflect external NODE_OPTIONS' -); -assert.ok( - externalOverride.warnings.some((warning) => warning.code === 'runtime.envOverride' - && warning.fields?.includes('runtime.maxOldSpaceMb')), - 'expected envOverride warning for max-old-space-size override' -); - -console.log('runtime-envelope-node-options-merge tests passed'); diff --git a/tests/shared/runtime/runtime-envelope-spawn-env.test.js b/tests/shared/runtime/runtime-envelope-spawn-env.test.js deleted file mode 100644 index 3dfb08f7b..000000000 --- a/tests/shared/runtime/runtime-envelope-spawn-env.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { repoRoot } from '../../helpers/root.js'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; - -const root = repoRoot(); -const wrapperPath = path.join(root, 'bin', 'pairofcleats.js'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); - -const buildEnv = (overrides) => { - const env = { ...process.env, ...overrides }; - delete env.UV_THREADPOOL_SIZE; - delete env.NODE_OPTIONS; - delete env.PAIROFCLEATS_NODE_OPTIONS; - return ensureTestingEnv(env); -}; - -const runDump = (env) => { - const result = spawnSync(process.execPath, [ - wrapperPath, - 'index', - '--config-dump', - '--repo', - fixtureRoot - ], { - cwd: root, - env, - encoding: 'utf8' - }); - assert.strictEqual(result.status, 0, `wrapper config dump exited with ${result.status}: ${result.stderr || ''}`); - const output = String(result.stdout || '').trim(); - assert.ok(output, 'expected config dump output from wrapper'); - return JSON.parse(output); -}; - -const patched = runDump(buildEnv({ - PAIROFCLEATS_UV_THREADPOOL_SIZE: '7', - PAIROFCLEATS_MAX_OLD_SPACE_MB: '2048' -})); - -assert.strictEqual( - patched.runtime.uvThreadpoolSize.effective.source, - 'external-env', - 'expected wrapper to apply UV_THREADPOOL_SIZE before spawning child' -); -assert.strictEqual( - patched.runtime.maxOldSpaceMb.effective.source, - 'external-env', - 'expected wrapper to apply NODE_OPTIONS before spawning child' -); - -const preserved = runDump({ - ...buildEnv({ - PAIROFCLEATS_UV_THREADPOOL_SIZE: '7', - PAIROFCLEATS_MAX_OLD_SPACE_MB: '2048' - }), - NODE_OPTIONS: '--trace-warnings' -}); - -assert.strictEqual( - preserved.runtime.nodeOptions.effective.source, - 'external-env', - 'expected wrapper to preserve NODE_OPTIONS when already set' -); -assert.ok( - preserved.runtime.nodeOptions.effective.value?.includes('--trace-warnings'), - 'expected NODE_OPTIONS to preserve unrelated flags' -); -assert.strictEqual( - preserved.runtime.maxOldSpaceMb.effective.value, - null, - 'expected maxOldSpaceMb to remain unset when NODE_OPTIONS is externally defined' -); - -console.log('runtime envelope spawn env test passed'); diff --git a/tests/shared/runtime/runtime-envelope-uv-threadpool-precedence.test.js b/tests/shared/runtime/runtime-envelope-uv-threadpool-precedence.test.js deleted file mode 100644 index bf52d89c5..000000000 --- a/tests/shared/runtime/runtime-envelope-uv-threadpool-precedence.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveRuntimeEnvelope } from '../../../src/shared/runtime-envelope.js'; - -const baseEnv = { ...process.env }; -delete baseEnv.UV_THREADPOOL_SIZE; -delete baseEnv.PAIROFCLEATS_UV_THREADPOOL_SIZE; - -const baseline = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: {}, - env: baseEnv, - cpuCount: 4, - toolVersion: 'test' -}); - -assert.strictEqual(baseline.runtime.uvThreadpoolSize.effective.value, 4, 'default uv threadpool size should be 4'); -assert.ok( - Number.isFinite(baseline.concurrency.totalMemBytes), - 'expected totalMemBytes to be present' -); -assert.strictEqual( - baseline.envPatch.set.UV_THREADPOOL_SIZE, - '4', - 'envPatch should set UV_THREADPOOL_SIZE to the default' -); - -const configEnv = { ...baseEnv }; -const configRequest = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: { runtime: { uvThreadpoolSize: 8 } }, - env: configEnv, - cpuCount: 4, - toolVersion: 'test' -}); - -assert.strictEqual(configRequest.runtime.uvThreadpoolSize.requested.value, 8, 'requested uv threadpool size should be 8'); -assert.strictEqual(configRequest.runtime.uvThreadpoolSize.effective.value, 8, 'effective uv threadpool size should match config'); -assert.strictEqual(configRequest.envPatch.set.UV_THREADPOOL_SIZE, '8', 'envPatch should set UV_THREADPOOL_SIZE when requested'); - -const externalEnv = { ...baseEnv, UV_THREADPOOL_SIZE: '6' }; -const externalOverride = resolveRuntimeEnvelope({ - argv: {}, - rawArgv: [], - userConfig: { runtime: { uvThreadpoolSize: 8 } }, - env: externalEnv, - cpuCount: 4, - toolVersion: 'test' -}); - -assert.strictEqual(externalOverride.runtime.uvThreadpoolSize.effective.value, 6, 'external UV_THREADPOOL_SIZE should win'); -assert.ok(!externalOverride.envPatch.set.UV_THREADPOOL_SIZE, 'envPatch should not override external UV_THREADPOOL_SIZE'); -assert.ok( - externalOverride.warnings.some((warning) => warning.code === 'runtime.envOverride' - && warning.fields?.includes('runtime.uvThreadpoolSize')), - 'expected envOverride warning when external UV_THREADPOOL_SIZE overrides request' -); - -console.log('runtime-envelope-uv-threadpool-precedence tests passed'); diff --git a/tests/shared/runtime/runtime-records-config.test.js b/tests/shared/runtime/runtime-records-config.test.js deleted file mode 100644 index 9e564009d..000000000 --- a/tests/shared/runtime/runtime-records-config.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { parseBuildArgs } from '../../../src/index/build/args.js'; -import { createBuildRuntime } from '../../../src/index/build/runtime.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'runtime-records-config'); -const repoRoot = path.join(tempRoot, 'repo'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -applyTestEnv({ - cacheRoot: tempRoot, - embeddings: 'off', - testConfig: { - indexing: { - scm: { provider: 'none' }, - embeddings: { - enabled: false, - hnsw: { enabled: false }, - lancedb: { enabled: false } - } - } - } -}); - -const defaults = parseBuildArgs([]).argv; -const runtime = await createBuildRuntime({ root: repoRoot, argv: defaults, rawArgv: [] }); - -if (!Object.prototype.hasOwnProperty.call(runtime, 'recordsDir')) { - console.error('runtime missing recordsDir'); - process.exit(1); -} -if (!Object.prototype.hasOwnProperty.call(runtime, 'recordsConfig')) { - console.error('runtime missing recordsConfig'); - process.exit(1); -} - -console.log('runtime records config test passed'); - diff --git a/tests/shared/runtime/thread-limits-precedence-cli-over-env.test.js b/tests/shared/runtime/thread-limits-precedence-cli-over-env.test.js deleted file mode 100644 index f2adbb258..000000000 --- a/tests/shared/runtime/thread-limits-precedence-cli-over-env.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveThreadLimits } from '../../../src/shared/threads.js'; - -const cliResult = resolveThreadLimits({ - argv: { threads: 8 }, - rawArgv: ['--threads', '8'], - envConfig: { threads: 4 }, - cpuCount: 16 -}); - -assert.strictEqual(cliResult.threads, 8, 'cli threads should override env'); -assert.strictEqual(cliResult.source, 'cli', 'cli should be recorded as source'); - -const configResult = resolveThreadLimits({ - argv: {}, - rawArgv: [], - envConfig: { threads: 4 }, - configConcurrency: 6, - configConcurrencySource: 'config.indexing.concurrency', - configSourceTag: 'config', - cpuCount: 16 -}); - -assert.strictEqual(configResult.threads, 6, 'config threads should override env'); -assert.strictEqual(configResult.source, 'config', 'config should be recorded as source'); - -console.log('thread limits precedence tests passed'); diff --git a/tests/shared/runtime/thread-limits.test.js b/tests/shared/runtime/thread-limits.test.js deleted file mode 100644 index 4de7ec582..000000000 --- a/tests/shared/runtime/thread-limits.test.js +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import { resolveThreadLimits } from '../../../src/shared/threads.js'; -import { planShardBatches } from '../../../src/index/build/shards.js'; - -const argv = { threads: 4 }; -const rawArgv = ['--threads', '4']; -const envConfig = {}; -const limits = resolveThreadLimits({ - argv, - rawArgv, - envConfig, - configConcurrency: null, - importConcurrencyConfig: null, - cpuCount: 8, - uvThreadpoolSize: 4 -}); -const expectedFileConcurrency = 8; - -if (limits.fileConcurrency !== expectedFileConcurrency) { - console.error( - `thread limits test failed: fileConcurrency ${limits.fileConcurrency} !== ${expectedFileConcurrency}` - ); - process.exit(1); -} -if (limits.cpuConcurrency !== limits.threads) { - console.error('thread limits test failed: cpuConcurrency should follow resolved thread count'); - process.exit(1); -} - -const items = [ - { id: 'a', weight: 8 }, - { id: 'b', weight: 7 }, - { id: 'c', weight: 6 }, - { id: 'd', weight: 5 } -]; -const batches = planShardBatches(items, 2, { resolveWeight: (item) => item.weight }); -if (batches.length !== 2) { - console.error(`thread limits test failed: expected 2 batches, got ${batches.length}`); - process.exit(1); -} -const sums = batches.map((batch) => batch.reduce((sum, item) => sum + item.weight, 0)); -const sorted = sums.slice().sort((a, b) => b - a); -if (sorted[0] !== 13 || sorted[1] !== 13) { - console.error(`thread limits test failed: batch sums ${sorted.join(',')} expected 13,13`); - process.exit(1); -} - -console.log('thread limits test passed'); diff --git a/tests/shared/runtime/uv-threadpool-env.test.js b/tests/shared/runtime/uv-threadpool-env.test.js deleted file mode 100644 index b0ad9ca7c..000000000 --- a/tests/shared/runtime/uv-threadpool-env.test.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import { resolveRuntimeEnv } from '../../../tools/shared/dict-utils.js'; - -const env = { - ...process.env, - UV_THREADPOOL_SIZE: undefined -}; - -const resolved = resolveRuntimeEnv({ uvThreadpoolSize: 8 }, env); -if (resolved.UV_THREADPOOL_SIZE !== '8') { - throw new Error( - `uv-threadpool-env test failed: expected UV_THREADPOOL_SIZE=8, got ${resolved.UV_THREADPOOL_SIZE}` - ); -} - -console.log('uv-threadpool-env test passed'); diff --git a/tests/shared/runtime/uv-threadpool-no-override.test.js b/tests/shared/runtime/uv-threadpool-no-override.test.js deleted file mode 100644 index 726cceeb7..000000000 --- a/tests/shared/runtime/uv-threadpool-no-override.test.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import { resolveRuntimeEnv } from '../../../tools/shared/dict-utils.js'; - -const baseEnv = { - ...process.env, - UV_THREADPOOL_SIZE: '4' -}; - -const resolved = resolveRuntimeEnv({ uvThreadpoolSize: 8 }, baseEnv); -if (resolved.UV_THREADPOOL_SIZE !== '4') { - throw new Error( - `uv-threadpool-no-override test failed: expected UV_THREADPOOL_SIZE=4, got ${resolved.UV_THREADPOOL_SIZE}` - ); -} - -console.log('uv-threadpool-no-override test passed'); diff --git a/tests/shared/safe-regex/contract-matrix.test.js b/tests/shared/safe-regex/contract-matrix.test.js new file mode 100644 index 000000000..31a56f27b --- /dev/null +++ b/tests/shared/safe-regex/contract-matrix.test.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { tryRequire } from '../../../src/shared/optional-deps.js'; +import { compileSafeRegex, createSafeRegex, normalizeSafeRegexConfig } from '../../../src/shared/safe-regex.js'; + +const hasRe2 = tryRequire('re2').ok; + +{ + const autoRegex = createSafeRegex('a', 'g'); + assert(autoRegex); + assert.equal(autoRegex.engine, hasRe2 ? 're2' : 're2js'); + + const forcedJs = createSafeRegex('a', 'g', { engine: 're2js' }); + assert(forcedJs); + assert.equal(forcedJs.engine, 're2js'); + + const forcedRe2 = createSafeRegex('a', 'g', { engine: 're2' }); + assert(forcedRe2); + assert.equal(forcedRe2.engine, hasRe2 ? 're2' : 're2js'); + + const matchRegex = createSafeRegex('(a)(b)', 'g'); + const match1 = matchRegex.exec('ab'); + assert(match1); + assert.equal(match1[0], 'ab'); + assert.equal(match1[1], 'a'); + assert.equal(match1[2], 'b'); + assert.equal(match1.index, 0); + assert.equal(matchRegex.lastIndex, 2); + assert.equal(matchRegex.exec('ab'), null); + assert.equal(matchRegex.lastIndex, 0); + + const testRegex = createSafeRegex('a', 'g'); + assert.equal(testRegex.test('a'), true); + assert.equal(testRegex.lastIndex, 1); + assert.equal(testRegex.test('a'), false); + assert.equal(testRegex.lastIndex, 0); + + const limitedInput = createSafeRegex('a', '', { maxInputLength: 1 }); + assert(limitedInput); + assert.equal(limitedInput.exec('aa'), null); + assert.equal(limitedInput.lastIndex, 0); + + assert.equal(createSafeRegex('aa', '', { maxPatternLength: 1 }), null); + assert.equal(createSafeRegex('a', '', { maxProgramSize: 1 }), null); + assert.equal(createSafeRegex('(', '', {}), null); +} + +{ + const normalized = normalizeSafeRegexConfig({ flags: 'usmgii' }); + assert.equal(normalized.flags, 'gims'); + const regex = createSafeRegex('a', 'smgi', { flags: 'i' }); + assert(regex); + assert.equal(regex.flags, 'gims'); +} + +{ + const { regex } = compileSafeRegex('a', 'g', { maxInputLength: 1 }); + assert(regex); + assert.equal(regex.test('aa'), false); + assert.equal(regex.lastIndex, 0); + + const result = compileSafeRegex('a', '', { maxProgramSize: 1 }); + assert.equal(result.regex, null); + assert.equal(result.error?.code, 'PROGRAM_TOO_LARGE'); +} + +console.log('shared safe-regex contract matrix test passed'); diff --git a/tests/shared/safe-regex/flags-normalization.test.js b/tests/shared/safe-regex/flags-normalization.test.js deleted file mode 100644 index 07a6cd7a7..000000000 --- a/tests/shared/safe-regex/flags-normalization.test.js +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSafeRegex, normalizeSafeRegexConfig } from '../../../src/shared/safe-regex.js'; - -const normalized = normalizeSafeRegexConfig({ flags: 'usmgii' }); -assert.equal(normalized.flags, 'gims', 'flags should be canonicalized and drop unsupported flags'); - -const regex = createSafeRegex('a', 'smgi', { flags: 'i' }); -assert(regex, 'regex should compile'); -assert.equal(regex.flags, 'gims', 'regex flags should be canonicalized'); - -console.log('safe regex flags normalization ok'); diff --git a/tests/shared/safe-regex/input-length-cap.test.js b/tests/shared/safe-regex/input-length-cap.test.js deleted file mode 100644 index b4365fa99..000000000 --- a/tests/shared/safe-regex/input-length-cap.test.js +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { compileSafeRegex } from '../../../src/shared/safe-regex.js'; - -const { regex } = compileSafeRegex('a', 'g', { maxInputLength: 1 }); -assert(regex, 'regex should compile'); -assert.equal(regex.test('aa'), false, 'input length cap should reject oversized input'); -assert.equal(regex.lastIndex, 0, 'lastIndex should reset when input is rejected'); - -console.log('safe regex input length cap ok'); diff --git a/tests/shared/safe-regex/program-size-cap.test.js b/tests/shared/safe-regex/program-size-cap.test.js deleted file mode 100644 index 19d1204d8..000000000 --- a/tests/shared/safe-regex/program-size-cap.test.js +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { compileSafeRegex } from '../../../src/shared/safe-regex.js'; - -const result = compileSafeRegex('a', '', { maxProgramSize: 1 }); -assert.equal(result.regex, null, 'program size cap should reject'); -assert.equal(result.error?.code, 'PROGRAM_TOO_LARGE'); - -console.log('safe regex program size cap ok'); diff --git a/tests/shared/safe-regex/safe-regex-engine.test.js b/tests/shared/safe-regex/safe-regex-engine.test.js deleted file mode 100644 index de4c4d92f..000000000 --- a/tests/shared/safe-regex/safe-regex-engine.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createSafeRegex } from '../../../src/shared/safe-regex.js'; -import { tryRequire } from '../../../src/shared/optional-deps.js'; - -const hasRe2 = tryRequire('re2').ok; - -const autoRegex = createSafeRegex('a', 'g'); -assert(autoRegex, 'auto regex should compile'); -assert.equal(autoRegex.engine, hasRe2 ? 're2' : 're2js', 'auto engine should match availability'); - -const forcedJs = createSafeRegex('a', 'g', { engine: 're2js' }); -assert(forcedJs, 'forced re2js should compile'); -assert.equal(forcedJs.engine, 're2js', 'forced re2js should use re2js'); - -const forcedRe2 = createSafeRegex('a', 'g', { engine: 're2' }); -assert(forcedRe2, 'forced re2 should compile or fall back'); -assert.equal(forcedRe2.engine, hasRe2 ? 're2' : 're2js', 'forced re2 should fall back when missing'); - -const matchRegex = createSafeRegex('(a)(b)', 'g'); -const match1 = matchRegex.exec('ab'); -assert(match1, 'exec should return match'); -assert.equal(match1[0], 'ab'); -assert.equal(match1[1], 'a'); -assert.equal(match1[2], 'b'); -assert.equal(match1.index, 0); -assert.equal(matchRegex.lastIndex, 2); -const match2 = matchRegex.exec('ab'); -assert.equal(match2, null); -assert.equal(matchRegex.lastIndex, 0); - -const testRegex = createSafeRegex('a', 'g'); -assert.equal(testRegex.test('a'), true); -assert.equal(testRegex.lastIndex, 1); -assert.equal(testRegex.test('a'), false); -assert.equal(testRegex.lastIndex, 0); - -const limitedInput = createSafeRegex('a', '', { maxInputLength: 1 }); -assert(limitedInput, 'input-limited regex should compile'); -assert.equal(limitedInput.exec('aa'), null); -assert.equal(limitedInput.lastIndex, 0); - -const limitedPattern = createSafeRegex('aa', '', { maxPatternLength: 1 }); -assert.equal(limitedPattern, null, 'pattern length limit should reject'); - -const limitedProgram = createSafeRegex('a', '', { maxProgramSize: 1 }); -assert.equal(limitedProgram, null, 'program size limit should reject'); - -const invalidPattern = createSafeRegex('(', '', {}); -assert.equal(invalidPattern, null, 'invalid patterns should be rejected'); - -console.log('safe regex engine tests passed'); diff --git a/tests/shared/search-request-contract.test.js b/tests/shared/search-request-contract.test.js new file mode 100644 index 000000000..d051eb116 --- /dev/null +++ b/tests/shared/search-request-contract.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildSearchRequestArgs, + normalizeMetaFilters, + normalizeMetaJson, + toList +} from '../../src/shared/search-request.js'; + +assert.deepEqual(toList(null), []); +assert.deepEqual(toList('alpha'), ['alpha']); +assert.deepEqual(toList(['alpha', 'beta']), ['alpha', 'beta']); + +assert.equal(normalizeMetaFilters(null), null); +assert.deepEqual(normalizeMetaFilters({ lang: 'js', flag: '' }), ['lang=js', 'flag']); +assert.deepEqual(normalizeMetaFilters(['x', { y: 2 }, { z: '' }]), ['x', 'y=2', 'z']); +assert.equal(normalizeMetaJson({ alpha: 1 }), '{"alpha":1}'); +assert.equal(normalizeMetaJson('{"beta":2}'), '{"beta":2}'); + +const invalidPayload = buildSearchRequestArgs(null); +assert.equal(invalidPayload.ok, false); +assert.match(invalidPayload.message, /Invalid search payload/i); + +const missingQuery = buildSearchRequestArgs({ query: ' ' }); +assert.equal(missingQuery.ok, false); +assert.match(missingQuery.message, /Missing query/i); + +const canonical = buildSearchRequestArgs({ + query: 'needle', + output: 'compact', + mode: 'both', + repoPath: 'C:/repo', + backend: 'sqlite-fts', + ann: false, + allowSparseFallback: true, + top: -5, + context: 2, + path: ['src/**'], + ext: ['js'], + meta: [{ lang: 'js' }, 'owner=api'], + metaJson: { source: 'contract' }, + case: true, + returns: true +}, { + includeRepo: true, + repoPath: 'C:/repo', + topMin: 0, + omitModeBoth: true +}); + +assert.equal(canonical.ok, true); +assert.equal(canonical.query, 'needle'); +assert.equal(canonical.output, 'compact'); +assert.deepEqual(canonical.args.slice(0, 4), ['--json', '--repo', 'C:/repo', '--compact']); +assert.equal(canonical.args.includes('--mode'), false, 'mode=both should be omitted when omitModeBoth is enabled'); +assert.equal(canonical.args.includes('--no-ann'), true); +assert.equal(canonical.args.includes('--allow-sparse-fallback'), true); +assert.equal(canonical.args.includes('--returns'), true); +assert.equal(canonical.args.includes('--case'), true); +assert.deepEqual( + canonical.args.filter((entry) => entry === '--meta').length, + 2, + 'expected each normalized meta filter to emit its own --meta flag' +); +assert.equal(canonical.args.includes('--meta-json'), true); +assert.deepEqual( + canonical.args.slice(canonical.args.indexOf('--top'), canonical.args.indexOf('--top') + 2), + ['--top', '0'], + 'top should clamp to the configured minimum' +); + +const invalidSnapshotMix = buildSearchRequestArgs({ + query: 'needle', + asOf: 'snap-1', + snapshot: 'snap-2' +}); +assert.equal(invalidSnapshotMix.ok, false); +assert.match(invalidSnapshotMix.message, /Cannot combine asOf with snapshot/i); + +const conflictingSnapshotIds = buildSearchRequestArgs({ + query: 'needle', + snapshot: 'snap-1', + snapshotId: 'snap-2' +}); +assert.equal(conflictingSnapshotIds.ok, false); +assert.match(conflictingSnapshotIds.message, /snapshot and snapshotId conflict/i); + +const badOutput = buildSearchRequestArgs({ + query: 'needle', + output: 'wide' +}); +assert.equal(badOutput.ok, false); +assert.match(badOutput.message, /Unsupported output mode/i); + +console.log('search request contract test passed'); diff --git a/tests/shared/seed-ref/parse-seed-ref.test.js b/tests/shared/seed-ref/parse.test.js similarity index 100% rename from tests/shared/seed-ref/parse-seed-ref.test.js rename to tests/shared/seed-ref/parse.test.js diff --git a/tests/shared/snapshots-registry.test.js b/tests/shared/snapshots-registry.test.js index 571b67fe1..3064ab5b1 100644 --- a/tests/shared/snapshots-registry.test.js +++ b/tests/shared/snapshots-registry.test.js @@ -3,7 +3,7 @@ import { applyTestEnv } from '../helpers/test-env.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { acquireIndexLock } from '../../src/index/build/lock.js'; +import { acquireRegistryLock } from '../../src/index/registry-lock.js'; import { cleanupStaleFrozenStagingDirs, createEmptySnapshotsManifest, @@ -54,13 +54,13 @@ await fs.writeFile(`${manifestPath}.tmp-orphan`, '{not-valid-json'); const loaded = loadSnapshotsManifest(repoCacheRoot); assert.deepEqual(loaded, manifest, 'orphan temp writes must not corrupt registry reads'); -const lock = await acquireIndexLock({ repoCacheRoot, waitMs: 0 }); -assert.ok(lock, 'expected to acquire index lock'); +const lock = await acquireRegistryLock({ repoCacheRoot, domain: 'snapshots', waitMs: 0 }); +assert.ok(lock, 'expected to acquire snapshots lock'); try { await assert.rejects( () => writeSnapshotsManifest(repoCacheRoot, createEmptySnapshotsManifest(), { waitMs: 0 }), (err) => err?.code === 'QUEUE_OVERLOADED', - 'manifest writes should fail fast when lock is held' + 'manifest writes should fail fast when snapshots lock is held' ); } finally { await lock.release(); diff --git a/tests/shared/subprocess/abort-kill-grace-unref.test.js b/tests/shared/subprocess/abort-kill-grace-unref.test.js new file mode 100644 index 000000000..ee685a90c --- /dev/null +++ b/tests/shared/subprocess/abort-kill-grace-unref.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const script = ` +import { spawnSubprocess } from './src/shared/subprocess/runner.js'; +const controller = new AbortController(); +setTimeout(() => controller.abort(), 25); +const startedAt = Date.now(); +try { + await spawnSubprocess( + process.execPath, + ['-e', 'setInterval(() => {}, 1000);'], + { + signal: controller.signal, + killGraceMs: 2500, + timeoutAbortReapWaitMs: 200, + stdio: ['ignore', 'ignore', 'ignore'], + captureStdout: false, + captureStderr: false + } + ); +} catch {} +process.stdout.write(JSON.stringify({ durationMs: Date.now() - startedAt })); +`; + +const startedAt = Date.now(); +const run = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: process.cwd(), + encoding: 'utf8' +}); +const wallClockMs = Date.now() - startedAt; + +assert.equal(run.status, 0, `expected helper script to exit cleanly: ${run.stderr || ''}`); + +let payload = {}; +try { + payload = JSON.parse(String(run.stdout || '{}')); +} catch { + assert.fail(`expected JSON payload from helper script, got: ${String(run.stdout || '').trim()}`); +} + +const innerDurationMs = Number(payload.durationMs); +assert.ok(Number.isFinite(innerDurationMs), `expected numeric inner duration, got: ${String(payload.durationMs)}`); +assert.ok(innerDurationMs >= 200, `expected abort path to await bounded reap before reject; got ${innerDurationMs}ms`); +assert.ok(innerDurationMs < 2000, `expected abort path to return before full grace wait; got ${innerDurationMs}ms`); +assert.ok(wallClockMs < 3000, `expected process to exit without waiting full grace timer; got ${wallClockMs}ms`); + +console.log('subprocess abort kill grace unref test passed'); diff --git a/tests/shared/subprocess/abort-kills-child.test.js b/tests/shared/subprocess/abort-kills-child.test.js index 97517316d..d83449e1a 100644 --- a/tests/shared/subprocess/abort-kills-child.test.js +++ b/tests/shared/subprocess/abort-kills-child.test.js @@ -1,12 +1,23 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { captureProcessSnapshot, snapshotTrackedSubprocesses, spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { captureProcessSnapshot, snapshotTrackedSubprocesses } from '../../../src/shared/subprocess/snapshot.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; import { resolveSilentStdio } from '../../helpers/test-env.js'; const controller = new AbortController(); const args = ['-e', 'setInterval(() => {}, 1000)']; let trackedSnapshotAtSpawn = null; +const waitFor = async (predicate, timeoutMs = 5000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return predicate(); +}; + setTimeout(() => controller.abort(), 120); let pid = null; @@ -54,11 +65,13 @@ if (pid && process.platform !== 'win32') { assert.equal(alive, false, 'expected subprocess to be killed'); } +const trackedCleared = await waitFor(() => getTrackedSubprocessCount() === 0, 5000); +assert.equal(trackedCleared, true, 'expected aborted subprocess to be removed from tracked registry'); const trackedAfterAbort = snapshotTrackedSubprocesses({ limit: 8 }); assert.equal( trackedAfterAbort.entries.some((entry) => entry.pid === pid), false, - 'expected aborted subprocess to be removed from tracked registry' + 'expected aborted subprocess snapshot to exclude spawned pid' ); const processSnapshot = captureProcessSnapshot({ includeStack: true, frameLimit: 6, handleTypeLimit: 4 }); assert.equal(processSnapshot.pid, process.pid, 'expected process snapshot to include current pid'); diff --git a/tests/shared/subprocess/capture-bounds.test.js b/tests/shared/subprocess/capture-bounds.test.js index e7e1e6286..88d6e904d 100644 --- a/tests/shared/subprocess/capture-bounds.test.js +++ b/tests/shared/subprocess/capture-bounds.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; const maxOutputBytes = 512; const script = `process.stdout.write('a'.repeat(${maxOutputBytes * 3}));`; diff --git a/tests/shared/subprocess/subprocess-collector-tail.test.js b/tests/shared/subprocess/collector-tail.test.js similarity index 100% rename from tests/shared/subprocess/subprocess-collector-tail.test.js rename to tests/shared/subprocess/collector-tail.test.js diff --git a/tests/shared/subprocess/command-invocation.test.js b/tests/shared/subprocess/command-invocation.test.js new file mode 100644 index 000000000..ee456f234 --- /dev/null +++ b/tests/shared/subprocess/command-invocation.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + resolveCommandInvocation, + shouldUseCommandShimShell +} from '../../../src/shared/subprocess/command-invocation.js'; + +if (process.platform !== 'win32') { + const invocation = resolveCommandInvocation('node', ['--version']); + assert.equal(invocation.command, 'node', 'expected non-Windows commands to pass through unchanged'); + assert.deepEqual(invocation.args, ['--version'], 'expected non-Windows args to pass through unchanged'); + assert.equal(invocation.env, null, 'expected no invocation env on non-Windows direct execution'); + assert.equal( + shouldUseCommandShimShell('npm', process.env), + false, + 'expected shim shell detection to stay disabled on non-Windows' + ); + console.log('shared command invocation test passed'); + process.exit(0); +} + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-command-invocation-')); +try { + const shimPath = path.join(tempRoot, 'npm.cmd'); + await fs.writeFile(shimPath, '@echo off\r\nnode "%~dp0\\noop.js" %*\r\n', 'utf8'); + await fs.writeFile(path.join(tempRoot, 'noop.js'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8'); + + const shimEnv = { + ...process.env, + PATH: tempRoot, + Path: tempRoot + }; + assert.equal( + shouldUseCommandShimShell('npm', shimEnv), + true, + 'expected shared shim detection to resolve bare npm through PATH-backed npm.cmd' + ); + const invocation = resolveCommandInvocation('npm', ['install', '--version'], shimEnv); + assert.notEqual( + String(invocation.command || '').trim().toLowerCase(), + 'npm', + 'expected bare npm shim resolution to avoid raw unresolved command token' + ); + assert.deepEqual( + invocation.args.slice(-2), + ['install', '--version'], + 'expected shared invocation helper to preserve original argv' + ); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('shared command invocation test passed'); diff --git a/tests/shared/subprocess/detached-unref-skips-tracking.test.js b/tests/shared/subprocess/detached-unref-skips-tracking.test.js index 040808d2a..d4ce3cac3 100644 --- a/tests/shared/subprocess/detached-unref-skips-tracking.test.js +++ b/tests/shared/subprocess/detached-unref-skips-tracking.test.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { getTrackedSubprocessCount, spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; let trackedAtSpawn = -1; let timedOut = false; diff --git a/tests/shared/subprocess/invalid-abort-signal-rejected.test.js b/tests/shared/subprocess/invalid-abort-signal-rejected.test.js new file mode 100644 index 000000000..a9b78df0d --- /dev/null +++ b/tests/shared/subprocess/invalid-abort-signal-rejected.test.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; + +await assert.rejects( + () => spawnSubprocess(process.execPath, ['-e', 'console.log("ok")'], { + signal: { aborted: false } + }), + (error) => ( + error?.code === 'SUBPROCESS_FAILED' + && /Invalid AbortSignal/.test(String(error?.message || '')) + && error?.result?.pid == null + ), + 'expected invalid signal input to be rejected before spawn' +); + +console.log('subprocess invalid abort-signal rejected test passed'); diff --git a/tests/shared/subprocess/output-callbacks-guarded.test.js b/tests/shared/subprocess/output-callbacks-guarded.test.js new file mode 100644 index 000000000..f545a5dfd --- /dev/null +++ b/tests/shared/subprocess/output-callbacks-guarded.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; +import { resolveSilentStdio } from '../../helpers/test-env.js'; + +const waitFor = async (predicate, timeoutMs = 5000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return predicate(); +}; + +const assertCallbackFailure = async ({ + script, + stdio, + handlerKey, + expectedStream +}) => { + let caught = null; + try { + await spawnSubprocess(process.execPath, ['-e', script], { + stdio, + killTree: true, + [handlerKey]: () => { + throw new Error(`${handlerKey}-boom`); + } + }); + assert.fail(`expected ${handlerKey} callback failure`); + } catch (error) { + caught = error; + } + + assert.equal(caught?.code, 'SUBPROCESS_FAILED', `expected controlled failure code for ${handlerKey}`); + assert.match( + String(caught?.message || ''), + new RegExp(`${expectedStream} callback failed`, 'i'), + `expected ${expectedStream} callback failure message` + ); + assert.match( + String(caught?.cause?.message || ''), + /-boom$/, + `expected ${handlerKey} callback throw to be preserved as cause` + ); + + const trackedCleared = await waitFor(() => getTrackedSubprocessCount() === 0, 5000); + assert.equal(trackedCleared, true, `expected tracked subprocess cleanup after ${handlerKey} failure`); +}; + +await assertCallbackFailure({ + script: 'process.stdout.write("chunk\\n"); setInterval(() => {}, 1000);', + stdio: ['ignore', resolveSilentStdio('pipe'), 'ignore'], + handlerKey: 'onStdout', + expectedStream: 'stdout' +}); + +await assertCallbackFailure({ + script: 'process.stderr.write("chunk\\n"); setInterval(() => {}, 1000);', + stdio: ['ignore', 'ignore', resolveSilentStdio('pipe')], + handlerKey: 'onStderr', + expectedStream: 'stderr' +}); + +console.log('subprocess output callback guard test passed'); diff --git a/tests/shared/subprocess/piscina-cleanup-timeout.test.js b/tests/shared/subprocess/piscina-cleanup-timeout.test.js new file mode 100644 index 000000000..837020d37 --- /dev/null +++ b/tests/shared/subprocess/piscina-cleanup-timeout.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { destroyPiscinaPool, forceTerminatePiscinaThreads } from '../../../src/shared/piscina-cleanup.js'; + +const forcedOnlyWorkers = [ + { + terminateCalls: 0, + async terminate() { + this.terminateCalls += 1; + return 0; + } + }, + { + terminateCalls: 0, + async terminate() { + this.terminateCalls += 1; + return 0; + } + } +]; + +const forcedOnlySummary = await forceTerminatePiscinaThreads( + { threads: forcedOnlyWorkers }, + { label: 'test-force-only', terminateTimeoutMs: 100 } +); +assert.equal(forcedOnlySummary.attempted, 2, 'expected forced terminate attempts for each worker'); +assert.equal(forcedOnlySummary.terminated, 2, 'expected both workers to terminate cleanly'); +assert.equal(forcedOnlyWorkers[0].terminateCalls, 1, 'expected first worker terminate call'); +assert.equal(forcedOnlyWorkers[1].terminateCalls, 1, 'expected second worker terminate call'); + +const workers = [ + { + terminateCalls: 0, + async terminate() { + this.terminateCalls += 1; + return 0; + } + }, + { + terminateCalls: 0, + async terminate() { + this.terminateCalls += 1; + return 0; + } + } +]; +const neverResolves = new Promise(() => {}); +let destroyCalls = 0; +const pool = { + threads: workers, + async destroy() { + destroyCalls += 1; + return neverResolves; + } +}; + +const destroyResult = await destroyPiscinaPool(pool, { + label: 'test-timeout', + destroyTimeoutMs: 25, + terminateTimeoutMs: 100 +}); + +assert.equal(destroyCalls, 1, 'expected one destroy attempt'); +assert.equal(destroyResult.skipped, false, 'expected timeout destroy path to run'); +assert.equal(destroyResult.timedOut, true, 'expected destroy timeout to trigger hard-stop'); +assert.equal(destroyResult.forced, true, 'expected timeout path to force terminate workers'); +assert.equal(destroyResult.forcedSummary?.attempted, 2, 'expected forced termination summary to include both workers'); +assert.equal(workers[0].terminateCalls, 1, 'expected first pool worker terminate call'); +assert.equal(workers[1].terminateCalls, 1, 'expected second pool worker terminate call'); + +console.log('piscina cleanup timeout test passed'); diff --git a/tests/shared/subprocess/process-exit-cleanup.test.js b/tests/shared/subprocess/process-exit-cleanup.test.js new file mode 100644 index 000000000..b35c899cf --- /dev/null +++ b/tests/shared/subprocess/process-exit-cleanup.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createTrackedChildCleanupScript, + isAlive, + readTrackedPidFromStdout, + runInlineNodeScript, + waitFor +} from './tracked-cleanup-fixture.js'; + +const inlineScript = createTrackedChildCleanupScript({ + afterRegister: [ + "process.stdout.write(`TRACKED_PID=${child.pid}\\n`, () => process.exit(0));" + ] +}); + +const { closeResult, stdout, stderr } = await runInlineNodeScript(inlineScript); + +assert.equal( + closeResult.exitCode, + 0, + `expected helper process exit=0; signal=${closeResult.signal} stderr=${stderr || ''}` +); + +const trackedPid = readTrackedPidFromStdout(stdout); + +const childTerminated = await waitFor(() => !isAlive(trackedPid), 5000); +assert.equal(childTerminated, true, 'expected tracked child process to be terminated after process.exit cleanup'); + +console.log('tracked subprocess process-exit cleanup test passed'); diff --git a/tests/shared/subprocess/quoting.test.js b/tests/shared/subprocess/quoting.test.js new file mode 100644 index 000000000..9c1c113ea --- /dev/null +++ b/tests/shared/subprocess/quoting.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import os from 'node:os'; +import path from 'node:path'; +import fsPromises from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { startApiServer } from '../../helpers/api-server.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const cacheRoot = resolveTestCachePath(root, 'subprocess-quoting'); + +await fsPromises.rm(cacheRoot, { recursive: true, force: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +// Create a repo path containing spaces to catch quoting/arg-parsing bugs. +const repoParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats repo with spaces ')); +const repoPath = path.join(repoParent, 'sample repo'); +await fsPromises.cp(fixtureRoot, repoPath, { recursive: true }); + +const env = { + ...applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false + }), + PAIROFCLEATS_CACHE_ROOT: cacheRoot +}; + +const build = spawnSync( + process.execPath, + [path.join(root, 'build_index.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoPath], + { env, stdio: 'inherit' } +); +if (build.status !== 0) { + console.error('subprocess-quoting test failed: build_index failed'); + process.exit(1); +} + +let serverInfo = null; +let stopServer = null; +let requestJson = null; +try { + const started = await startApiServer({ + repoRoot: repoPath, + env + }); + serverInfo = started.serverInfo; + stopServer = started.stop; + requestJson = started.requestJson; + if (!serverInfo?.baseUrl) { + throw new Error('api-server did not report a baseUrl'); + } + + const health = await requestJson('GET', '/health', null, serverInfo); + if (!health.body?.ok) { + throw new Error('api-server /health failed'); + } + + const status = await requestJson('GET', '/status', null, serverInfo); + if (!status.body?.ok || !status.body?.status) { + throw new Error('api-server /status failed'); + } + + const search = await requestJson('POST', '/search', { + repoPath, + query: 'return', + mode: 'code', + top: 10 + }, serverInfo); + if (!search.body?.ok || !Array.isArray(search.body?.result?.code) || !search.body.result.code.length) { + throw new Error('api-server /search returned no results'); + } +} catch (err) { + console.error(err?.message || err); + process.exit(1); +} finally { + if (typeof stopServer === 'function') { + await stopServer(); + } +} + +console.log('subprocess-quoting: ok'); + diff --git a/tests/shared/subprocess/shell-metachar-quoting.test.js b/tests/shared/subprocess/shell-metachar-quoting.test.js index 026d103de..470996d89 100644 --- a/tests/shared/subprocess/shell-metachar-quoting.test.js +++ b/tests/shared/subprocess/shell-metachar-quoting.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSubprocess, spawnSubprocessSync } from '../../../src/shared/subprocess.js'; +import { spawnSubprocess, spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; const script = 'process.stdout.write("ok");'; diff --git a/tests/shared/subprocess/spawn-error-propagates.test.js b/tests/shared/subprocess/spawn-error-propagates.test.js index 0996eb8d8..d4ff3ee3b 100644 --- a/tests/shared/subprocess/spawn-error-propagates.test.js +++ b/tests/shared/subprocess/spawn-error-propagates.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; import { resolveSilentStdio } from '../../helpers/test-env.js'; try { diff --git a/tests/shared/subprocess/subprocess-quoting.test.js b/tests/shared/subprocess/subprocess-quoting.test.js deleted file mode 100644 index 2fc0d0fb4..000000000 --- a/tests/shared/subprocess/subprocess-quoting.test.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import readline from 'node:readline'; -import fsPromises from 'node:fs/promises'; -import { spawn, spawnSync } from 'node:child_process'; -import { attachSilentLogging } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const cacheRoot = resolveTestCachePath(root, 'subprocess-quoting'); -const serverPath = path.join(root, 'tools', 'api', 'server.js'); - -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -// Create a repo path containing spaces to catch quoting/arg-parsing bugs. -const repoParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pairofcleats repo with spaces ')); -const repoPath = path.join(repoParent, 'sample repo'); -await fsPromises.cp(fixtureRoot, repoPath, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; - -const build = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoPath], - { env, stdio: 'inherit' } -); -if (build.status !== 0) { - console.error('subprocess-quoting test failed: build_index failed'); - process.exit(1); -} - -const server = spawn( - process.execPath, - [serverPath, '--repo', repoPath, '--host', '127.0.0.1', '--port', '0', '--json'], - { env, stdio: ['ignore', 'pipe', 'pipe'] } -); -attachSilentLogging(server, 'api-server'); - -let stderr = ''; -server.stderr.on('data', (chunk) => { - stderr += chunk.toString(); -}); - -const rl = readline.createInterface({ input: server.stdout }); -const readStartup = () => new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('timeout waiting for api-server startup')), 15000); - rl.once('line', (line) => { - clearTimeout(timeout); - resolve(line); - }); -}); - -const requestJson = (baseUrl, method, pathname, body = null) => new Promise((resolve, reject) => { - const payload = body ? JSON.stringify(body) : null; - const req = http.request(baseUrl + pathname, { - method, - headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {} - }, (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk.toString(); - }); - res.on('end', () => { - try { - resolve({ status: res.statusCode || 0, body: JSON.parse(data || '{}') }); - } catch (err) { - reject(err); - } - }); - }); - req.on('error', reject); - if (payload) req.write(payload); - req.end(); -}); - -let serverInfo = null; -try { - const line = await readStartup(); - serverInfo = JSON.parse(line || '{}'); - if (!serverInfo?.baseUrl) { - throw new Error('api-server did not report a baseUrl'); - } - - const health = await requestJson(serverInfo.baseUrl, 'GET', '/health'); - if (!health.body?.ok) { - throw new Error('api-server /health failed'); - } - - const status = await requestJson(serverInfo.baseUrl, 'GET', '/status'); - if (!status.body?.ok || !status.body?.status) { - throw new Error('api-server /status failed'); - } - - const search = await requestJson(serverInfo.baseUrl, 'POST', '/search', { - repoPath, - query: 'return', - mode: 'code', - top: 10 - }); - if (!search.body?.ok || !Array.isArray(search.body?.result?.code) || !search.body.result.code.length) { - throw new Error('api-server /search returned no results'); - } -} catch (err) { - console.error(err?.message || err); - if (stderr.trim()) { - console.error(stderr.trim()); - } - server.kill('SIGKILL'); - process.exit(1); -} finally { - try { - server.kill('SIGKILL'); - } catch (e) { - // ignore - } -} - -console.log('subprocess-quoting: ok'); - diff --git a/tests/shared/subprocess/sync-command-timeout-opt-out.test.js b/tests/shared/subprocess/sync-command-timeout-opt-out.test.js new file mode 100644 index 000000000..206817a91 --- /dev/null +++ b/tests/shared/subprocess/sync-command-timeout-opt-out.test.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runSyncCommandWithTimeout } from '../../../src/shared/subprocess/sync-command.js'; + +const startedAt = Date.now(); +const result = runSyncCommandWithTimeout( + process.execPath, + ['-e', 'setTimeout(() => process.exit(0), 5200);'], + { + timeoutMs: null, + stdio: 'ignore', + encoding: 'utf8' + } +); +const elapsedMs = Date.now() - startedAt; + +assert.equal(result.status, 0, 'expected sync command to complete normally when timeout is disabled'); +assert.equal(result.error, undefined, 'expected no timeout/spawn error when timeout is disabled'); +assert.equal(elapsedMs >= 5000, true, `expected disabled timeout to allow long command to finish (elapsed=${elapsedMs}ms)`); + +console.log('sync command timeout opt-out test passed'); diff --git a/tests/shared/subprocess/sync-command-timeout.test.js b/tests/shared/subprocess/sync-command-timeout.test.js new file mode 100644 index 000000000..d6de2e81a --- /dev/null +++ b/tests/shared/subprocess/sync-command-timeout.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + isSyncCommandTimedOut, + runSyncCommandWithTimeout, + toSyncCommandExitCode +} from '../../../src/shared/subprocess/sync-command.js'; + +const startedAt = Date.now(); +const result = runSyncCommandWithTimeout( + process.execPath, + ['-e', 'setInterval(() => {}, 1000);'], + { + timeoutMs: 100, + stdio: 'ignore', + encoding: 'utf8' + } +); +const elapsedMs = Date.now() - startedAt; + +assert.equal(isSyncCommandTimedOut(result), true, 'expected sync command timeout classification'); +assert.equal(toSyncCommandExitCode(result), null, 'expected null exit code for timed out sync command'); +assert.equal(elapsedMs >= 90, true, `expected timeout guard to wait for configured timeout (elapsed=${elapsedMs}ms)`); +assert.equal(elapsedMs < 2000, true, `expected timeout guard to avoid long hangs (elapsed=${elapsedMs}ms)`); + +console.log('sync command timeout guard test passed'); diff --git a/tests/shared/subprocess/sync-timeout-kills-child-tree.test.js b/tests/shared/subprocess/sync-timeout-kills-child-tree.test.js new file mode 100644 index 000000000..1fa4f7eb5 --- /dev/null +++ b/tests/shared/subprocess/sync-timeout-kills-child-tree.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { killProcessTree } from '../../../src/shared/kill-tree.js'; +import { SubprocessTimeoutError, spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; + +const sleep = (ms) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +const isPidAlive = (pid) => { + const parsed = Number(pid); + if (!Number.isFinite(parsed) || parsed <= 0) return false; + try { + process.kill(parsed, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +}; + +const waitForFile = async (filePath, timeoutMs = 2000) => { + const startedAt = Date.now(); + while ((Date.now() - startedAt) < timeoutMs) { + try { + return await fs.readFile(filePath, 'utf8'); + } catch {} + await sleep(50); + } + return null; +}; + +const waitForPidExit = async (pid, timeoutMs = 2000) => { + const startedAt = Date.now(); + while ((Date.now() - startedAt) < timeoutMs) { + if (!isPidAlive(pid)) return true; + await sleep(50); + } + return !isPidAlive(pid); +}; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-sync-timeout-kill-')); +const childPidFile = path.join(tempRoot, 'child.pid'); +let spawnedChildPid = null; + +try { + const script = [ + 'const fs = require("node:fs");', + 'const { spawn } = require("node:child_process");', + 'const pidFile = process.argv[1];', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 60000);"], { stdio: "ignore" });', + 'fs.writeFileSync(pidFile, String(child.pid));', + 'setInterval(() => {}, 60000);' + ].join(' '); + + assert.throws( + () => spawnSubprocessSync(process.execPath, ['-e', script, childPidFile], { + stdio: ['ignore', 'ignore', 'ignore'], + captureStdout: false, + captureStderr: false, + timeoutMs: 2000 + }), + (error) => error instanceof SubprocessTimeoutError, + 'expected sync subprocess timeout error' + ); + + const pidText = await waitForFile(childPidFile); + assert.ok(pidText, 'expected parent script to persist child pid before timeout'); + spawnedChildPid = Number.parseInt(String(pidText).trim(), 10); + assert.ok(Number.isFinite(spawnedChildPid) && spawnedChildPid > 0, 'expected valid spawned child pid'); + const reaped = await waitForPidExit(spawnedChildPid, 2500); + assert.equal(reaped, true, 'expected timed-out sync subprocess to reap spawned child tree'); + + console.log('sync subprocess timeout child-tree reap test passed'); +} finally { + if (Number.isFinite(spawnedChildPid) && isPidAlive(spawnedChildPid)) { + await killProcessTree(spawnedChildPid, { + killTree: true, + graceMs: 0, + awaitGrace: true + }); + } + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/shared/subprocess/timeout-bounded-reap-referenced.test.js b/tests/shared/subprocess/timeout-bounded-reap-referenced.test.js new file mode 100644 index 000000000..feb0fc44d --- /dev/null +++ b/tests/shared/subprocess/timeout-bounded-reap-referenced.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const script = ` +import { spawnSubprocess } from './src/shared/subprocess/runner.js'; +const startedAt = Date.now(); +try { + await spawnSubprocess( + process.execPath, + ['-e', 'setInterval(() => {}, 1000);'], + { + timeoutMs: 25, + killGraceMs: 2500, + timeoutAbortReapWaitMs: 200, + stdio: ['ignore', 'ignore', 'ignore'], + captureStdout: false, + captureStderr: false + } + ); + process.stdout.write(JSON.stringify({ ok: false, reason: 'expected timeout' })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: error?.name === 'SubprocessTimeoutError' || error?.code === 'SUBPROCESS_TIMEOUT', + durationMs: Date.now() - startedAt + })); +} +`; + +const run = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: process.cwd(), + encoding: 'utf8' +}); + +assert.equal( + run.status, + 0, + `expected timeout helper script to settle without top-level-await exit regression: ${run.stderr || ''}` +); + +let payload = null; +try { + payload = JSON.parse(String(run.stdout || '{}')); +} catch { + assert.fail(`expected json payload, got: ${String(run.stdout || '').trim()}`); +} + +assert.equal(payload?.ok, true, `expected timeout path to reject deterministically: ${run.stdout || ''}`); +assert.ok( + Number.isFinite(Number(payload?.durationMs)) && Number(payload.durationMs) >= 200, + `expected bounded reap wait to remain effective, got: ${String(payload?.durationMs)}` +); + +console.log('subprocess timeout bounded reap referenced test passed'); diff --git a/tests/shared/subprocess/timeout-kill-grace-unref.test.js b/tests/shared/subprocess/timeout-kill-grace-unref.test.js index 544f3c902..27ce407d8 100644 --- a/tests/shared/subprocess/timeout-kill-grace-unref.test.js +++ b/tests/shared/subprocess/timeout-kill-grace-unref.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; const script = ` -import { spawnSubprocess } from './src/shared/subprocess.js'; +import { spawnSubprocess } from './src/shared/subprocess/runner.js'; const startedAt = Date.now(); try { await spawnSubprocess( @@ -12,6 +12,7 @@ try { { timeoutMs: 75, killGraceMs: 2500, + timeoutAbortReapWaitMs: 200, stdio: ['ignore', 'ignore', 'ignore'], captureStdout: false, captureStderr: false @@ -39,6 +40,7 @@ try { const innerDurationMs = Number(payload.durationMs); assert.ok(Number.isFinite(innerDurationMs), `expected numeric inner duration, got: ${String(payload.durationMs)}`); +assert.ok(innerDurationMs >= 200, `expected timeout path to await bounded reap before reject; got ${innerDurationMs}ms`); assert.ok(innerDurationMs < 2000, `expected timeout path to return before grace wait; got ${innerDurationMs}ms`); assert.ok(wallClockMs < 3000, `expected process to exit without waiting full grace timer; got ${wallClockMs}ms`); diff --git a/tests/shared/subprocess/timeout-kills-child.test.js b/tests/shared/subprocess/timeout-kills-child.test.js index f074f4b2e..65f2dfdf7 100644 --- a/tests/shared/subprocess/timeout-kills-child.test.js +++ b/tests/shared/subprocess/timeout-kills-child.test.js @@ -1,10 +1,20 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; import { resolveSilentStdio } from '../../helpers/test-env.js'; const args = ['-e', 'setInterval(() => {}, 1000)']; +const waitFor = async (predicate, timeoutMs = 5000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return predicate(); +}; + let pid = null; try { await spawnSubprocess(process.execPath, args, { @@ -29,4 +39,7 @@ if (pid && process.platform !== 'win32') { assert.equal(alive, false, 'expected subprocess to be killed'); } +const trackedCleared = await waitFor(() => getTrackedSubprocessCount() === 0, 5000); +assert.equal(trackedCleared, true, 'expected timeout subprocess registration to be cleared'); + console.log('subprocess timeout kill test passed'); diff --git a/tests/shared/subprocess/tracked-cleanup-fixture.js b/tests/shared/subprocess/tracked-cleanup-fixture.js new file mode 100644 index 000000000..926259ba3 --- /dev/null +++ b/tests/shared/subprocess/tracked-cleanup-fixture.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; + +export const waitFor = async (predicate, timeoutMs = 5000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return predicate(); +}; + +export const isAlive = (pid) => { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +export const createTrackedChildCleanupScript = ({ beforeSpawn = [], afterRegister = [] } = {}) => [ + "import { spawn } from 'node:child_process';", + "import { registerChildProcessForCleanup } from './src/shared/subprocess/tracking.js';", + ...beforeSpawn, + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {", + " stdio: 'ignore',", + " detached: process.platform !== 'win32'", + '});', + 'registerChildProcessForCleanup(child, {', + ' killTree: true,', + " detached: process.platform !== 'win32'", + '});', + ...afterRegister +].join('\n'); + +export const runInlineNodeScript = async (inlineScript) => { + const runner = spawn(process.execPath, ['-e', inlineScript], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + runner.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + runner.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + const closeResult = await new Promise((resolve, reject) => { + runner.on('error', reject); + runner.on('close', (exitCode, signal) => resolve({ exitCode, signal })); + }); + + return { closeResult, stdout, stderr }; +}; + +export const readTrackedPidFromStdout = (stdout) => { + const pidMatch = stdout.match(/TRACKED_PID=(\d+)/); + assert.ok(pidMatch, `expected TRACKED_PID in stdout, got: ${stdout || ''}`); + const trackedPid = Number(pidMatch[1]); + assert.ok(Number.isFinite(trackedPid) && trackedPid > 0, 'expected tracked child pid from helper process'); + return trackedPid; +}; diff --git a/tests/shared/subprocess/tracked-event-ledger.test.js b/tests/shared/subprocess/tracked-event-ledger.test.js new file mode 100644 index 000000000..5d07a1518 --- /dev/null +++ b/tests/shared/subprocess/tracked-event-ledger.test.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + getTrackedSubprocessCount, + registerChildProcessForCleanup, + resetTrackedSubprocessEvents, + snapshotTrackedSubprocessEvents, + terminateTrackedSubprocesses +} from '../../../src/shared/subprocess/tracking.js'; + +const ownershipId = `event-ledger-${process.pid}-${Date.now()}`; + +const waitFor = async (predicate, timeoutMs = 5000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return predicate(); +}; + +try { + resetTrackedSubprocessEvents(); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + stdio: 'ignore', + detached: process.platform !== 'win32' + }); + assert.ok(Number.isFinite(child.pid) && child.pid > 0, 'expected child pid for event-ledger test'); + + const unregister = registerChildProcessForCleanup(child, { + killTree: true, + detached: process.platform !== 'win32', + scope: ownershipId, + ownershipId, + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000);'], + name: 'tracked-event-ledger' + }); + + const tracked = await waitFor(() => getTrackedSubprocessCount(ownershipId) > 0, 5000); + assert.equal(tracked, true, 'expected tracked subprocess registration'); + + const spawnedSnapshot = snapshotTrackedSubprocessEvents({ ownershipId, limit: 32 }); + assert.ok( + spawnedSnapshot.events.some((event) => event.kind === 'process_spawned' && event.pid === child.pid), + 'expected process_spawned event in tracked subprocess ledger' + ); + + for (let index = 0; index < 130; index += 1) { + const fakePid = 900000 + index; + const fakeChild = { + pid: fakePid, + once: () => {}, + off: () => {} + }; + const unregisterFake = registerChildProcessForCleanup(fakeChild, { + scope: ownershipId, + ownershipId, + command: 'fake-child', + args: [], + name: 'tracked-event-ledger-fake' + }); + unregisterFake(); + } + const highLimitSnapshot = snapshotTrackedSubprocessEvents({ ownershipId, limit: 256 }); + assert.equal( + highLimitSnapshot.total >= 260, + true, + 'expected tracked event ledger to retain more than default 64 events' + ); + + const summary = await terminateTrackedSubprocesses({ + reason: 'tracked-event-ledger-test', + force: true, + ownershipId + }); + assert.equal(summary.failures, 0, 'expected tracked subprocess termination without failures'); + assert.equal(getTrackedSubprocessCount(ownershipId), 0, 'expected tracked scope to be empty after cleanup'); + + const postSnapshot = snapshotTrackedSubprocessEvents({ ownershipId, limit: 64 }); + assert.ok( + postSnapshot.events.some((event) => event.kind === 'process_untracked' && event.reason === 'terminate'), + 'expected process_untracked(terminate) event for ownership scope' + ); + assert.ok( + postSnapshot.events.some((event) => event.kind === 'process_reaped' && event.reason === 'tracked-event-ledger-test'), + 'expected process_reaped event with termination reason' + ); + + unregister(); + console.log('tracked subprocess event ledger test passed'); +} finally { + await terminateTrackedSubprocesses({ + reason: 'tracked-event-ledger-finally', + force: true, + ownershipId + }); + resetTrackedSubprocessEvents(); +} diff --git a/tests/shared/subprocess/tracked-leak-fails-process.test.js b/tests/shared/subprocess/tracked-leak-fails-process.test.js new file mode 100644 index 000000000..637a8b6de --- /dev/null +++ b/tests/shared/subprocess/tracked-leak-fails-process.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { killProcessTree } from '../../helpers/kill-tree.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', `tracked-leak-fails-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +if (process.platform === 'win32') { + console.log('skipping tracked leak fail-on-cleanup test on Windows'); + await fs.rm(tempRoot, { recursive: true, force: true }); + process.exit(0); +} + +const pidFile = path.join(tempRoot, 'leaked-child.pid'); +const scriptPath = path.join(tempRoot, 'spawn-leak.mjs'); +const subprocessTrackingModuleHref = pathToFileURL( + path.join(root, 'src', 'shared', 'subprocess', 'tracking.js') +).href; +const scriptBody = [ + "import fs from 'node:fs';", + "import { spawn } from 'node:child_process';", + `import { registerChildProcessForCleanup } from '${subprocessTrackingModuleHref}';`, + 'const pidFile = process.argv[2];', + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000);'], {", + " stdio: 'ignore',", + " detached: process.platform !== 'win32'", + '});', + 'registerChildProcessForCleanup(child, {', + " killTree: true,", + " killSignal: 'NOT_A_SIGNAL',", + " detached: process.platform !== 'win32',", + " name: 'tracked-leak-fixture-child'", + "});", + "try { fs.writeFileSync(pidFile, String(child.pid || '')); } catch {}", + 'setTimeout(() => process.exit(0), 30);' +].join('\n'); +await fs.writeFile(scriptPath, scriptBody, 'utf8'); + +let leakedPid = null; +try { + const helperImport = pathToFileURL(path.join(root, 'tests', 'helpers', 'test-env.js')).href; + const inheritedNodeOptions = String(process.env.NODE_OPTIONS || '').trim(); + const mergedNodeOptions = inheritedNodeOptions + ? `${inheritedNodeOptions} --import ${helperImport}` + : `--import ${helperImport}`; + const result = spawnSync( + process.execPath, + [scriptPath, pidFile], + { + cwd: root, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: mergedNodeOptions + } + } + ); + assert.equal(result.status, 1, `expected leak fixture process to fail (status=${result.status ?? 'null'})`); + const stderr = String(result.stderr || ''); + assert.equal( + stderr.includes('[test-cleanup][leak') || stderr.includes('[test-cleanup][leak-sync]'), + true, + 'expected leak fixture process to emit test-cleanup leak marker' + ); +} finally { + try { + leakedPid = Number(String(await fs.readFile(pidFile, 'utf8')).trim()); + } catch {} + if (Number.isFinite(leakedPid) && leakedPid > 0) { + await killProcessTree(leakedPid, { graceMs: 0 }); + } + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('tracked subprocess leak fail-on-cleanup test passed'); diff --git a/tests/shared/subprocess/tracked-shutdown-cleanup.test.js b/tests/shared/subprocess/tracked-shutdown-cleanup.test.js index a9a0731bd..d264b5596 100644 --- a/tests/shared/subprocess/tracked-shutdown-cleanup.test.js +++ b/tests/shared/subprocess/tracked-shutdown-cleanup.test.js @@ -5,7 +5,7 @@ import { getTrackedSubprocessCount, registerChildProcessForCleanup, terminateTrackedSubprocesses -} from '../../../src/shared/subprocess.js'; +} from '../../../src/shared/subprocess/tracking.js'; const isAlive = (pid) => { if (!Number.isFinite(pid) || pid <= 0) return false; diff --git a/tests/shared/subprocess/tracked-shutdown-signal-cleanup.test.js b/tests/shared/subprocess/tracked-shutdown-signal-cleanup.test.js index 8f1b99d4f..51e5eee4d 100644 --- a/tests/shared/subprocess/tracked-shutdown-signal-cleanup.test.js +++ b/tests/shared/subprocess/tracked-shutdown-signal-cleanup.test.js @@ -1,65 +1,23 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (predicate()) return true; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - return predicate(); -}; - -const isAlive = (pid) => { - if (!Number.isFinite(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -const inlineScript = [ - "import { spawn } from 'node:child_process';", - "import { registerChildProcessForCleanup } from './src/shared/subprocess.js';", - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {", - " stdio: 'ignore',", - " detached: process.platform !== 'win32'", - '});', - "registerChildProcessForCleanup(child, {", - " killTree: true,", - " detached: process.platform !== 'win32'", - '});', - "process.stdout.write(`TRACKED_PID=${child.pid}\\n`);", - "process.emit('SIGTERM', 'SIGTERM');", - 'setInterval(() => {}, 1000);' -].join('\n'); - -const runner = spawn(process.execPath, ['-e', inlineScript], { - cwd: process.cwd(), - stdio: ['ignore', 'pipe', 'pipe'] -}); - -let stdout = ''; -let stderr = ''; -runner.stdout.on('data', (chunk) => { - stdout += String(chunk); -}); -runner.stderr.on('data', (chunk) => { - stderr += String(chunk); -}); - -const closeResult = await new Promise((resolve, reject) => { - runner.on('error', reject); - runner.on('close', (exitCode, signal) => resolve({ exitCode, signal })); +import { + createTrackedChildCleanupScript, + isAlive, + readTrackedPidFromStdout, + runInlineNodeScript, + waitFor +} from './tracked-cleanup-fixture.js'; + +const inlineScript = createTrackedChildCleanupScript({ + afterRegister: [ + "process.stdout.write(`TRACKED_PID=${child.pid}\\n`);", + "process.emit('SIGTERM', 'SIGTERM');", + 'setInterval(() => {}, 1000);' + ] }); -const pidMatch = stdout.match(/TRACKED_PID=(\d+)/); -assert.ok(pidMatch, `expected TRACKED_PID in stdout, got: ${stdout || ''}`); -const trackedPid = Number(pidMatch[1]); -assert.ok(Number.isFinite(trackedPid) && trackedPid > 0, 'expected tracked child pid from helper process'); +const { closeResult, stdout, stderr } = await runInlineNodeScript(inlineScript); +const trackedPid = readTrackedPidFromStdout(stdout); const terminatedBySignal = closeResult.signal === 'SIGTERM'; const terminatedByCode = closeResult.exitCode === 143; diff --git a/tests/shared/subprocess/tracked-shutdown-signal-respects-custom-handler.test.js b/tests/shared/subprocess/tracked-shutdown-signal-respects-custom-handler.test.js index 6694890cf..e9324e0b0 100644 --- a/tests/shared/subprocess/tracked-shutdown-signal-respects-custom-handler.test.js +++ b/tests/shared/subprocess/tracked-shutdown-signal-respects-custom-handler.test.js @@ -1,72 +1,32 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; - -const waitFor = async (predicate, timeoutMs = 5000) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (predicate()) return true; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - return predicate(); -}; - -const isAlive = (pid) => { - if (!Number.isFinite(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -const inlineScript = [ - "import { spawn } from 'node:child_process';", - "import { registerChildProcessForCleanup } from './src/shared/subprocess.js';", - "process.on('SIGTERM', () => {", - " process.stdout.write('CUSTOM_HANDLER\\n');", - " setTimeout(() => {", - " process.stdout.write('CUSTOM_DONE\\n');", - ' process.exit(0);', - ' }, 100);', - '});', - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {", - " stdio: 'ignore',", - " detached: process.platform !== 'win32'", - '});', - "registerChildProcessForCleanup(child, {", - " killTree: true,", - " detached: process.platform !== 'win32'", - '});', - "process.stdout.write(`TRACKED_PID=${child.pid}\\n`);", - "process.emit('SIGTERM', 'SIGTERM');", - 'setInterval(() => {}, 1000);' -].join('\n'); - -const runner = spawn(process.execPath, ['-e', inlineScript], { - cwd: process.cwd(), - stdio: ['ignore', 'pipe', 'pipe'] -}); - -let stdout = ''; -let stderr = ''; -runner.stdout.on('data', (chunk) => { - stdout += String(chunk); -}); -runner.stderr.on('data', (chunk) => { - stderr += String(chunk); -}); - -const closeResult = await new Promise((resolve, reject) => { - runner.on('error', reject); - runner.on('close', (exitCode, signal) => resolve({ exitCode, signal })); +import { + createTrackedChildCleanupScript, + isAlive, + readTrackedPidFromStdout, + runInlineNodeScript, + waitFor +} from './tracked-cleanup-fixture.js'; + +const inlineScript = createTrackedChildCleanupScript({ + beforeSpawn: [ + "process.on('SIGTERM', () => {", + " process.stdout.write('CUSTOM_HANDLER\\n');", + " setTimeout(() => {", + " process.stdout.write('CUSTOM_DONE\\n');", + ' process.exit(0);', + ' }, 100);', + '});' + ], + afterRegister: [ + "process.stdout.write(`TRACKED_PID=${child.pid}\\n`);", + "process.emit('SIGTERM', 'SIGTERM');", + 'setInterval(() => {}, 1000);' + ] }); -const pidMatch = stdout.match(/TRACKED_PID=(\d+)/); -assert.ok(pidMatch, `expected TRACKED_PID in stdout, got: ${stdout || ''}`); -const trackedPid = Number(pidMatch[1]); -assert.ok(Number.isFinite(trackedPid) && trackedPid > 0, 'expected tracked child pid from helper process'); +const { closeResult, stdout, stderr } = await runInlineNodeScript(inlineScript); +const trackedPid = readTrackedPidFromStdout(stdout); assert.equal(stdout.includes('CUSTOM_HANDLER'), true, 'expected custom SIGTERM handler to run'); assert.equal(stdout.includes('CUSTOM_DONE'), true, 'expected custom SIGTERM handler completion marker'); diff --git a/tests/shared/subprocess/tracked-signal-scope-binding.test.js b/tests/shared/subprocess/tracked-signal-scope-binding.test.js index e6879160c..f807db7fa 100644 --- a/tests/shared/subprocess/tracked-signal-scope-binding.test.js +++ b/tests/shared/subprocess/tracked-signal-scope-binding.test.js @@ -1,11 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { getTrackedSubprocessCount, - spawnSubprocess, + registerChildProcessForCleanup, terminateTrackedSubprocesses, withTrackedSubprocessSignalScope -} from '../../../src/shared/subprocess.js'; +} from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; const waitFor = async (predicate, timeoutMs = 5000) => { const startedAt = Date.now(); @@ -28,11 +30,23 @@ const pending = withTrackedSubprocessSignalScope(controller.signal, scope, () => detached: process.platform !== 'win32' } )); +let unregisterRawChild = () => {}; +await withTrackedSubprocessSignalScope(controller.signal, scope, () => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000);'], { + stdio: 'ignore', + detached: process.platform !== 'win32' + }); + unregisterRawChild = registerChildProcessForCleanup(child, { + killTree: true, + detached: process.platform !== 'win32' + }); + return child; +}); const tracked = await waitFor(() => getTrackedSubprocessCount(scope) > 0, 5000); assert.equal(tracked, true, 'expected subprocess to inherit tracked scope from bound signal'); const scopedTrackedCount = getTrackedSubprocessCount(scope); -assert.ok(scopedTrackedCount > 0, 'expected at least one subprocess in bound scope'); +assert.ok(scopedTrackedCount >= 2, 'expected both shared-runner and raw-registered subprocesses in bound scope'); const scopedSummary = await terminateTrackedSubprocesses({ reason: 'signal-scope-test', @@ -55,6 +69,7 @@ assert.ok( ); await pending; +unregisterRawChild(); assert.equal(getTrackedSubprocessCount(scope), 0, 'expected scope registry to be empty after terminate'); console.log('tracked subprocess signal scope binding test passed'); diff --git a/tests/shared/subprocess/windows-cmd.test.js b/tests/shared/subprocess/windows-cmd.test.js new file mode 100644 index 000000000..81354a7a6 --- /dev/null +++ b/tests/shared/subprocess/windows-cmd.test.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + quoteWindowsCmdArg, + resolveWindowsCmdShimPath, + resolveWindowsCmdInvocation +} from '../../../src/shared/subprocess/windows-cmd.js'; + +assert.match(quoteWindowsCmdArg('%TEMP%'), /\^%TEMP\^%/, 'expected percent expansion to be escaped'); +assert.match(quoteWindowsCmdArg('!BANG!'), /\^!BANG\^!/, 'expected delayed expansion marker to be escaped'); +assert.match(quoteWindowsCmdArg('value^caret'), /\^\^/, 'expected carets to be doubled'); + +assert.throws( + () => resolveWindowsCmdInvocation('tool.cmd', ['alpha beta', '%TEMP%', '!BANG!', '^caret']), + (err) => err?.code === 'ERR_WINDOWS_CMD_NOT_FOUND', + 'expected unresolved Windows wrapper commands to fail closed' +); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-windows-cmd-')); +try { + const scriptPath = path.join(tempRoot, 'echo-arg.js'); + const wrapperPath = path.join(tempRoot, 'echo-arg.cmd'); + const bareWrapperPath = path.join(tempRoot, 'npm.cmd'); + const badWrapperPath = path.join(tempRoot, 'opaque.cmd'); + const outputPath = path.join(tempRoot, 'arg.txt'); + const literalArg = '%TEMP%&literal!bang^caret'; + await fs.writeFile( + scriptPath, + `#!/usr/bin/env node\nconst fs = require('node:fs');\nfs.writeFileSync(${JSON.stringify(outputPath)}, process.argv[2], 'utf8');\n`, + 'utf8' + ); + await fs.writeFile( + wrapperPath, + `@echo off\r\nnode "%~dp0\\echo-arg.js" %*\r\n`, + 'utf8' + ); + await fs.writeFile( + bareWrapperPath, + `@echo off\r\nnode "%~dp0\\echo-arg.js" %*\r\n`, + 'utf8' + ); + await fs.writeFile( + badWrapperPath, + '@echo off\r\necho unsupported wrapper\r\n', + 'utf8' + ); + const fixedWrapperPath = path.join(tempRoot, 'fixed.cmd'); + await fs.writeFile( + fixedWrapperPath, + '@echo off\r\nnode "%~dp0\\ok.js" --mode fixed\r\n', + 'utf8' + ); + await fs.writeFile( + path.join(tempRoot, 'ok.js'), + '#!/usr/bin/env node\nprocess.exit(0);\n', + 'utf8' + ); + const runInvocation = resolveWindowsCmdInvocation(wrapperPath, [literalArg]); + assert.notEqual( + path.basename(runInvocation.command).toLowerCase(), + 'cmd.exe', + 'expected parseable wrappers to bypass cmd.exe fallback' + ); + assert.ok(runInvocation.args.includes(literalArg), 'expected resolved wrapper args to preserve literal argv'); + const shimEnv = { + ...process.env, + PATH: tempRoot, + Path: tempRoot + }; + assert.equal( + resolveWindowsCmdShimPath('npm', shimEnv), + bareWrapperPath, + 'expected bare npm to resolve through PATH-backed npm.cmd shim' + ); + const bareInvocation = resolveWindowsCmdInvocation('npm', [literalArg], shimEnv); + assert.notEqual( + path.basename(String(bareInvocation.command || '')).toLowerCase(), + 'npm', + 'expected bare npm shim resolution to avoid spawning the unresolved bare command token' + ); + assert.ok( + bareInvocation.args.includes(literalArg), + 'expected bare npm shim resolution to preserve literal argv' + ); + const opaqueInvocation = resolveWindowsCmdInvocation(badWrapperPath, [literalArg]); + assert.equal( + path.basename(String(opaqueInvocation.command || '')).toLowerCase(), + 'cmd.exe', + 'expected opaque wrappers to fall back to an explicit cmd.exe invocation' + ); + assert.equal( + opaqueInvocation.args.slice(0, 3).join(' '), + '/d /s /c', + 'expected opaque wrapper fallback to use bounded cmd.exe execution flags' + ); + assert.match( + String(opaqueInvocation.args[3] || ''), + /opaque\.cmd/i, + 'expected opaque wrapper fallback payload to target the wrapper path' + ); + const fixedInvocation = resolveWindowsCmdInvocation(fixedWrapperPath, ['--ignored']); + assert.match(fixedInvocation.args[0] || '', /ok\.js$/i, 'expected fixed-arg wrapper to resolve its script payload'); + assert.deepEqual( + fixedInvocation.args.slice(1), + ['--mode', 'fixed'], + 'expected fixed-arg wrappers without %* to keep only their authored argv' + ); + assert.equal( + fixedInvocation.args.includes('--ignored'), + false, + 'expected fixed-arg wrappers without %* to avoid appending caller argv' + ); + if (process.platform === 'win32') { + const result = spawnSync(runInvocation.command, runInvocation.args, { + shell: false, + windowsHide: true, + encoding: 'utf8' + }); + assert.equal(result.status, 0, `expected wrapper invocation to succeed: ${result.stderr || result.stdout}`); + const captured = await fs.readFile(outputPath, 'utf8'); + assert.equal(captured, literalArg, 'expected wrapper invocation to preserve literal argument text'); + const bareResult = spawnSync(bareInvocation.command, bareInvocation.args, { + shell: false, + windowsHide: true, + encoding: 'utf8', + env: shimEnv + }); + assert.equal( + bareResult.status, + 0, + `expected bare npm shim invocation to succeed: ${bareResult.stderr || bareResult.stdout}` + ); + } +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('windows cmd invocation test passed'); diff --git a/tests/shared/toolchain-env-applies-gradle-daemon-policy.test.js b/tests/shared/toolchain-env-applies-gradle-daemon-policy.test.js new file mode 100644 index 000000000..d158c2be3 --- /dev/null +++ b/tests/shared/toolchain-env-applies-gradle-daemon-policy.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyToolchainDaemonPolicyEnv } from '../../src/shared/toolchain-env.js'; + +const empty = applyToolchainDaemonPolicyEnv({}); +assert.equal(empty.ORG_GRADLE_DAEMON, 'false'); +assert.equal(empty.GRADLE_OPTS, '-Dorg.gradle.daemon=false'); + +const merged = applyToolchainDaemonPolicyEnv({ + ORG_GRADLE_DAEMON: 'true', + GRADLE_OPTS: '-Xmx2g' +}); +assert.equal(merged.ORG_GRADLE_DAEMON, 'false'); +assert.equal(merged.GRADLE_OPTS, '-Xmx2g -Dorg.gradle.daemon=false'); + +const replaced = applyToolchainDaemonPolicyEnv({ + GRADLE_OPTS: '-Xmx2g -Dorg.gradle.daemon=true -Dfile.encoding=UTF-8' +}); +assert.equal( + replaced.GRADLE_OPTS, + '-Xmx2g -Dorg.gradle.daemon=false -Dfile.encoding=UTF-8' +); + +const withCacheRoot = applyToolchainDaemonPolicyEnv({ + PAIROFCLEATS_CACHE_ROOT: path.join('tmp', 'cache-root') +}); +assert.equal( + withCacheRoot.ERL_CRASH_DUMP, + path.join('tmp', 'cache-root', 'erl_crash.dump'), + 'expected BEAM crash dumps to route into the test cache root when available' +); + +const withCwdFallback = applyToolchainDaemonPolicyEnv({}, { cwd: path.join('tmp', 'workspace-root') }); +assert.equal( + withCwdFallback.ERL_CRASH_DUMP, + path.join('tmp', 'workspace-root', 'erl_crash.dump'), + 'expected cwd fallback when no cache root is available' +); + +const explicitCrashDump = applyToolchainDaemonPolicyEnv({ + ERL_CRASH_DUMP: path.join('tmp', 'custom', 'beam.dump') +}, { cwd: path.join('tmp', 'workspace-root') }); +assert.equal( + explicitCrashDump.ERL_CRASH_DUMP, + path.join('tmp', 'custom', 'beam.dump'), + 'expected explicit ERL_CRASH_DUMP to be preserved' +); + +console.log('toolchain daemon policy env test passed'); diff --git a/tests/shared/truncation/truncation.test.js b/tests/shared/truncation/contract.test.js similarity index 100% rename from tests/shared/truncation/truncation.test.js rename to tests/shared/truncation/contract.test.js diff --git a/tests/shared/type-normalization.test.js b/tests/shared/type-normalization.test.js new file mode 100644 index 000000000..9dfc1364c --- /dev/null +++ b/tests/shared/type-normalization.test.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { canonicalizeTypeText } from '../../src/shared/type-normalization.js'; + +const pythonOptional = canonicalizeTypeText('typing.Optional[ builtins.str ]', { languageId: 'python' }); +assert.equal(pythonOptional.displayText, 'str | None', 'expected python Optional alias normalization'); +assert.equal(pythonOptional.originalText, 'typing.Optional[ builtins.str ]'); + +const tsNestedGeneric = canonicalizeTypeText('Promise < Result < Foo | undefined > >', { languageId: 'typescript' }); +assert.equal(tsNestedGeneric.displayText, 'Promise>', 'expected nested generic spacing normalization'); + +const rustPrefixes = canonicalizeTypeText('std::vec::Vec', { languageId: 'rust' }); +assert.equal(rustPrefixes.displayText, 'vec::Vec', 'expected rust module prefix stripping'); + +const csharpGlobal = canonicalizeTypeText('global::System.Collections.Generic.List', { languageId: 'csharp' }); +assert.equal( + csharpGlobal.displayText, + 'System.Collections.Generic.List', + 'expected csharp global qualifier stripping' +); + +console.log('shared type normalization test passed'); diff --git a/tests/smoke/api-core-health.test.js b/tests/smoke/api-core-health.test.js new file mode 100644 index 000000000..e9c9db529 --- /dev/null +++ b/tests/smoke/api-core-health.test.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; + +import { buildIndex, search, status } from '../../src/integrations/core/index.js'; +import { createApiRouter } from '../../tools/api/router.js'; +import { applyTestEnv } from '../helpers/test-env.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { cleanup, root } from './smoke-utils.js'; + +const cacheRoots = [ + resolveTestCachePath(root, 'core-api'), + resolveTestCachePath(root, 'api-router') +]; + +const normalizePathForCompare = (value) => { + const resolved = path.resolve(String(value || '')); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +}; + +const writeCoreFixture = async (repoRoot) => { + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'index.js'), + 'export function greet(name = "world") { return `hello ${name}`; }\n', + 'utf8' + ); + await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# Core API smoke fixture\n\nsmall synthetic repo\n', + 'utf8' + ); +}; + +const runCoreSmoke = async () => { + const cacheRoot = resolveTestCachePath(root, 'core-api'); + const repoRoot = path.join(cacheRoot, 'repo'); + await fsPromises.rm(cacheRoot, { recursive: true, force: true }); + await fsPromises.mkdir(repoRoot, { recursive: true }); + await writeCoreFixture(repoRoot); + + const previousCacheRoot = process.env.PAIROFCLEATS_CACHE_ROOT; + const previousEmbeddings = process.env.PAIROFCLEATS_EMBEDDINGS; + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: false, + mode: 'off' + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + syncProcess: false, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } + }); + process.env.PAIROFCLEATS_CACHE_ROOT = env.PAIROFCLEATS_CACHE_ROOT; + process.env.PAIROFCLEATS_EMBEDDINGS = env.PAIROFCLEATS_EMBEDDINGS; + try { + await buildIndex(repoRoot, { + stage: 'stage1', + mode: 'code', + sqlite: false, + stubEmbeddings: true, + scmProvider: 'none', + log: () => {} + }); + + const searchPayload = await search(repoRoot, { query: 'greet', mode: 'code', json: true }); + assert.ok(Array.isArray(searchPayload.code) && searchPayload.code.length > 0); + + const statusPayload = await status(repoRoot); + assert.equal(normalizePathForCompare(statusPayload?.repo?.root), normalizePathForCompare(repoRoot)); + } finally { + if (previousCacheRoot == null) { + delete process.env.PAIROFCLEATS_CACHE_ROOT; + } else { + process.env.PAIROFCLEATS_CACHE_ROOT = previousCacheRoot; + } + if (previousEmbeddings == null) { + delete process.env.PAIROFCLEATS_EMBEDDINGS; + } else { + process.env.PAIROFCLEATS_EMBEDDINGS = previousEmbeddings; + } + } +}; + +const runRouterSmoke = async () => { + const tempRoot = resolveTestCachePath(root, 'api-router'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: tempRoot, + defaultOutput: 'json', + metricsRegistry: null + }); + + const server = http.createServer((req, res) => router.handleRequest(req, res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + try { + const response = await fetch(`http://127.0.0.1:${port}/missing`); + const payload = await response.json(); + assert.equal(payload.ok, false); + assert.ok(payload.hint); + } finally { + server.close(); + if (typeof router.close === 'function') router.close(); + } +}; + +let failure = null; +try { + await cleanup(cacheRoots); + await runCoreSmoke(); + await runRouterSmoke(); +} catch (error) { + console.error(error?.stack || error?.message || String(error)); + failure = error; +} +await cleanup(cacheRoots); + +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke api-core-health passed'); diff --git a/tests/smoke/baseline.test.js b/tests/smoke/baseline.test.js new file mode 100644 index 000000000..f28075081 --- /dev/null +++ b/tests/smoke/baseline.test.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { createCli } from '../../src/shared/cli.js'; +import { getDictionaryPaths, getDictConfig, getIndexDir, loadUserConfig, resolveSqlitePaths } from '../../tools/shared/dict-utils.js'; +import { normalizePostingsConfig } from '../../src/shared/postings-config.js'; +import { getVectorExtensionConfig, resolveVectorExtensionPath } from '../../tools/sqlite/vector-extension.js'; + +const argv = createCli({ + scriptName: 'verify', + options: { + 'require-index': { type: 'boolean', default: false }, + 'require-sqlite': { type: 'boolean', default: false }, + 'require-dicts': { type: 'boolean', default: false } + } +}).parse(); + +const root = process.cwd(); +let failures = 0; +const report = (ok, msg) => { + const prefix = ok ? 'ok' : 'fail'; + console.log(`[${prefix}] ${msg}`); + if (!ok) failures += 1; +}; +const warn = (msg) => console.log(`[warn] ${msg}`); + +report(fs.existsSync(path.join(root, 'package.json')), 'package.json present'); +report(fs.existsSync(path.join(root, 'build_index.js')), 'build_index.js present'); +report(fs.existsSync(path.join(root, 'search.js')), 'search.js present'); + +const configPath = path.join(root, '.pairofcleats.json'); +report(fs.existsSync(configPath), '.pairofcleats.json present'); + +const userConfig = loadUserConfig(root); +const postingsConfig = normalizePostingsConfig(userConfig.indexing?.postings || {}); +const vectorExtension = getVectorExtensionConfig(root, userConfig); +const dictConfig = getDictConfig(root, userConfig); +const dictionaryPaths = await getDictionaryPaths(root, dictConfig); +if (dictionaryPaths.length) { + report(true, `dictionary files found (${dictionaryPaths.length})`); +} else if (argv['require-dicts']) { + report(false, 'dictionary files not found'); +} else { + warn('dictionary files not found (run node tools/download/dicts.js --lang en)'); +} + +const indexDirs = [ + { name: getIndexDir(root, 'code', userConfig), required: argv['require-index'] }, + { name: getIndexDir(root, 'prose', userConfig), required: argv['require-index'] } +]; +const indexFiles = [ + 'chunk_meta.json', + 'token_postings.json', + ...(postingsConfig.enablePhraseNgrams !== false ? ['phrase_ngrams.json'] : []), + ...(postingsConfig.enableChargrams !== false ? ['chargram_postings.json'] : []), + 'minhash_signatures.json' +]; + +for (const dir of indexDirs) { + const dirPath = dir.name; + if (!fs.existsSync(dirPath)) { + const display = path.relative(root, dirPath) || dirPath; + if (dir.required) report(false, `${display} missing`); + else warn(`${display} missing (build index to generate)`); + continue; + } + for (const file of indexFiles) { + const filePath = path.join(dirPath, file); + const displayPath = path.relative(root, filePath) || filePath; + if (fs.existsSync(filePath)) report(true, `${displayPath} present`); + else if (dir.required) report(false, `${displayPath} missing`); + else warn(`${displayPath} missing`); + } +} + +const sqlitePaths = resolveSqlitePaths(root, userConfig); +if (vectorExtension.enabled) { + const extPath = resolveVectorExtensionPath(vectorExtension); + if (extPath && fs.existsSync(extPath)) { + report(true, `sqlite ann extension present (${extPath})`); + } else if (argv['require-sqlite']) { + report(false, 'sqlite ann extension missing (configured)'); + } else { + warn('sqlite ann extension missing (configured)'); + } +} +const sqliteTargets = [ + { label: 'code', path: sqlitePaths.codePath }, + { label: 'prose', path: sqlitePaths.prosePath } +]; + +let sqlitePresent = false; +for (const target of sqliteTargets) { + if (fs.existsSync(target.path)) { + sqlitePresent = true; + report(true, `sqlite index present (${target.label}: ${target.path})`); + } else if (argv['require-sqlite']) { + report(false, `sqlite index missing (${target.label}: ${target.path})`); + } else { + warn(`sqlite index missing (${target.label}: ${target.path})`); + } +} + +if (sqlitePaths.legacyExists) { + const msg = `legacy sqlite index detected (${sqlitePaths.legacyPath})`; + report(false, msg); +} + +if (sqlitePresent) { + try { + const { default: Database } = await import('better-sqlite3'); + const requiredTables = [ + 'chunks', + 'chunks_fts', + 'token_vocab', + 'token_postings', + 'doc_lengths', + 'token_stats', + 'phrase_vocab', + 'phrase_postings', + 'chargram_vocab', + 'chargram_postings', + 'minhash_signatures', + 'dense_vectors', + 'dense_meta', + 'file_manifest' + ]; + for (const target of sqliteTargets) { + if (!fs.existsSync(target.path)) continue; + const db = new Database(target.path, { readonly: true }); + const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); + const tableNames = new Set(rows.map((row) => row.name)); + const missing = requiredTables.filter((name) => !tableNames.has(name)); + if (missing.length) { + const msg = `sqlite ${target.label} index missing tables (${missing.join(', ')})`; + if (argv['require-sqlite']) report(false, msg); + else warn(msg); + } else { + report(true, `sqlite ${target.label} tables present`); + } + db.close(); + } + } catch (err) { + warn(`sqlite table check skipped (${err?.message || 'better-sqlite3 unavailable'})`); + } +} + +if (failures) { + console.error(`\n${failures} checks failed`); + process.exit(1); +} + +console.log('\nAll required checks passed'); diff --git a/tests/smoke/e2e-smoke.test.js b/tests/smoke/e2e-smoke.test.js deleted file mode 100644 index c284a336a..000000000 --- a/tests/smoke/e2e-smoke.test.js +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCombinedOutput } from '../helpers/stdio.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'e2e-smoke'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'alpha.js'), - 'export function alpha() { return 1; }\n' -); -await fsPromises.writeFile( - path.join(repoRoot, 'README.md'), - '# Alpha Repo\nThis is a tiny repo for smoke testing.\n' -); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; - -const runNode = (label, args, options = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - encoding: 'utf8', - ...options - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result; -}; - -runNode('build index', [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], { stdio: 'inherit' }); - -const search = runNode('search', [ - path.join(root, 'search.js'), - 'alpha', - '--mode', - 'code', - '--json', - '--no-ann', - '--repo', - repoRoot -]); -let searchPayload = null; -try { - searchPayload = JSON.parse(search.stdout || '{}'); -} catch { - console.error('Failed: search output invalid JSON'); - process.exit(1); -} -const codeHits = Array.isArray(searchPayload.code) ? searchPayload.code : []; -if (!codeHits.length) { - console.error('Failed: search returned no code results'); - process.exit(1); -} - -const mapJson = runNode('map json', [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', - 'json', - '--repo', - repoRoot -]); -let mapPayload = null; -try { - mapPayload = JSON.parse(mapJson.stdout || '{}'); -} catch { - console.error('Failed: map json output invalid'); - process.exit(1); -} -if (!Array.isArray(mapPayload.nodes) || mapPayload.nodes.length === 0) { - console.error('Failed: map json nodes missing'); - process.exit(1); -} - -const mapDot = runNode('map dot', [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', - 'dot', - '--repo', - repoRoot -]); -const mapDotOutput = getCombinedOutput(mapDot); -if (!mapDotOutput.includes('digraph')) { - console.error('Failed: map dot output missing digraph'); - process.exit(1); -} - -const dotCheck = spawnSync('dot', ['-V'], { encoding: 'utf8' }); -if (dotCheck.status === 0) { - const mapSvg = runNode('map svg', [ - path.join(root, 'tools', 'reports/report-code-map.js'), - '--format', - 'svg', - '--repo', - repoRoot - ]); - const mapSvgOutput = getCombinedOutput(mapSvg); - if (!mapSvgOutput.includes(''); - process.exit(1); - } -} else { - console.log('[skip] Graphviz dot missing; svg map output skipped.'); -} - -console.log('e2e smoke test passed'); - diff --git a/tests/smoke/e2e.test.js b/tests/smoke/e2e.test.js new file mode 100644 index 000000000..a1e136c8e --- /dev/null +++ b/tests/smoke/e2e.test.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { getCombinedOutput } from '../helpers/stdio.js'; +import { runNode as runNodeHelper } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'e2e-smoke'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'alpha.js'), + 'export function alpha() { return 1; }\n' +); +await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# Alpha Repo\nThis is a tiny repo for smoke testing.\n' +); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + scm: { provider: 'none' } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + syncProcess: false +}); + +const runNode = (label, args, options = {}) => { + const { + stdio = 'pipe', + timeout, + ...spawnOptions + } = options; + const result = runNodeHelper(args, label, repoRoot, env, { + stdio, + timeoutMs: timeout, + allowFailure: true, + spawnOptions + }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + const output = getCombinedOutput(result, { trim: true }); + if (output) console.error(output); + process.exit(result.status ?? 1); + } + return result; +}; + +runNode('build index', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot, + '--no-sqlite' +], { stdio: 'inherit' }); + +const search = runNode('search', [ + path.join(root, 'search.js'), + 'alpha', + '--mode', + 'code', + '--json', + '--no-ann', + '--repo', + repoRoot +]); +let searchPayload = null; +try { + searchPayload = JSON.parse(search.stdout || '{}'); +} catch { + console.error('Failed: search output invalid JSON'); + process.exit(1); +} +const codeHits = Array.isArray(searchPayload.code) ? searchPayload.code : []; +if (!codeHits.length) { + console.error('Failed: search returned no code results'); + process.exit(1); +} + +const mapJson = runNode('map json', [ + path.join(root, 'tools', 'reports/report-code-map.js'), + '--format', + 'json', + '--repo', + repoRoot +]); +let mapPayload = null; +try { + mapPayload = JSON.parse(mapJson.stdout || '{}'); +} catch { + console.error('Failed: map json output invalid'); + process.exit(1); +} +if (!Array.isArray(mapPayload.nodes) || mapPayload.nodes.length === 0) { + console.error('Failed: map json nodes missing'); + process.exit(1); +} + +const mapDot = runNode('map dot', [ + path.join(root, 'tools', 'reports/report-code-map.js'), + '--format', + 'dot', + '--repo', + repoRoot +]); +const mapDotOutput = getCombinedOutput(mapDot); +if (!mapDotOutput.includes('digraph')) { + console.error('Failed: map dot output missing digraph'); + process.exit(1); +} + +const dotCheck = spawnSync('dot', ['-V'], { encoding: 'utf8' }); +if (dotCheck.status === 0) { + const mapSvg = runNode('map svg', [ + path.join(root, 'tools', 'reports/report-code-map.js'), + '--format', + 'svg', + '--repo', + repoRoot + ]); + const mapSvgOutput = getCombinedOutput(mapSvg); + if (!mapSvgOutput.includes(''); + process.exit(1); + } +} else { + console.log('[skip] Graphviz dot missing; svg map output skipped.'); +} + +console.log('e2e smoke test passed'); + diff --git a/tests/smoke/embeddings.test.js b/tests/smoke/embeddings.test.js new file mode 100644 index 000000000..0edcab9ba --- /dev/null +++ b/tests/smoke/embeddings.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { cleanup, createSmokeIndexFixture, root, runSmokeNode } from './smoke-utils.js'; +import { getIndexDir, loadUserConfig } from '../../tools/shared/dict-utils.js'; +import { loadPiecesManifestPieces } from '../helpers/pieces-manifest.js'; + +let tempRoot = null; + +let failure = null; +try { + const fixture = await createSmokeIndexFixture({ + name: 'smoke-embeddings', + token: 'embeddings_smoke_token' + }); + tempRoot = fixture.tempRoot; + const { env, repoRoot } = fixture; + const run = (label, args) => runSmokeNode(label, args, { cwd: repoRoot, env }); + + run('build_index', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ]); + run('build_embeddings', [ + path.join(root, 'tools', 'build', 'embeddings.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ]); + + const userConfig = loadUserConfig(repoRoot); + const codeDir = getIndexDir(repoRoot, 'code', userConfig); + const pieces = loadPiecesManifestPieces(codeDir); + const pieceNames = new Set( + pieces + .filter((entry) => entry && typeof entry.name === 'string') + .map((entry) => entry.name) + ); + + assert.ok(pieceNames.has('dense_vectors'), 'expected dense_vectors entry in pieces manifest'); + assert.ok(pieceNames.has('dense_vectors_lancedb_meta'), 'expected lancedb metadata entry in pieces manifest'); +} catch (err) { + console.error(err?.message || err); + failure = err; +} +if (tempRoot) { + await cleanup([tempRoot]); +} + +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke embeddings passed'); + diff --git a/tests/smoke/retrieval.test.js b/tests/smoke/retrieval.test.js new file mode 100644 index 000000000..257a2a681 --- /dev/null +++ b/tests/smoke/retrieval.test.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { runNode as runNodeHelper } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; +import { cleanup, root } from './smoke-utils.js'; + +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +const tempRoot = resolveTestCachePath(root, 'smoke-retrieval'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const searchPath = path.join(root, 'search.js'); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + syncProcess: false +}); + +const fail = (message, exitCode = 1) => { + const error = new Error(message); + error.exitCode = exitCode; + throw error; +}; + +const runNode = (label, args, options = {}) => { + const { + cwd = root, + stdio = 'pipe', + timeout, + ...spawnOptions + } = options; + const result = runNodeHelper(args, label, cwd, env, { + stdio, + timeoutMs: timeout, + allowFailure: true, + spawnOptions + }); + if (result.status !== 0) { + const stderr = result.stderr ? result.stderr.trim() : ''; + if (stderr) console.error(stderr); + fail(`Failed: ${label}`, result.status ?? 1); + } + return result; +}; + +let failure = null; +try { + await cleanup([tempRoot]); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'alpha.js'), + 'export function returnSmokeValue() { return "return smoke token"; }\n' + ); + + const build = runNode( + 'build index', + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot, + '--no-sqlite' + ], + { stdio: 'inherit' } + ); + if (build.status !== 0) { + fail('smoke retrieval failed: build_index failed', build.status ?? 1); + } + + const annResult = runNode( + 'search ann', + [ + searchPath, + 'return smoke token', + '--mode', + 'code', + '--ann', + '--json', + '--stats', + '--explain', + '--repo', + repoRoot + ] + ); + let annPayload = null; + try { + annPayload = JSON.parse(annResult.stdout || '{}'); + } catch { + fail('search ann test failed: invalid JSON output'); + } + if (!annPayload?.stats?.annActive) { + fail('search ann test failed: annActive was false'); + } + const annHit = annPayload?.code?.find((hit) => hit?.scoreBreakdown?.ann); + if (!annHit) { + fail('search ann test failed: no ann hits found'); + } + const annSource = annHit?.scoreBreakdown?.ann?.source; + if (!annSource) { + fail('search ann test failed: ann source missing'); + } + + const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, ''); + const explainResult = runNode( + 'search explain', + [searchPath, 'return smoke token', '--mode', 'code', '--no-ann', '--repo', repoRoot, '--explain'] + ); + const explainOutput = stripAnsi(`${explainResult.stdout || ''}\n${explainResult.stderr || ''}`); + if (!/score/i.test(explainOutput)) { + fail('Explain output missing score details.'); + } + if (!/sparse|bm25/i.test(explainOutput)) { + fail('Explain output missing sparse/bm25 details.'); + } + +} catch (err) { + console.error(err?.message || err); + failure = err; +} + +await cleanup([tempRoot]); +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke retrieval passed'); + diff --git a/tests/smoke/services.test.js b/tests/smoke/services.test.js new file mode 100644 index 000000000..07252ec91 --- /dev/null +++ b/tests/smoke/services.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; + +import { MCP_PROTOCOL_VERSION } from '../../src/integrations/mcp/protocol.js'; +import { MCP_SCHEMA_VERSION } from '../../src/integrations/mcp/defs.js'; +import { createApiRouter } from '../../tools/api/router.js'; +import { startMcpServer } from '../helpers/mcp-client.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { cleanup, root } from './smoke-utils.js'; + +const cacheRoots = [ + resolveTestCachePath(root, 'mcp-protocol-init'), + resolveTestCachePath(root, 'api-router') +]; + +const runMcpSmoke = async () => { + const cacheRoot = resolveTestCachePath(root, 'mcp-protocol-init'); + const { send, readMessage, shutdown } = await startMcpServer({ cacheRoot }); + try { + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }); + const init = await readMessage(); + assert.equal(init.result?.protocolVersion, MCP_PROTOCOL_VERSION); + assert.equal(init.result?.schemaVersion, MCP_SCHEMA_VERSION); + assert.ok(init.result?.serverInfo?.name); + + send({ jsonrpc: '2.0', id: 2, method: 'shutdown' }); + await readMessage(); + send({ jsonrpc: '2.0', method: 'exit' }); + } finally { + await shutdown(); + } +}; + +const runApiRouterSmoke = async () => { + const tempRoot = resolveTestCachePath(root, 'api-router'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + const router = createApiRouter({ + host: '127.0.0.1', + defaultRepo: tempRoot, + defaultOutput: 'json', + metricsRegistry: null + }); + + const server = http.createServer((req, res) => router.handleRequest(req, res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + try { + const response = await fetch(`http://127.0.0.1:${port}/missing`); + const payload = await response.json(); + assert.equal(payload.ok, false); + assert.ok(payload.code); + assert.ok(payload.namespaceCode); + } finally { + server.close(); + if (typeof router.close === 'function') router.close(); + } +}; + +let failure = null; +try { + await cleanup(cacheRoots); + await runMcpSmoke(); + await runApiRouterSmoke(); +} catch (error) { + console.error(error?.stack || error?.message || String(error)); + failure = error; +} +await cleanup(cacheRoots); + +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke services passed'); diff --git a/tests/smoke/smoke-embeddings.test.js b/tests/smoke/smoke-embeddings.test.js deleted file mode 100644 index 2de46e817..000000000 --- a/tests/smoke/smoke-embeddings.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { cleanup, runNode, root } from './smoke-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const cacheRoots = [ - resolveTestCachePath(root, 'build-embeddings-cache'), - resolveTestCachePath(root, 'embeddings-dims-mismatch'), - resolveTestCachePath(root, 'embeddings-cache-identity') -]; - -let failure = null; -try { - await cleanup(cacheRoots); - runNode('embeddings-cache', path.join(root, 'tests', 'indexing', 'embeddings', 'build', 'build-embeddings-cache.test.js')); - runNode('onnx-session-queue', path.join(root, 'tests', 'indexing', 'embeddings', 'onnx-session-queue.test.js')); - runNode('embeddings-cache-identity', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-cache-identity.test.js')); - runNode('embeddings-cache-invalidation', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-cache-invalidation.test.js')); - runNode('embeddings-dims-mismatch', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-dims-mismatch.test.js')); -} catch (err) { - console.error(err?.message || err); - failure = err; -} -await cleanup(cacheRoots); - -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke embeddings passed'); - diff --git a/tests/smoke/smoke-retrieval.test.js b/tests/smoke/smoke-retrieval.test.js deleted file mode 100644 index e8511204b..000000000 --- a/tests/smoke/smoke-retrieval.test.js +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { toPosix } from '../../src/shared/files.js'; -import { getCombinedOutput } from '../helpers/stdio.js'; -import { applyTestEnv } from '../helpers/test-env.js'; -import { cleanup, root } from './smoke-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const tempRoot = resolveTestCachePath(root, 'smoke-retrieval'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const searchPath = path.join(root, 'search.js'); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub' -}); - -const fail = (message, exitCode = 1) => { - const error = new Error(message); - error.exitCode = exitCode; - throw error; -}; - -const runNode = (label, args, options = {}) => { - const result = spawnSync(process.execPath, args, { env, encoding: 'utf8', ...options }); - if (result.status !== 0) { - const stderr = result.stderr ? result.stderr.trim() : ''; - if (stderr) console.error(stderr); - fail(`Failed: ${label}`, result.status ?? 1); - } - return result; -}; - -let failure = null; -try { - await cleanup([tempRoot]); - await fsPromises.mkdir(cacheRoot, { recursive: true }); - await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - - const build = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - { env, stdio: 'inherit' } - ); - if (build.status !== 0) { - fail('smoke retrieval failed: build_index failed', build.status ?? 1); - } - - const helpResult = spawnSync(process.execPath, [searchPath], { encoding: 'utf8' }); - if (helpResult.status === 0) { - fail('Expected search help to exit non-zero with no query.'); - } - const helpOutput = getCombinedOutput(helpResult); - const requiredFlags = ['--filter', '--explain', '--json', '--mode']; - for (const flag of requiredFlags) { - if (!helpOutput.includes(flag)) { - fail(`Help output missing flag: ${flag}`); - } - } - - const annResult = runNode( - 'search ann', - [ - searchPath, - 'return', - '--mode', - 'code', - '--ann', - '--json', - '--stats', - '--explain', - '--repo', - repoRoot - ] - ); - let annPayload = null; - try { - annPayload = JSON.parse(annResult.stdout || '{}'); - } catch { - fail('search ann test failed: invalid JSON output'); - } - if (!annPayload?.stats?.annActive) { - fail('search ann test failed: annActive was false'); - } - const annHit = annPayload?.code?.find((hit) => hit?.scoreBreakdown?.ann); - if (!annHit) { - fail('search ann test failed: no ann hits found'); - } - const annSource = annHit?.scoreBreakdown?.ann?.source; - if (!annSource) { - fail('search ann test failed: ann source missing'); - } - - const filterResult = runNode( - 'search filters', - [ - searchPath, - 'return', - '--mode', - 'code', - '--json', - '--no-ann', - '--repo', - repoRoot, - '--file', - 'src/index.js' - ] - ); - const filterPayload = JSON.parse(filterResult.stdout || '{}'); - const filterHits = filterPayload?.code || []; - if (!filterHits.length) { - fail('search filter test failed: no results returned'); - } - const badFilterHit = filterHits.find((hit) => !toPosix(hit.file || '').endsWith('src/index.js')); - if (badFilterHit) { - fail('search filter test failed: file filter mismatch'); - } - - const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, ''); - const explainResult = runNode( - 'search explain', - [searchPath, 'return', '--mode', 'code', '--no-ann', '--repo', repoRoot, '--explain'] - ); - const explainOutput = stripAnsi(getCombinedOutput(explainResult)); - if (!/score/i.test(explainOutput)) { - fail('Explain output missing score details.'); - } - if (!/sparse|bm25/i.test(explainOutput)) { - fail('Explain output missing sparse/bm25 details.'); - } - -} catch (err) { - console.error(err?.message || err); - failure = err; -} - -await cleanup([tempRoot]); -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke retrieval passed'); - diff --git a/tests/smoke/smoke-section1.test.js b/tests/smoke/smoke-section1.test.js deleted file mode 100644 index 284d9f245..000000000 --- a/tests/smoke/smoke-section1.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { cleanup, runNode, root } from './smoke-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const cacheRoots = [ - resolveTestCachePath(root, 'core-api'), - resolveTestCachePath(root, 'api-health-status') -]; - -let failure = null; -try { - await cleanup(cacheRoots); - runNode('core-api', path.join(root, 'tests', 'services', 'api', 'core-api.test.js')); - runNode('api-health-status', path.join(root, 'tests', 'services', 'api', 'health-and-status.test.js')); -} catch (err) { - console.error(err?.message || err); - failure = err; -} -await cleanup(cacheRoots); - -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke section1 passed'); - diff --git a/tests/smoke/smoke-services.test.js b/tests/smoke/smoke-services.test.js deleted file mode 100644 index fd9d41a44..000000000 --- a/tests/smoke/smoke-services.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { cleanup, runNode, root } from './smoke-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const cacheRoots = [ - resolveTestCachePath(root, 'mcp-protocol-init'), - resolveTestCachePath(root, 'api-health-status') -]; - -let failure = null; -try { - await cleanup(cacheRoots); - runNode('mcp-protocol-init', path.join(root, 'tests', 'services', 'mcp', 'protocol-initialize.test.js')); - runNode('api-health-status', path.join(root, 'tests', 'services', 'api', 'health-and-status.test.js')); -} catch (err) { - console.error(err?.message || err); - failure = err; -} -await cleanup(cacheRoots); - -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke services passed'); - diff --git a/tests/smoke/smoke-sqlite.test.js b/tests/smoke/smoke-sqlite.test.js deleted file mode 100644 index f47995b3c..000000000 --- a/tests/smoke/smoke-sqlite.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { cleanup, runNode, root } from './smoke-utils.js'; - -import { applyTestEnv } from '../helpers/test-env.js'; -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const cacheSuffix = 'smoke-sqlite'; -const cacheRoots = [ - resolveTestCachePath(root, 'sqlite-incremental', `file-manifest-updates-${cacheSuffix}`), - resolveTestCachePath(root, `sqlite-ann-fallback-${cacheSuffix}`) -]; - -let failure = null; -try { - await cleanup(cacheRoots); - const env = applyTestEnv({ - extraEnv: { - PAIROFCLEATS_TEST_CACHE_SUFFIX: cacheSuffix - } - }); - runNode( - 'sqlite-incremental-manifest', - path.join(root, 'tests', 'storage', 'sqlite', 'incremental', 'file-manifest-updates.test.js'), - [], - { env } - ); - runNode( - 'sqlite-ann-fallback', - path.join(root, 'tests', 'storage', 'sqlite', 'ann', 'sqlite-ann-fallback.test.js'), - [], - { env } - ); -} catch (err) { - console.error(err?.message || err); - failure = err; -} -await cleanup(cacheRoots); - -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke sqlite passed'); - diff --git a/tests/smoke/smoke-utils.js b/tests/smoke/smoke-utils.js index 48057a244..524eb882f 100644 --- a/tests/smoke/smoke-utils.js +++ b/tests/smoke/smoke-utils.js @@ -1,4 +1,9 @@ -import { spawnSync } from 'node:child_process'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { runNode as runNodeHelper } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; import { RETRYABLE_RM_CODES, rmDirRecursive } from '../helpers/temp.js'; export const root = process.cwd(); @@ -25,9 +30,18 @@ export async function cleanup(paths) { } export function runNode(label, scriptPath, args = [], options = {}) { - const result = spawnSync(process.execPath, [scriptPath, ...args], { - stdio: 'inherit', - ...options + const { + cwd = root, + env = process.env, + stdio = 'inherit', + timeout, + ...spawnOptions + } = options; + const result = runNodeHelper([scriptPath, ...args], label, cwd, env, { + stdio, + timeoutMs: timeout, + allowFailure: true, + spawnOptions }); if (result.status !== 0) { const error = new Error(`Failed: ${label}`); @@ -36,3 +50,70 @@ export function runNode(label, scriptPath, args = [], options = {}) { } return result; } + +export const createSmokeIndexFixture = async ({ + name, + token, + testConfig = {}, + embeddings = 'stub' +}) => { + const tempRoot = resolveTestCachePath(root, name); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const env = applyTestEnv({ + cacheRoot, + embeddings, + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + scm: { provider: 'none' } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + }, + ...testConfig + } + }); + + await cleanup([tempRoot]); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'alpha.js'), + `export const alpha = () => "${token}";\n` + ); + + return { cacheRoot, env, repoRoot, tempRoot }; +}; + +export const failSmoke = (message, exitCode = 1) => { + const error = new Error(message); + error.exitCode = exitCode; + throw error; +}; + +export const runSmokeNode = (label, args, { cwd, env, options = {} }) => { + const { + stdio = 'pipe', + timeout, + ...spawnOptions + } = options; + const result = runNodeHelper(args, label, cwd, env, { + stdio, + timeoutMs: timeout, + allowFailure: true, + spawnOptions + }); + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + if (stderr) console.error(stderr); + if (stdout) console.error(stdout); + failSmoke(`Failed: ${label}`, result.status ?? 1); + } + return result; +}; diff --git a/tests/smoke/smoke-workers.test.js b/tests/smoke/smoke-workers.test.js deleted file mode 100644 index 2a777f5a6..000000000 --- a/tests/smoke/smoke-workers.test.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { cleanup, runNode, root } from './smoke-utils.js'; - -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -const cacheRoots = [resolveTestCachePath(root, 'type-inference-crossfile-stats')]; - -let failure = null; -try { - await cleanup(cacheRoots); - runNode('worker-pool', path.join(root, 'tests', 'indexing', 'workers', 'worker-pool.test.js')); - runNode('crossfile-stats', path.join(root, 'tests', 'tooling', 'type-inference', 'crossfile-stats.unit.test.js')); -} catch (err) { - console.error(err?.message || err); - failure = err; -} -await cleanup(cacheRoots); - -if (failure) { - process.exit(failure.exitCode ?? 1); -} -console.log('smoke workers passed'); - diff --git a/tests/smoke/smoke.test.js b/tests/smoke/smoke.test.js deleted file mode 100644 index e296ba3a1..000000000 --- a/tests/smoke/smoke.test.js +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { createCli } from '../../src/shared/cli.js'; -import { getDictionaryPaths, getDictConfig, getIndexDir, loadUserConfig, resolveSqlitePaths } from '../../tools/shared/dict-utils.js'; -import { normalizePostingsConfig } from '../../src/shared/postings-config.js'; -import { getVectorExtensionConfig, resolveVectorExtensionPath } from '../../tools/sqlite/vector-extension.js'; - -const argv = createCli({ - scriptName: 'verify', - options: { - 'require-index': { type: 'boolean', default: false }, - 'require-sqlite': { type: 'boolean', default: false }, - 'require-dicts': { type: 'boolean', default: false } - } -}).parse(); - -const root = process.cwd(); -let failures = 0; -const report = (ok, msg) => { - const prefix = ok ? 'ok' : 'fail'; - console.log(`[${prefix}] ${msg}`); - if (!ok) failures += 1; -}; -const warn = (msg) => console.log(`[warn] ${msg}`); - -report(fs.existsSync(path.join(root, 'package.json')), 'package.json present'); -report(fs.existsSync(path.join(root, 'build_index.js')), 'build_index.js present'); -report(fs.existsSync(path.join(root, 'search.js')), 'search.js present'); - -const configPath = path.join(root, '.pairofcleats.json'); -report(fs.existsSync(configPath), '.pairofcleats.json present'); - -const userConfig = loadUserConfig(root); -const postingsConfig = normalizePostingsConfig(userConfig.indexing?.postings || {}); -const vectorExtension = getVectorExtensionConfig(root, userConfig); -const dictConfig = getDictConfig(root, userConfig); -const dictionaryPaths = await getDictionaryPaths(root, dictConfig); -if (dictionaryPaths.length) { - report(true, `dictionary files found (${dictionaryPaths.length})`); -} else if (argv['require-dicts']) { - report(false, 'dictionary files not found'); -} else { - warn('dictionary files not found (run npm run download-dicts -- --lang en)'); -} - -const indexDirs = [ - { name: getIndexDir(root, 'code', userConfig), required: argv['require-index'] }, - { name: getIndexDir(root, 'prose', userConfig), required: argv['require-index'] } -]; -const indexFiles = [ - 'chunk_meta.json', - 'token_postings.json', - ...(postingsConfig.enablePhraseNgrams !== false ? ['phrase_ngrams.json'] : []), - ...(postingsConfig.enableChargrams !== false ? ['chargram_postings.json'] : []), - 'minhash_signatures.json' -]; - -for (const dir of indexDirs) { - const dirPath = dir.name; - if (!fs.existsSync(dirPath)) { - const display = path.relative(root, dirPath) || dirPath; - if (dir.required) report(false, `${display} missing`); - else warn(`${display} missing (build index to generate)`); - continue; - } - for (const file of indexFiles) { - const filePath = path.join(dirPath, file); - const displayPath = path.relative(root, filePath) || filePath; - if (fs.existsSync(filePath)) report(true, `${displayPath} present`); - else if (dir.required) report(false, `${displayPath} missing`); - else warn(`${displayPath} missing`); - } -} - -const sqlitePaths = resolveSqlitePaths(root, userConfig); -if (vectorExtension.enabled) { - const extPath = resolveVectorExtensionPath(vectorExtension); - if (extPath && fs.existsSync(extPath)) { - report(true, `sqlite ann extension present (${extPath})`); - } else if (argv['require-sqlite']) { - report(false, 'sqlite ann extension missing (configured)'); - } else { - warn('sqlite ann extension missing (configured)'); - } -} -const sqliteTargets = [ - { label: 'code', path: sqlitePaths.codePath }, - { label: 'prose', path: sqlitePaths.prosePath } -]; - -let sqlitePresent = false; -for (const target of sqliteTargets) { - if (fs.existsSync(target.path)) { - sqlitePresent = true; - report(true, `sqlite index present (${target.label}: ${target.path})`); - } else if (argv['require-sqlite']) { - report(false, `sqlite index missing (${target.label}: ${target.path})`); - } else { - warn(`sqlite index missing (${target.label}: ${target.path})`); - } -} - -if (sqlitePaths.legacyExists) { - const msg = `legacy sqlite index detected (${sqlitePaths.legacyPath})`; - report(false, msg); -} - -if (sqlitePresent) { - try { - const { default: Database } = await import('better-sqlite3'); - const requiredTables = [ - 'chunks', - 'chunks_fts', - 'token_vocab', - 'token_postings', - 'doc_lengths', - 'token_stats', - 'phrase_vocab', - 'phrase_postings', - 'chargram_vocab', - 'chargram_postings', - 'minhash_signatures', - 'dense_vectors', - 'dense_meta', - 'file_manifest' - ]; - for (const target of sqliteTargets) { - if (!fs.existsSync(target.path)) continue; - const db = new Database(target.path, { readonly: true }); - const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); - const tableNames = new Set(rows.map((row) => row.name)); - const missing = requiredTables.filter((name) => !tableNames.has(name)); - if (missing.length) { - const msg = `sqlite ${target.label} index missing tables (${missing.join(', ')})`; - if (argv['require-sqlite']) report(false, msg); - else warn(msg); - } else { - report(true, `sqlite ${target.label} tables present`); - } - db.close(); - } - } catch (err) { - warn(`sqlite table check skipped (${err?.message || 'better-sqlite3 unavailable'})`); - } -} - -if (failures) { - console.error(`\n${failures} checks failed`); - process.exit(1); -} - -console.log('\nAll required checks passed'); diff --git a/tests/smoke/sqlite.test.js b/tests/smoke/sqlite.test.js new file mode 100644 index 000000000..8aac429e7 --- /dev/null +++ b/tests/smoke/sqlite.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { cleanup, createSmokeIndexFixture, root, runSmokeNode } from './smoke-utils.js'; +import { runSqliteBuild } from '../helpers/sqlite-builder.js'; + +let tempRoot = null; + +let failure = null; +try { + const fixture = await createSmokeIndexFixture({ + name: 'smoke-sqlite', + token: 'sqlite_smoke_token' + }); + tempRoot = fixture.tempRoot; + const { env, repoRoot } = fixture; + const run = (label, args, options = {}) => + runSmokeNode(label, args, { cwd: repoRoot, env, options }); + + run('build_index', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ]); + await runSqliteBuild(repoRoot, { mode: 'code', env }); + + const searchResult = run('search sqlite backend', [ + path.join(root, 'search.js'), + 'sqlite_smoke_token', + '--mode', + 'code', + '--backend', + 'sqlite', + '--json', + '--repo', + repoRoot + ]); + + const payload = JSON.parse(searchResult.stdout || '{}'); + const hits = Array.isArray(payload?.code) ? payload.code : []; + assert.ok(hits.length > 0, 'expected sqlite smoke search to return at least one code hit'); + assert.ok( + hits.some((hit) => String(hit?.file || '').includes('src/alpha.js')), + 'expected sqlite smoke search to return src/alpha.js' + ); +} catch (err) { + console.error(err?.message || err); + failure = err; +} +if (tempRoot) { + await cleanup([tempRoot]); +} + +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke sqlite passed'); + diff --git a/tests/smoke/tantivy-smoke.test.js b/tests/smoke/tantivy-smoke.test.js deleted file mode 100644 index 8d3b32232..000000000 --- a/tests/smoke/tantivy-smoke.test.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { tryRequire } from '../../src/shared/optional-deps.js'; -import { resolveTestCachePath } from '../helpers/test-cache.js'; - -applyTestEnv(); -const gate = String(process.env.PAIROFCLEATS_TEST_TANTIVY || '').trim().toLowerCase(); -if (!['1', 'true', 'yes', 'on'].includes(gate)) { - console.warn('tantivy smoke test skipped (set PAIROFCLEATS_TEST_TANTIVY=1 to run).'); - process.exit(0); -} - -const tantivyAvailable = tryRequire('tantivy').ok; -if (!tantivyAvailable) { - console.error('tantivy missing; install the optional dependency to run this test.'); - process.exit(1); -} - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'tantivy-smoke'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub' -}); - -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'build index'); -run([path.join(root, 'tools', 'build/tantivy-index.js'), '--mode', 'code', '--repo', repoRoot], 'build tantivy index'); - -const searchResult = spawnSync( - process.execPath, - [path.join(root, 'search.js'), 'index', '--mode', 'code', '--json', '--backend', 'tantivy', '--no-ann', '--repo', repoRoot], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('search.js failed for Tantivy smoke test.'); - if (searchResult.stderr) console.error(searchResult.stderr.trim()); - process.exit(searchResult.status ?? 1); -} - -let payload = null; -try { - payload = JSON.parse(searchResult.stdout || '{}'); -} catch { - console.error('Failed to parse Tantivy search JSON output.'); - process.exit(1); -} - -if (payload.backend !== 'tantivy') { - console.error(`Expected backend=tantivy, got ${payload.backend}`); - process.exit(1); -} -if (!Array.isArray(payload.code) || payload.code.length === 0) { - console.error('Expected Tantivy code results to be non-empty.'); - process.exit(1); -} - -if (fs.existsSync(tempRoot)) { - await fsPromises.rm(tempRoot, { recursive: true, force: true }); -} - -console.log('tantivy smoke test passed'); diff --git a/tests/smoke/tantivy.test.js b/tests/smoke/tantivy.test.js new file mode 100644 index 000000000..c17d58827 --- /dev/null +++ b/tests/smoke/tantivy.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../helpers/test-env.js'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { tryRequire } from '../../src/shared/optional-deps.js'; +import { runNode } from '../helpers/run-node.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +applyTestEnv(); +const gate = String(process.env.PAIROFCLEATS_TEST_TANTIVY || '').trim().toLowerCase(); +if (!['1', 'true', 'yes', 'on'].includes(gate)) { + console.warn('tantivy smoke test skipped (set PAIROFCLEATS_TEST_TANTIVY=1 to run).'); + process.exit(0); +} + +const tantivyAvailable = tryRequire('tantivy').ok; +if (!tantivyAvailable) { + console.error('tantivy missing; install the optional dependency to run this test.'); + process.exit(1); +} + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const tempRoot = resolveTestCachePath(root, 'tantivy-smoke'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + lsp: { enabled: false } + } + } +}); + +const run = (args, label) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +run([path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot], 'build index'); +run([path.join(root, 'tools', 'build/tantivy-index.js'), '--mode', 'code', '--repo', repoRoot], 'build tantivy index'); + +const searchResult = runNode( + [path.join(root, 'search.js'), 'index', '--mode', 'code', '--json', '--backend', 'tantivy', '--no-ann', '--repo', repoRoot], + 'tantivy smoke search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (searchResult.status !== 0) { + console.error('search.js failed for Tantivy smoke test.'); + if (searchResult.stderr) console.error(searchResult.stderr.trim()); + process.exit(searchResult.status ?? 1); +} + +let payload = null; +try { + payload = JSON.parse(searchResult.stdout || '{}'); +} catch { + console.error('Failed to parse Tantivy search JSON output.'); + process.exit(1); +} + +if (payload.backend !== 'tantivy') { + console.error(`Expected backend=tantivy, got ${payload.backend}`); + process.exit(1); +} +if (!Array.isArray(payload.code) || payload.code.length === 0) { + console.error('Expected Tantivy code results to be non-empty.'); + process.exit(1); +} + +if (fs.existsSync(tempRoot)) { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('tantivy smoke test passed'); diff --git a/tests/smoke/workers.test.js b/tests/smoke/workers.test.js new file mode 100644 index 000000000..8648df679 --- /dev/null +++ b/tests/smoke/workers.test.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { normalizePostingsConfig } from '../../src/shared/postings-config.js'; +import { quantizeVec } from '../../src/index/embedding.js'; +import { createTokenizationContext, tokenizeChunkText } from '../../src/index/build/tokenization.js'; +import { createIndexerWorkerPool, normalizeWorkerPoolConfig } from '../../src/index/build/worker-pool.js'; +import { applyCrossFileInference } from '../../src/index/type-inference-crossfile.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { cleanup, root } from './smoke-utils.js'; + +const cacheRoots = [resolveTestCachePath(root, 'type-inference-crossfile-stats')]; + +const runWorkerPoolSmoke = async () => { + const postingsConfig = normalizePostingsConfig({ + enablePhraseNgrams: true, + phraseMinN: 2, + phraseMaxN: 3, + enableChargrams: true, + chargramMinN: 3, + chargramMaxN: 3 + }); + const dictWords = new Set(['hello', 'world', 'foo', 'bar']); + const dictConfig = { segmentation: 'greedy' }; + const workerConfig = normalizeWorkerPoolConfig({ + enabled: true, + maxWorkers: 1, + maxFileBytes: 4096, + quantizeBatchSize: 2, + taskTimeoutMs: 5000 + }, { cpuLimit: 1 }); + + const workerPool = await createIndexerWorkerPool({ + config: workerConfig, + dictWords, + dictConfig, + postingsConfig + }); + if (!workerPool) { + return; + } + + try { + const context = createTokenizationContext({ dictWords, dictConfig, postingsConfig }); + const sample = 'helloWorld fooBar'; + const syncTokens = tokenizeChunkText({ text: sample, mode: 'code', ext: '.js', context }); + const workerTokens = await workerPool.tokenizeChunk({ text: sample, mode: 'code', ext: '.js' }); + assert.deepEqual(workerTokens.tokens, syncTokens.tokens); + + const vectors = [ + [0, 0.5], + [1, -1] + ]; + const syncQuant = vectors.map((vec) => quantizeVec(vec)); + const workerQuant = await workerPool.runQuantize({ vectors }); + assert.deepEqual(workerQuant, syncQuant); + } finally { + await workerPool.destroy(); + } +}; + +const buildSymbolMeta = ({ file, name, kind, chunkUid }) => ({ + chunkUid, + file, + name, + kind, + symbol: { + v: 1, + scheme: 'heur', + kindGroup: String(kind || '').toLowerCase().includes('class') ? 'class' : 'function', + qualifiedName: name, + symbolKey: `${file}::${name}`, + signatureKey: null, + scopedId: `${kind}|${file}::${name}|${chunkUid}`, + symbolId: `sym1:heur:${chunkUid}` + } +}); + +const writeScenarioFile = async (rootDir, relPath, contents) => { + const absPath = path.join(rootDir, relPath); + await fsPromises.mkdir(path.dirname(absPath), { recursive: true }); + await fsPromises.writeFile(absPath, contents, 'utf8'); +}; + +const runCrossfileSmoke = async () => { + const scenarioRoot = path.join(resolveTestCachePath(root, 'type-inference-crossfile-stats'), 'smoke-scenario'); + await fsPromises.rm(scenarioRoot, { recursive: true, force: true }); + await fsPromises.mkdir(scenarioRoot, { recursive: true }); + + const producer = 'export function helper() { return 7; }\n'; + const consumer = 'export function useHelper() { return helper(); }\n'; + await writeScenarioFile(scenarioRoot, 'src/helper.js', producer); + await writeScenarioFile(scenarioRoot, 'src/consumer.js', consumer); + + const stats = await applyCrossFileInference({ + rootDir: scenarioRoot, + enabled: true, + log: () => {}, + useTooling: false, + enableTypeInference: true, + enableRiskCorrelation: true, + chunks: [ + { + file: 'src/consumer.js', + name: 'useHelper', + kind: 'function', + chunkUid: 'uid-consumer', + start: 0, + end: consumer.length, + docmeta: { returnsValue: true }, + codeRelations: { + calls: [['useHelper', 'helper']] + }, + metaV2: buildSymbolMeta({ + file: 'src/consumer.js', + name: 'useHelper', + kind: 'function', + chunkUid: 'uid-consumer' + }) + }, + { + file: 'src/helper.js', + name: 'helper', + kind: 'function', + chunkUid: 'uid-helper', + start: 0, + end: producer.length, + docmeta: { + returnType: 'number', + returnsValue: true + }, + codeRelations: {}, + metaV2: buildSymbolMeta({ + file: 'src/helper.js', + name: 'helper', + kind: 'function', + chunkUid: 'uid-helper' + }) + } + ] + }); + + assert.equal(stats.linkedCalls, 1); + assert.equal(stats.inferredReturns, 1); +}; + +let failure = null; +try { + await cleanup(cacheRoots); + await runWorkerPoolSmoke(); + await runCrossfileSmoke(); +} catch (error) { + console.error(error?.stack || error?.message || String(error)); + failure = error; +} +await cleanup(cacheRoots); + +if (failure) { + process.exit(failure.exitCode ?? 1); +} +console.log('smoke workers passed'); diff --git a/tests/storage/backend/backend-policy.test.js b/tests/storage/backend/policy.test.js similarity index 100% rename from tests/storage/backend/backend-policy.test.js rename to tests/storage/backend/policy.test.js diff --git a/tests/storage/embeddings/embeddings-backend-resilience.test.js b/tests/storage/embeddings/backend-resilience.test.js similarity index 100% rename from tests/storage/embeddings/embeddings-backend-resilience.test.js rename to tests/storage/embeddings/backend-resilience.test.js diff --git a/tests/storage/lmdb/contract-matrix.test.js b/tests/storage/lmdb/contract-matrix.test.js new file mode 100644 index 000000000..690bc4e91 --- /dev/null +++ b/tests/storage/lmdb/contract-matrix.test.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { Packr, Unpackr } from 'msgpackr'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { runNode } from '../../helpers/run-node.js'; +import { getCombinedOutput } from '../../helpers/stdio.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { LMDB_META_KEYS, LMDB_SCHEMA_VERSION } from '../../../src/storage/lmdb/schema.js'; +import { + createLmdbCodec, + decodeLmdbValue, + hasLmdbStore, + validateLmdbArtifactKeys, + validateLmdbSchemaAndMode +} from '../../../src/storage/lmdb/utils.js'; +import { resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; + +requireOrSkip({ capability: 'lmdb', reason: 'Skipping lmdb contract matrix; lmdb not available.' }); + +const { open } = await import('lmdb'); +const packr = new Packr(); +const unpackr = new Unpackr(); +const encode = (value) => packr.pack(value); +const decode = (value) => (value == null ? null : unpackr.unpack(value)); +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lmdb-contract-matrix'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); + +{ + const missingStore = path.join(tempRoot, 'missing-store'); + assert.equal(hasLmdbStore(missingStore), false); + await fsPromises.mkdir(missingStore, { recursive: true }); + assert.equal(hasLmdbStore(missingStore), false); + await fsPromises.writeFile(path.join(missingStore, 'data.mdb'), Buffer.from('x')); + assert.equal(hasLmdbStore(missingStore), true); + + const sample = { ok: true, nested: { count: 2 } }; + const codec = createLmdbCodec(); + assert.deepEqual(codec.decode(encode(sample)), sample); + assert.deepEqual(decodeLmdbValue(encode(sample)), sample); + + const metadata = new Map([ + [LMDB_META_KEYS.schemaVersion, encode(LMDB_SCHEMA_VERSION)], + [LMDB_META_KEYS.mode, encode('code')], + [LMDB_META_KEYS.artifacts, encode(['chunk_meta', 'token_postings'])], + ['chunk_meta', encode([{ id: 1 }])], + ['token_postings', encode({ vocab: [], postings: [] })] + ]); + const db = { get(key) { return metadata.has(key) ? metadata.get(key) : null; } }; + const schemaOk = validateLmdbSchemaAndMode({ db, label: 'code', decode: decodeLmdbValue }); + assert.equal(schemaOk.ok, true); + metadata.set(LMDB_META_KEYS.mode, encode('prose')); + const schemaMismatch = validateLmdbSchemaAndMode({ db, label: 'code', decode: decodeLmdbValue }); + assert.equal(schemaMismatch.ok, false); + assert.equal(schemaMismatch.issues.some((issue) => issue.includes('mode mismatch')), true); + metadata.set(LMDB_META_KEYS.mode, encode('code')); + const artifactOk = validateLmdbArtifactKeys({ db, requiredKeys: ['chunk_meta', 'token_postings'], decode: decodeLmdbValue }); + assert.equal(artifactOk.ok, true); + metadata.set(LMDB_META_KEYS.artifacts, encode(['chunk_meta'])); + const artifactMismatch = validateLmdbArtifactKeys({ db, requiredKeys: ['chunk_meta', 'token_postings'], decode: decodeLmdbValue }); + assert.equal(artifactMismatch.ok, false); + metadata.delete('token_postings'); + metadata.set(LMDB_META_KEYS.artifacts, encode(['chunk_meta', 'token_postings'])); + const artifactMissingValue = validateLmdbArtifactKeys({ db, requiredKeys: ['chunk_meta', 'token_postings'], decode: decodeLmdbValue }); + assert.equal(artifactMissingValue.ok, false); +} + +{ + const caseRoot = path.join(tempRoot, 'backend'); + const repoRoot = path.join(caseRoot, 'repo'); + const cacheRoot = path.join(caseRoot, 'cache'); + await fsPromises.mkdir(repoRoot, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\n'); + await fsPromises.writeFile(path.join(repoRoot, 'beta.js'), 'const beta = 2;\n'); + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } + }); + + const runNodeCommand = (label, args) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + assert.equal(result.status, 0, `Failed: ${label}`); + }; + runNodeCommand('build_index', [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--scm-provider', + 'none', + '--repo', + repoRoot + ]); + runNodeCommand('build_lmdb_index', [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'code', '--repo', repoRoot]); + + const lmdbPaths = resolveLmdbPaths(repoRoot, {}); + const dbPath = lmdbPaths.codePath; + assert.ok(fs.existsSync(path.join(dbPath, 'data.mdb'))); + const db = open({ path: dbPath, readOnly: true }); + assert.equal(decode(db.get(LMDB_META_KEYS.schemaVersion)), LMDB_SCHEMA_VERSION); + assert.equal(decode(db.get(LMDB_META_KEYS.mode)), 'code'); + assert.ok(Number(decode(db.get(LMDB_META_KEYS.chunkCount)) || 0) > 0); + const mapSizeBytes = Number(decode(db.get(LMDB_META_KEYS.mapSizeBytes))); + const mapSizeEstimatedBytes = Number(decode(db.get(LMDB_META_KEYS.mapSizeEstimatedBytes))); + db.close(); + assert.ok(Number.isFinite(mapSizeBytes) && mapSizeBytes > 0); + assert.ok(Number.isFinite(mapSizeEstimatedBytes) && mapSizeEstimatedBytes >= 0); + assert.ok(mapSizeBytes >= mapSizeEstimatedBytes); + + const searchResult = runNode( + [path.join(root, 'search.js'), 'alpha', '--json', '--backend', 'lmdb', '--mode', 'code', '--no-ann', '--repo', repoRoot], + 'lmdb search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.equal(searchResult.status, 0); + const payload = JSON.parse(String(searchResult.stdout || '{}').trim()); + assert.equal(payload.backend, 'lmdb'); + + const dbWrite = open({ path: dbPath, readOnly: false }); + dbWrite.putSync(LMDB_META_KEYS.schemaVersion, encode(LMDB_SCHEMA_VERSION + 1)); + dbWrite.close(); + const badSearch = runNode( + [path.join(root, 'search.js'), 'alpha', '--json', '--backend', 'lmdb', '--mode', 'code', '--no-ann', '--repo', repoRoot], + 'lmdb bad schema search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.notEqual(badSearch.status, 0); + assert.ok(getCombinedOutput(badSearch).includes('schema mismatch')); +} + +console.log('lmdb contract matrix test passed'); diff --git a/tests/storage/lmdb/lmdb-backend.test.js b/tests/storage/lmdb/lmdb-backend.test.js deleted file mode 100644 index f25800863..000000000 --- a/tests/storage/lmdb/lmdb-backend.test.js +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { Packr, Unpackr } from 'msgpackr'; -import { LMDB_META_KEYS, LMDB_SCHEMA_VERSION } from '../../../src/storage/lmdb/schema.js'; -import { resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; -import { getCombinedOutput } from '../../helpers/stdio.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let open = null; -try { - ({ open } = await import('lmdb')); -} catch (err) { - console.error(`lmdb missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lmdb-backend'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\\n'); -await fsPromises.writeFile(path.join(repoRoot, 'beta.js'), 'const beta = 2;\\n'); - -const env = applyTestEnv({ - cacheRoot: cacheRoot, - embeddings: 'stub' -}); - -const runNode = (label, args) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -runNode('build_index', [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot]); -runNode('build_lmdb_index', [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'code', '--repo', repoRoot]); - -const lmdbPaths = resolveLmdbPaths(repoRoot, {}); -const dbPath = lmdbPaths.codePath; -const dataPath = path.join(dbPath, 'data.mdb'); -if (!fs.existsSync(dataPath)) { - console.error(`Expected LMDB data file to exist at ${dataPath}`); - process.exit(1); -} - -const db = open({ path: dbPath, readOnly: true }); -const unpackr = new Unpackr(); -const decode = (value) => (value == null ? null : unpackr.unpack(value)); -const version = decode(db.get(LMDB_META_KEYS.schemaVersion)); -if (version !== LMDB_SCHEMA_VERSION) { - console.error(`Expected LMDB schema version ${LMDB_SCHEMA_VERSION}, got ${version}`); - process.exit(1); -} -const mode = decode(db.get(LMDB_META_KEYS.mode)); -if (mode !== 'code') { - console.error(`Expected LMDB mode code, got ${mode}`); - process.exit(1); -} -const chunkCount = Number(decode(db.get(LMDB_META_KEYS.chunkCount)) || 0); -if (!Number.isFinite(chunkCount) || chunkCount <= 0) { - console.error('Expected LMDB chunkCount to be positive.'); - process.exit(1); -} -db.close(); - -const searchResult = spawnSync( - process.execPath, - [path.join(root, 'search.js'), 'alpha', '--json', '--backend', 'lmdb', '--mode', 'code', '--no-ann', '--repo', repoRoot], - { encoding: 'utf8', env } -); -if (searchResult.status !== 0) { - console.error('search.js failed for LMDB backend test.'); - process.exit(searchResult.status ?? 1); -} -const output = String(searchResult.stdout || '').trim(); -let payload = null; -try { - payload = JSON.parse(output); -} catch { - console.error('Failed to parse LMDB search JSON output.'); - process.exit(1); -} -if (payload.backend !== 'lmdb') { - console.error(`Expected backend=lmdb, got ${payload.backend}`); - process.exit(1); -} - -const dbWrite = open({ path: dbPath, readOnly: false }); -dbWrite.putSync(LMDB_META_KEYS.schemaVersion, new Packr().pack(LMDB_SCHEMA_VERSION + 1)); -dbWrite.close(); - -const badSearch = spawnSync( - process.execPath, - [path.join(root, 'search.js'), 'alpha', '--json', '--backend', 'lmdb', '--mode', 'code', '--no-ann', '--repo', repoRoot], - { encoding: 'utf8', env } -); -if (badSearch.status === 0) { - console.error('Expected lmdb search to fail on schema mismatch.'); - process.exit(1); -} -const badOutput = getCombinedOutput(badSearch); -if (!badOutput.includes('schema mismatch')) { - console.error('Expected lmdb schema mismatch error message.'); - process.exit(1); -} - -console.log('lmdb backend test passed'); - diff --git a/tests/storage/lmdb/lmdb-corruption.test.js b/tests/storage/lmdb/lmdb-corruption.test.js deleted file mode 100644 index c6ae31fe9..000000000 --- a/tests/storage/lmdb/lmdb-corruption.test.js +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { LMDB_META_KEYS } from '../../../src/storage/lmdb/schema.js'; -import { loadUserConfig, resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let open = null; -try { - ({ open } = await import('lmdb')); -} catch (err) { - console.error(`lmdb missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'lmdb-corruption'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot: cacheRoot, - embeddings: 'stub' -}); - -const run = (args, label, options = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - ...options - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result; -}; - -run( - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - 'build index', - { stdio: 'inherit' } -); -run( - [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'all', '--repo', repoRoot], - 'build lmdb index', - { stdio: 'inherit' } -); - -const userConfig = loadUserConfig(repoRoot); -const lmdbPaths = resolveLmdbPaths(repoRoot, userConfig); -const db = open({ path: lmdbPaths.codePath, readOnly: false }); -if (typeof db.removeSync === 'function') { - db.removeSync(LMDB_META_KEYS.schemaVersion); -} else { - db.remove(LMDB_META_KEYS.schemaVersion); -} -db.close(); - -const report = run( - [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', repoRoot], - 'report artifacts', - { encoding: 'utf8' } -); - -let payload = null; -try { - payload = JSON.parse(report.stdout || '{}'); -} catch { - console.error('Failed to parse report-artifacts JSON output.'); - process.exit(1); -} - -if (payload?.corruption?.ok !== false) { - console.error('Expected corruption report ok=false after LMDB tamper.'); - process.exit(1); -} -if (payload?.corruption?.lmdb?.ok !== false) { - console.error('Expected LMDB corruption report ok=false.'); - process.exit(1); -} -const issues = Array.isArray(payload?.corruption?.issues) ? payload.corruption.issues : []; -if (!issues.some((issue) => issue.includes('lmdb/code'))) { - console.error('Expected LMDB corruption issues for code db.'); - process.exit(1); -} -if (!issues.some((issue) => issue.includes('schema mismatch'))) { - console.error('Expected LMDB schema mismatch issue after tampering.'); - process.exit(1); -} - -console.log('lmdb corruption test passed'); - diff --git a/tests/storage/lmdb/lmdb-mapsize.test.js b/tests/storage/lmdb/lmdb-mapsize.test.js deleted file mode 100644 index f820120ae..000000000 --- a/tests/storage/lmdb/lmdb-mapsize.test.js +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { Unpackr } from 'msgpackr'; -import { LMDB_META_KEYS } from '../../../src/storage/lmdb/schema.js'; -import { resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -requireOrSkip({ capability: 'lmdb', reason: 'Skipping lmdb mapsize test; lmdb not available.' }); - -const { open } = await import('lmdb'); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lmdb-mapsize'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\\n'); - -const env = { - ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -applyTestEnv(); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const runNode = (label, args) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -runNode('build_index', [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot]); -runNode('build_lmdb_index', [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'code', '--repo', repoRoot]); - -const lmdbPaths = resolveLmdbPaths(repoRoot, {}); -const dbPath = lmdbPaths.codePath; -const db = open({ path: dbPath, readOnly: true }); -const unpackr = new Unpackr(); -const decode = (value) => (value == null ? null : unpackr.unpack(value)); -const mapSizeBytes = Number(decode(db.get(LMDB_META_KEYS.mapSizeBytes))); -const mapSizeEstimatedBytes = Number(decode(db.get(LMDB_META_KEYS.mapSizeEstimatedBytes))); -db.close(); - -if (!Number.isFinite(mapSizeBytes) || mapSizeBytes <= 0) { - console.error('Expected lmdb mapSizeBytes to be a positive number.'); - process.exit(1); -} -if (!Number.isFinite(mapSizeEstimatedBytes) || mapSizeEstimatedBytes < 0) { - console.error('Expected lmdb mapSizeEstimatedBytes to be a non-negative number.'); - process.exit(1); -} -if (mapSizeBytes < mapSizeEstimatedBytes) { - console.error(`Expected mapSizeBytes >= estimated bytes (${mapSizeBytes} < ${mapSizeEstimatedBytes}).`); - process.exit(1); -} - -console.log('lmdb mapSize meta test passed'); diff --git a/tests/storage/lmdb/lmdb-report-artifacts.test.js b/tests/storage/lmdb/lmdb-report-artifacts.test.js deleted file mode 100644 index a4533410a..000000000 --- a/tests/storage/lmdb/lmdb-report-artifacts.test.js +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { Packr, Unpackr } from 'msgpackr'; -import { LMDB_ARTIFACT_KEYS, LMDB_META_KEYS } from '../../../src/storage/lmdb/schema.js'; -import { loadUserConfig, resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let open = null; -try { - ({ open } = await import('lmdb')); -} catch (err) { - console.error(`lmdb missing: ${err?.message || err}`); - process.exit(1); -} -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'lmdb-report-artifacts'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - lmdb: { use: true } - } -}); - -const run = (args, label, options = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - ...options - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result; -}; - -run( - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], - 'build index', - { stdio: 'inherit' } -); - -run( - [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'all', '--repo', repoRoot], - 'build lmdb index', - { stdio: 'inherit' } -); - -const report = run( - [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', repoRoot], - 'report artifacts', - { encoding: 'utf8' } -); - -let payload = null; -try { - payload = JSON.parse(report.stdout || '{}'); -} catch { - console.error('Failed to parse report-artifacts JSON output.'); - process.exit(1); -} - -const lmdbThroughput = payload?.throughput?.lmdb; -if (!lmdbThroughput?.code || !Number.isFinite(lmdbThroughput.code.chunksPerSec)) { - console.error('LMDB code throughput missing or invalid in report-artifacts.'); - process.exit(1); -} -if (!lmdbThroughput?.prose || !Number.isFinite(lmdbThroughput.prose.chunksPerSec)) { - console.error('LMDB prose throughput missing or invalid in report-artifacts.'); - process.exit(1); -} -if (payload?.corruption?.lmdb?.ok !== true) { - console.error('LMDB corruption report expected ok=true.'); - process.exit(1); -} - -const userConfig = loadUserConfig(repoRoot); -const lmdbPaths = resolveLmdbPaths(repoRoot, userConfig); -const lmdbDb = open({ path: lmdbPaths.codePath, readOnly: false }); -const unpackr = new Unpackr(); -const packr = new Packr(); -const decode = (value) => (value == null ? null : unpackr.unpack(value)); -const artifacts = decode(lmdbDb.get(LMDB_META_KEYS.artifacts)) || []; -const filtered = artifacts.filter((key) => key !== LMDB_ARTIFACT_KEYS.tokenPostings); -lmdbDb.putSync(LMDB_META_KEYS.artifacts, packr.pack(filtered)); -lmdbDb.close(); - -const reportMissing = run( - [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', repoRoot], - 'report artifacts (missing lmdb key)', - { encoding: 'utf8' } -); -let payloadMissing = null; -try { - payloadMissing = JSON.parse(reportMissing.stdout || '{}'); -} catch { - console.error('Failed to parse report-artifacts JSON output (missing key).'); - process.exit(1); -} -const issues = Array.isArray(payloadMissing?.corruption?.issues) ? payloadMissing.corruption.issues : []; -if (!issues.some((issue) => issue.includes('missing artifact key') && issue.includes('token_postings'))) { - console.error('Expected missing artifact key issue for LMDB token_postings.'); - process.exit(1); -} - -console.log('lmdb report artifacts test passed'); diff --git a/tests/storage/lmdb/lmdb-utils-contract.test.js b/tests/storage/lmdb/lmdb-utils-contract.test.js deleted file mode 100644 index 6761a5ffd..000000000 --- a/tests/storage/lmdb/lmdb-utils-contract.test.js +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { Packr } from 'msgpackr'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { LMDB_META_KEYS, LMDB_SCHEMA_VERSION } from '../../../src/storage/lmdb/schema.js'; -import { - createLmdbCodec, - decodeLmdbValue, - hasLmdbStore, - validateLmdbArtifactKeys, - validateLmdbSchemaAndMode -} from '../../../src/storage/lmdb/utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const packr = new Packr(); -const encode = (value) => packr.pack(value); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lmdb-utils-contract'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const missingStore = path.join(tempRoot, 'missing-store'); -assert.equal(hasLmdbStore(missingStore), false); -await fs.mkdir(missingStore, { recursive: true }); -assert.equal(hasLmdbStore(missingStore), false); -await fs.writeFile(path.join(missingStore, 'data.mdb'), Buffer.from('x')); -assert.equal(hasLmdbStore(missingStore), true); - -const sample = { ok: true, nested: { count: 2 } }; -const codec = createLmdbCodec(); -assert.deepEqual(codec.decode(encode(sample)), sample); -assert.deepEqual(decodeLmdbValue(encode(sample)), sample); - -const metadata = new Map([ - [LMDB_META_KEYS.schemaVersion, encode(LMDB_SCHEMA_VERSION)], - [LMDB_META_KEYS.mode, encode('code')], - [LMDB_META_KEYS.artifacts, encode(['chunk_meta', 'token_postings'])], - ['chunk_meta', encode([{ id: 1 }])], - ['token_postings', encode({ vocab: [], postings: [] })] -]); -const db = { - get(key) { - return metadata.has(key) ? metadata.get(key) : null; - } -}; - -const schemaOk = validateLmdbSchemaAndMode({ db, label: 'code', decode: decodeLmdbValue }); -assert.equal(schemaOk.ok, true); -assert.equal(schemaOk.issues.length, 0); - -metadata.set(LMDB_META_KEYS.mode, encode('prose')); -const schemaMismatch = validateLmdbSchemaAndMode({ db, label: 'code', decode: decodeLmdbValue }); -assert.equal(schemaMismatch.ok, false); -assert.equal(schemaMismatch.issues.some((issue) => issue.includes('mode mismatch')), true); -metadata.set(LMDB_META_KEYS.mode, encode('code')); - -const artifactOk = validateLmdbArtifactKeys({ - db, - requiredKeys: ['chunk_meta', 'token_postings'], - decode: decodeLmdbValue -}); -assert.equal(artifactOk.ok, true); -assert.equal(artifactOk.missingMeta, false); - -metadata.set(LMDB_META_KEYS.artifacts, encode(['chunk_meta'])); -const artifactMismatch = validateLmdbArtifactKeys({ - db, - requiredKeys: ['chunk_meta', 'token_postings'], - decode: decodeLmdbValue -}); -assert.equal(artifactMismatch.ok, false); -assert.equal(artifactMismatch.missingArtifactKeys.includes('token_postings'), true); -assert.equal(artifactMismatch.missingArtifactValues.includes('token_postings'), false); - -metadata.delete('token_postings'); -metadata.set(LMDB_META_KEYS.artifacts, encode(['chunk_meta', 'token_postings'])); -const artifactMissingValue = validateLmdbArtifactKeys({ - db, - requiredKeys: ['chunk_meta', 'token_postings'], - decode: decodeLmdbValue -}); -assert.equal(artifactMissingValue.ok, false); -assert.equal(artifactMissingValue.missingArtifactValues.includes('token_postings'), true); - -console.log('lmdb utils contract test passed'); diff --git a/tests/storage/lmdb/report-contract-matrix.test.js b/tests/storage/lmdb/report-contract-matrix.test.js new file mode 100644 index 000000000..f631c3e3e --- /dev/null +++ b/tests/storage/lmdb/report-contract-matrix.test.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { Packr, Unpackr } from 'msgpackr'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { LMDB_ARTIFACT_KEYS, LMDB_META_KEYS } from '../../../src/storage/lmdb/schema.js'; +import { loadUserConfig, resolveLmdbPaths } from '../../../tools/shared/dict-utils.js'; + +let open = null; +try { + ({ open } = await import('lmdb')); +} catch (err) { + console.error(`lmdb missing: ${err?.message || err}`); + process.exit(1); +} + +const packr = new Packr(); +const unpackr = new Unpackr(); +const decode = (value) => (value == null ? null : unpackr.unpack(value)); + +const root = process.cwd(); +const createTestConfig = (extraTestConfig = null) => ({ + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + }, + lmdb: { + use: true + }, + ...(extraTestConfig || {}) +}); + +const createFixture = async (name, extraTestConfig = null) => { + const tempRoot = resolveTestCachePath(root, name); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'sample.js'), + [ + 'export function greet(name = "world") {', + ' return `hello ${name}`;', + '}', + '' + ].join('\n'), + 'utf8' + ); + await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# LMDB report fixture\n\nhello prose fixture\n', + 'utf8' + ); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: createTestConfig(extraTestConfig), + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } + }); + + const run = (args, label, options = {}) => { + const result = runNode(args, label, repoRoot, env, { + stdio: options.stdio || 'pipe', + encoding: options.encoding || 'utf8', + allowFailure: true + }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); + } + return result; + }; + + run( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--repo', + repoRoot + ], + 'build index', + { stdio: 'inherit' } + ); + run( + [path.join(root, 'tools', 'build/lmdb-index.js'), '--mode', 'all', '--repo', repoRoot], + 'build lmdb index', + { stdio: 'inherit' } + ); + + return { + tempRoot, + repoRoot, + run, + lmdbPaths: resolveLmdbPaths(repoRoot, loadUserConfig(repoRoot)) + }; +}; + +const snapshotPathTree = async (sourcePath, snapshotPath) => { + await fsPromises.rm(snapshotPath, { recursive: true, force: true }); + await fsPromises.cp(sourcePath, snapshotPath, { recursive: true }); +}; + +const restorePathTree = async (snapshotPath, targetPath) => { + await fsPromises.rm(targetPath, { recursive: true, force: true }); + await fsPromises.cp(snapshotPath, targetPath, { recursive: true }); +}; + +const runHealthyReportScenario = async (fixture) => { + const report = fixture.run( + [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', fixture.repoRoot], + 'report artifacts' + ); + + let payload = null; + try { + payload = JSON.parse(report.stdout || '{}'); + } catch { + throw new Error('Failed to parse report-artifacts JSON output.'); + } + + const lmdbThroughput = payload?.throughput?.lmdb; + if (!lmdbThroughput?.code || !Number.isFinite(lmdbThroughput.code.chunksPerSec)) { + throw new Error('LMDB code throughput missing or invalid in report-artifacts.'); + } + if (!lmdbThroughput?.prose || !Number.isFinite(lmdbThroughput.prose.chunksPerSec)) { + throw new Error('LMDB prose throughput missing or invalid in report-artifacts.'); + } + if (payload?.corruption?.lmdb?.ok !== true) { + throw new Error('LMDB corruption report expected ok=true.'); + } + + const lmdbDb = open({ path: fixture.lmdbPaths.codePath, readOnly: false }); + const artifacts = decode(lmdbDb.get(LMDB_META_KEYS.artifacts)) || []; + const filtered = artifacts.filter((key) => key !== LMDB_ARTIFACT_KEYS.tokenPostings); + lmdbDb.putSync(LMDB_META_KEYS.artifacts, packr.pack(filtered)); + lmdbDb.close(); + + const reportMissing = fixture.run( + [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', fixture.repoRoot], + 'report artifacts (missing lmdb key)' + ); + let payloadMissing = null; + try { + payloadMissing = JSON.parse(reportMissing.stdout || '{}'); + } catch { + throw new Error('Failed to parse report-artifacts JSON output (missing key).'); + } + const issues = Array.isArray(payloadMissing?.corruption?.issues) ? payloadMissing.corruption.issues : []; + if (!issues.some((issue) => issue.includes('missing artifact key') && issue.includes('token_postings'))) { + throw new Error('Expected missing artifact key issue for LMDB token_postings.'); + } +}; + +const runCorruptionScenario = async (fixture) => { + const db = open({ path: fixture.lmdbPaths.codePath, readOnly: false }); + if (typeof db.removeSync === 'function') { + db.removeSync(LMDB_META_KEYS.schemaVersion); + } else { + db.remove(LMDB_META_KEYS.schemaVersion); + } + db.close(); + + const report = fixture.run( + [path.join(root, 'tools', 'index', 'report-artifacts.js'), '--json', '--repo', fixture.repoRoot], + 'report artifacts' + ); + + let payload = null; + try { + payload = JSON.parse(report.stdout || '{}'); + } catch { + throw new Error('Failed to parse report-artifacts JSON output.'); + } + + if (payload?.corruption?.ok !== false) { + throw new Error('Expected corruption report ok=false after LMDB tamper.'); + } + if (payload?.corruption?.lmdb?.ok !== false) { + throw new Error('Expected LMDB corruption report ok=false.'); + } + const issues = Array.isArray(payload?.corruption?.issues) ? payload.corruption.issues : []; + if (!issues.some((issue) => issue.includes('lmdb/code'))) { + throw new Error('Expected LMDB corruption issues for code db.'); + } + if (!issues.some((issue) => issue.includes('schema mismatch'))) { + throw new Error('Expected LMDB schema mismatch issue after tampering.'); + } +}; + +const fixture = await createFixture('lmdb-report-contract-matrix'); +const codeSnapshotPath = path.join(fixture.tempRoot, 'code-snapshot'); +await snapshotPathTree(fixture.lmdbPaths.codePath, codeSnapshotPath); + +try { + await runHealthyReportScenario(fixture); + await restorePathTree(codeSnapshotPath, fixture.lmdbPaths.codePath); + await runCorruptionScenario(fixture); +} catch (error) { + console.error('lmdb report contract matrix failed'); + console.error(error?.stack || error?.message || String(error)); + process.exit(1); +} + +console.log('lmdb report contract matrix passed (2 cases)'); diff --git a/tests/storage/sqlite/ann/sqlite-ann-extension.test.js b/tests/storage/sqlite/ann/sqlite-ann-extension.test.js deleted file mode 100644 index 2f6b8106e..000000000 --- a/tests/storage/sqlite/ann/sqlite-ann-extension.test.js +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadUserConfig, resolveSqlitePaths } from '../../../../tools/shared/dict-utils.js'; -import { - getVectorExtensionConfig, - resolveVectorExtensionConfigForMode -} from '../../../../tools/sqlite/vector-extension.js'; -import { requireSqliteVec } from '../../../helpers/optional-deps.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-ann-extension'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const deletableFile = path.join(repoRoot, 'src', 'ann_deletable.js'); -await fsPromises.writeFile( - deletableFile, - 'export const annDeletable = "ann_deletable_token";\n' -); - -const extensionPath = requireSqliteVec({ repoRoot }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - sqlite: { - vectorExtension: { - annMode: 'extension', - enabled: true, - path: extensionPath - } - } - }, - extraEnv: { PAIROFCLEATS_BUNDLE_THREADS: '1' } -}); - -function run(args, label) { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -} - -run([path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], 'build index'); -await runSqliteBuild(repoRoot); - -const userConfig = loadUserConfig(repoRoot); -const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -const sqliteSharedDb = Boolean( - sqlitePaths?.codePath - && sqlitePaths?.prosePath - && path.resolve(sqlitePaths.codePath) === path.resolve(sqlitePaths.prosePath) -); -const vectorExtension = getVectorExtensionConfig(repoRoot, userConfig); -const codeVectorExtension = resolveVectorExtensionConfigForMode(vectorExtension, 'code', { - sharedDb: sqliteSharedDb -}); -const annTableName = codeVectorExtension?.table || 'dense_vectors_ann'; - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite-ann-extension test.'); - process.exit(1); -} - -const db = new Database(sqlitePaths.codePath, { readonly: true }); -try { - db.loadExtension(extensionPath); -} catch (err) { - console.error(`Failed to load sqlite ann extension for verification: ${err?.message || err}`); - process.exit(1); -} -const table = db.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name = ?" -).get(annTableName); -if (!table) { - console.error(`sqlite ann extension table missing: ${annTableName}`); - process.exit(1); -} -const countRow = db.prepare(`SELECT COUNT(*) AS count FROM ${annTableName}`).get(); -if (!countRow?.count) { - console.error(`sqlite ann extension table empty: ${annTableName}`); - process.exit(1); -} -const denseCountBefore = db.prepare( - 'SELECT COUNT(*) AS count FROM dense_vectors WHERE mode = ?' -).get('code'); -const annCountBefore = countRow.count; -db.close(); - -const searchResult = spawnSync( - process.execPath, - [ - path.join(root, 'search.js'), - 'index', - '--json', - '--stats', - '--ann', - '--ann-backend', - 'sqlite-extension', - '--repo', - repoRoot - ], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('search.js failed for sqlite ann extension test.'); - if (searchResult.stderr) console.error(searchResult.stderr.trim()); - process.exit(searchResult.status ?? 1); -} - -const payload = JSON.parse(searchResult.stdout || '{}'); -const stats = payload.stats || {}; -if (stats.annBackend !== 'sqlite-extension') { - console.error(`Expected annBackend=sqlite-extension, got ${stats.annBackend}`); - process.exit(1); -} -if (stats.annMode !== 'extension') { - console.error(`Expected annMode=extension, got ${stats.annMode}`); - process.exit(1); -} -if (!stats.annExtension?.available?.code) { - console.error('Expected sqlite ann extension available for code.'); - process.exit(1); -} - -await fsPromises.rm(deletableFile, { force: true }); -run([path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], 'build index (incremental)'); -await runSqliteBuild(repoRoot, { mode: 'code', incremental: true }); - -const sqlitePathsAfter = resolveSqlitePaths(repoRoot, userConfig); -const dbAfter = new Database(sqlitePathsAfter.codePath, { readonly: true }); -try { - dbAfter.loadExtension(extensionPath); -} catch (err) { - console.error(`Failed to load sqlite ann extension for incremental verification: ${err?.message || err}`); - process.exit(1); -} -const denseCountAfter = dbAfter.prepare( - 'SELECT COUNT(*) AS count FROM dense_vectors WHERE mode = ?' -).get('code'); -const annCountAfter = dbAfter.prepare( - `SELECT COUNT(*) AS count FROM ${annTableName}` -).get()?.count; -if (Number(annCountAfter) !== Number(denseCountAfter?.count)) { - console.error(`Dense vector count mismatch after incremental update: dense=${denseCountAfter?.count} ann=${annCountAfter}`); - process.exit(1); -} -if (denseCountBefore?.count && denseCountAfter?.count >= denseCountBefore.count) { - console.error('Expected dense vector count to drop after deletion.'); - process.exit(1); -} -const orphanRow = dbAfter.prepare( - `SELECT COUNT(*) AS count FROM ${annTableName} WHERE rowid NOT IN (SELECT doc_id FROM dense_vectors WHERE mode = ?)` -).get('code'); -if (orphanRow?.count) { - console.error(`Found ${orphanRow.count} orphaned ann rows after deletion.`); - process.exit(1); -} -dbAfter.close(); - -console.log('sqlite ann extension test passed'); diff --git a/tests/storage/sqlite/ann/sqlite-ann-fallback.test.js b/tests/storage/sqlite/ann/sqlite-ann-fallback.test.js deleted file mode 100644 index 2efc67cdc..000000000 --- a/tests/storage/sqlite/ann/sqlite-ann-fallback.test.js +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadUserConfig, getIndexDir } from '../../../../tools/shared/dict-utils.js'; -import { loadChunkMeta, MAX_JSON_BYTES } from '../../../../src/shared/artifact-io.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const suffixRaw = typeof process.env.PAIROFCLEATS_TEST_CACHE_SUFFIX === 'string' - ? process.env.PAIROFCLEATS_TEST_CACHE_SUFFIX.trim() - : ''; -const cacheName = suffixRaw ? `sqlite-ann-fallback-${suffixRaw}` : 'sqlite-ann-fallback'; -const tempRoot = resolveTestCachePath(root, cacheName); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'alpha.js'), - 'export const alpha = () => "ann_fallback_token";\n' -); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub' -}); - -const runNode = (label, args) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -runNode('build_index', [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot]); -await runSqliteBuild(repoRoot); - -const searchResult = spawnSync( - process.execPath, - [path.join(root, 'search.js'), 'ann_fallback_token', '--ann', '--json', '--repo', repoRoot], - { env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('sqlite ann fallback test failed: search returned error'); - if (searchResult.stderr) console.error(searchResult.stderr.trim()); - process.exit(searchResult.status ?? 1); -} - -let payload = null; -try { - payload = JSON.parse(searchResult.stdout || '{}'); -} catch { - console.error('sqlite ann fallback test failed: invalid JSON output'); - process.exit(1); -} - -const hits = payload?.code || []; -if (!hits.length) { - console.error('sqlite ann fallback test failed: no results returned'); - process.exit(1); -} -if (payload?.stats?.annBackend === 'sqlite-extension') { - console.error('sqlite ann fallback test failed: ann backend should not be sqlite-extension'); - process.exit(1); -} -if (payload?.stats?.annExtension?.available?.code) { - console.error('sqlite ann fallback test failed: ann extension should be unavailable'); - process.exit(1); -} - -const userConfig = loadUserConfig(repoRoot); -const indexDir = getIndexDir(repoRoot, 'code', userConfig); -const chunkMeta = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); -const maxId = Array.isArray(chunkMeta) ? chunkMeta.length - 1 : -1; -for (const hit of hits) { - if (!Number.isFinite(hit?.id) || hit.id < 0 || hit.id > maxId) { - console.error(`sqlite ann fallback test failed: out-of-range doc id ${hit?.id}`); - process.exit(1); - } -} - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -console.log('sqlite ann fallback test passed'); - diff --git a/tests/storage/sqlite/ann/sqlite-extension.test.js b/tests/storage/sqlite/ann/sqlite-extension.test.js new file mode 100644 index 000000000..b8dd81b54 --- /dev/null +++ b/tests/storage/sqlite/ann/sqlite-extension.test.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../../helpers/test-env.js'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { loadUserConfig, resolveSqlitePaths } from '../../../../tools/shared/dict-utils.js'; +import { + getVectorExtensionConfig, + resolveVectorExtensionConfigForMode +} from '../../../../tools/sqlite/vector-extension.js'; +import { requireSqliteVec } from '../../../helpers/optional-deps.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-ann-extension'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'index.js'), + [ + 'export function annPrimary() {', + ' return "index token";', + '}', + '' + ].join('\n'), + 'utf8' +); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'secondary.js'), + [ + 'export const annSecondary = "sqlite extension vector token";', + '' + ].join('\n'), + 'utf8' +); + +const deletableFile = path.join(repoRoot, 'src', 'ann_deletable.js'); +await fsPromises.writeFile( + deletableFile, + 'export const annDeletable = "ann_deletable_token";\n' +); + +const extensionPath = requireSqliteVec({ repoRoot }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + sqlite: { + vectorExtension: { + annMode: 'extension', + enabled: true, + path: extensionPath + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { PAIROFCLEATS_BUNDLE_THREADS: '1' } +}); + +function run(args, label) { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +} + +const runEmbeddings = (label) => run( + [ + path.join(root, 'tools', 'build', 'embeddings.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ], + label +); + +run( + [ + path.join(root, 'build_index.js'), + '--incremental', + '--stage', + 'stage1', + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'build index' +); +runEmbeddings('build embeddings'); +await runSqliteBuild(repoRoot, { mode: 'code' }); + +const userConfig = loadUserConfig(repoRoot); +const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); +const sqliteSharedDb = Boolean( + sqlitePaths?.codePath + && sqlitePaths?.prosePath + && path.resolve(sqlitePaths.codePath) === path.resolve(sqlitePaths.prosePath) +); +const vectorExtension = getVectorExtensionConfig(repoRoot, userConfig); +const codeVectorExtension = resolveVectorExtensionConfigForMode(vectorExtension, 'code', { + sharedDb: sqliteSharedDb +}); +const annTableName = codeVectorExtension?.table || 'dense_vectors_ann'; + +let Database; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch { + console.error('better-sqlite3 is required for sqlite-ann-extension test.'); + process.exit(1); +} + +const db = new Database(sqlitePaths.codePath, { readonly: true }); +try { + db.loadExtension(extensionPath); +} catch (err) { + console.error(`Failed to load sqlite ann extension for verification: ${err?.message || err}`); + process.exit(1); +} +const table = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?" +).get(annTableName); +if (!table) { + console.error(`sqlite ann extension table missing: ${annTableName}`); + process.exit(1); +} +const countRow = db.prepare(`SELECT COUNT(*) AS count FROM ${annTableName}`).get(); +if (!countRow?.count) { + console.error(`sqlite ann extension table empty: ${annTableName}`); + process.exit(1); +} +const denseCountBefore = db.prepare( + 'SELECT COUNT(*) AS count FROM dense_vectors WHERE mode = ?' +).get('code'); +const annCountBefore = countRow.count; +db.close(); + +const searchResult = runNode( + [ + path.join(root, 'search.js'), + 'index', + '--json', + '--stats', + '--mode', + 'code', + '--ann', + '--ann-backend', + 'sqlite-extension', + '--repo', + repoRoot + ], + 'sqlite ann extension search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (searchResult.status !== 0) { + console.error('search.js failed for sqlite ann extension test.'); + if (searchResult.stderr) console.error(searchResult.stderr.trim()); + process.exit(searchResult.status ?? 1); +} + +const payload = JSON.parse(searchResult.stdout || '{}'); +const stats = payload.stats || {}; +if (stats.annBackend !== 'sqlite-extension') { + console.error(`Expected annBackend=sqlite-extension, got ${stats.annBackend}`); + process.exit(1); +} +if (stats.annMode !== 'extension') { + console.error(`Expected annMode=extension, got ${stats.annMode}`); + process.exit(1); +} +if (!stats.annExtension?.available?.code) { + console.error('Expected sqlite ann extension available for code.'); + process.exit(1); +} + +await fsPromises.rm(deletableFile, { force: true }); +run( + [ + path.join(root, 'build_index.js'), + '--incremental', + '--stage', + 'stage1', + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'build index (incremental)' +); +runEmbeddings('build embeddings (incremental)'); +await runSqliteBuild(repoRoot, { mode: 'code', incremental: true }); + +const sqlitePathsAfter = resolveSqlitePaths(repoRoot, userConfig); +const dbAfter = new Database(sqlitePathsAfter.codePath, { readonly: true }); +try { + dbAfter.loadExtension(extensionPath); +} catch (err) { + console.error(`Failed to load sqlite ann extension for incremental verification: ${err?.message || err}`); + process.exit(1); +} +const denseCountAfter = dbAfter.prepare( + 'SELECT COUNT(*) AS count FROM dense_vectors WHERE mode = ?' +).get('code'); +const annCountAfter = dbAfter.prepare( + `SELECT COUNT(*) AS count FROM ${annTableName}` +).get()?.count; +if (Number(annCountAfter) !== Number(denseCountAfter?.count)) { + console.error(`Dense vector count mismatch after incremental update: dense=${denseCountAfter?.count} ann=${annCountAfter}`); + process.exit(1); +} +if (denseCountBefore?.count && denseCountAfter?.count >= denseCountBefore.count) { + console.error('Expected dense vector count to drop after deletion.'); + process.exit(1); +} +const orphanRow = dbAfter.prepare( + `SELECT COUNT(*) AS count FROM ${annTableName} WHERE rowid NOT IN (SELECT doc_id FROM dense_vectors WHERE mode = ?)` +).get('code'); +if (orphanRow?.count) { + console.error(`Found ${orphanRow.count} orphaned ann rows after deletion.`); + process.exit(1); +} +dbAfter.close(); + +console.log('sqlite ann extension test passed'); diff --git a/tests/storage/sqlite/ann/sqlite-fallback.test.js b/tests/storage/sqlite/ann/sqlite-fallback.test.js new file mode 100644 index 000000000..880d1f936 --- /dev/null +++ b/tests/storage/sqlite/ann/sqlite-fallback.test.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { loadUserConfig, getIndexDir } from '../../../../tools/shared/dict-utils.js'; +import { loadChunkMeta, MAX_JSON_BYTES } from '../../../../src/shared/artifact-io.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const root = process.cwd(); +const suffixRaw = typeof process.env.PAIROFCLEATS_TEST_CACHE_SUFFIX === 'string' + ? process.env.PAIROFCLEATS_TEST_CACHE_SUFFIX.trim() + : ''; +const cacheName = suffixRaw ? `sqlite-ann-fallback-${suffixRaw}` : 'sqlite-ann-fallback'; +const tempRoot = resolveTestCachePath(root, cacheName); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); + +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'alpha.js'), + 'export const alpha = () => "ann_fallback_token";\n' +); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + scm: { provider: 'none' } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const runChildNode = (label, args) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +runChildNode('build_index', [ + path.join(root, 'build_index.js'), + '--stage', + 'stage2', + '--mode', + 'code', + '--stub-embeddings', + '--repo', + repoRoot +]); +await runSqliteBuild(repoRoot, { mode: 'code', env, emitOutput: false }); + +const searchResult = runNode( + [path.join(root, 'search.js'), 'ann_fallback_token', '--mode', 'code', '--ann', '--json', '--repo', repoRoot], + 'sqlite ann fallback search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +if (searchResult.status !== 0) { + console.error('sqlite ann fallback test failed: search returned error'); + if (searchResult.stderr) console.error(searchResult.stderr.trim()); + process.exit(searchResult.status ?? 1); +} + +let payload = null; +try { + payload = JSON.parse(searchResult.stdout || '{}'); +} catch { + console.error('sqlite ann fallback test failed: invalid JSON output'); + process.exit(1); +} + +const hits = payload?.code || []; +if (!hits.length) { + console.error('sqlite ann fallback test failed: no results returned'); + process.exit(1); +} +if (payload?.stats?.annBackend === 'sqlite-extension') { + console.error('sqlite ann fallback test failed: ann backend should not be sqlite-extension'); + process.exit(1); +} +if (payload?.stats?.annExtension?.available?.code) { + console.error('sqlite ann fallback test failed: ann extension should be unavailable'); + process.exit(1); +} + +const userConfig = loadUserConfig(repoRoot); +const indexDir = getIndexDir(repoRoot, 'code', userConfig); +const chunkMeta = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); +const maxId = Array.isArray(chunkMeta) ? chunkMeta.length - 1 : -1; +for (const hit of hits) { + if (!Number.isFinite(hit?.id) || hit.id < 0 || hit.id > maxId) { + console.error(`sqlite ann fallback test failed: out-of-range doc id ${hit?.id}`); + process.exit(1); + } +} + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +console.log('sqlite ann fallback test passed'); + diff --git a/tests/storage/sqlite/ann/sqlite-ann-mode-scope.test.js b/tests/storage/sqlite/ann/sqlite-mode-scope.test.js similarity index 100% rename from tests/storage/sqlite/ann/sqlite-ann-mode-scope.test.js rename to tests/storage/sqlite/ann/sqlite-mode-scope.test.js diff --git a/tests/storage/sqlite/ann/sqlite-vec-candidate-set.test.js b/tests/storage/sqlite/ann/sqlite-vec-candidate-set.test.js index 185bcdb91..985a740f8 100644 --- a/tests/storage/sqlite/ann/sqlite-vec-candidate-set.test.js +++ b/tests/storage/sqlite/ann/sqlite-vec-candidate-set.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert'; import { queryVectorAnn } from '../../../../tools/sqlite/vector-extension.js'; -import { getMetricsText } from '../../../../src/shared/metrics.js'; +import { getMetricsText } from '../../../../src/shared/metrics/core.js'; const config = { enabled: true, diff --git a/tests/storage/sqlite/batch-size-adaptive.test.js b/tests/storage/sqlite/batch-size-adaptive.test.js new file mode 100644 index 000000000..915ba1378 --- /dev/null +++ b/tests/storage/sqlite/batch-size-adaptive.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveSqliteBatchSize, resolveSqliteIngestPlan } from '../../../src/storage/sqlite/utils.js'; + +const MB = 1024 * 1024; + +assert.equal(resolveSqliteBatchSize({ batchSize: 10 }), 50, 'min clamp expected'); +assert.equal(resolveSqliteBatchSize({ batchSize: 5000 }), 2000, 'max clamp expected'); + +assert.equal(resolveSqliteBatchSize({ inputBytes: 3000 * MB }), 200, 'large inputBytes should reduce batch size'); +assert.equal(resolveSqliteBatchSize({ inputBytes: 700 * MB }), 400, 'mid inputBytes should reduce batch size'); +assert.equal(resolveSqliteBatchSize({ inputBytes: 200 * MB }), 700, 'smaller inputBytes should reduce batch size'); +assert.equal(resolveSqliteBatchSize({ inputBytes: 10 * MB }), 1000, 'small inputBytes should keep default'); + +assert.equal( + resolveSqliteBatchSize({ inputBytes: 200 * MB, rowCount: 1_000_000 }), + 200, + 'rowCount should cap batch size' +); +assert.equal( + resolveSqliteBatchSize({ inputBytes: 200 * MB, rowCount: 100_000 }), + 700, + 'rowCount should not increase batch size' +); + +const walPlan = resolveSqliteIngestPlan({ + inputBytes: 200 * MB, + pageSize: 4096, + journalMode: 'wal', + walEnabled: true, + walBytes: 32 * MB, + rowCount: 100_000, + fileCount: 500 +}); +assert.equal(walPlan.telemetry.planVersion, 1, 'expected telemetry plan version'); +assert.equal(walPlan.telemetry.walPressure, 'medium', 'expected telemetry wal pressure'); +assert.ok( + walPlan.telemetry.batchAdjustments.some((entry) => entry.reason === 'wal_pressure_medium'), + 'expected telemetry batch adjustments to retain wal-pressure rationale' +); + +const requestedPlan = resolveSqliteIngestPlan({ + batchSize: { + requested: 321, + pageSize: 4096, + journalMode: 'wal', + walEnabled: true, + walBytes: 12 * MB + } +}); +assert.equal(requestedPlan.batchSize, 321, 'expected requested override to win'); +assert.equal(requestedPlan.telemetry.requestedOverride, true, 'expected requested override flag'); +assert.deepEqual( + requestedPlan.telemetry.batchAdjustments, + [{ kind: 'requested', factor: 1, reason: 'requested_batch_size' }], + 'expected requested override rationale to be preserved' +); + +console.log('sqlite batch size adaptive test passed'); diff --git a/tests/storage/sqlite/build-bench-contract.test.js b/tests/storage/sqlite/build-bench-contract.test.js new file mode 100644 index 000000000..9966b6de4 --- /dev/null +++ b/tests/storage/sqlite/build-bench-contract.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { setupSqliteBuildFixture } from './helpers/build-fixture.js'; + +const root = process.cwd(); +const env = applyTestEnv({ syncProcess: false }); +const fixture = await setupSqliteBuildFixture({ + tempLabel: 'sqlite-build-bench-contract', + chunkCount: 50, + fileCount: 3, + mode: 'code' +}); + +const benchScript = path.join(root, 'tools', 'bench', 'sqlite', 'build-from-artifacts.js'); +const result = runNode( + [ + benchScript, + '--mode', + 'current', + '--index-dir', + fixture.indexDir, + '--statement-strategy', + 'prepared' + ], + 'sqlite build bench contract', + root, + env, + { stdio: 'pipe', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stdout || ''); + console.error(result.stderr || ''); + process.exit(result.status ?? 1); +} + +const output = `${result.stdout || ''}${result.stderr || ''}`; +assert.match(output, /\[bench\] build-from-artifacts current chunks=/, 'expected bench to report run'); +assert.match(output, /\[bench\] current statementStrategy=/, 'expected bench to print strategy line'); +assert.match(output, /\[bench\] current tables/, 'expected bench to print per-table stats'); + +console.log('sqlite build bench contract test passed'); + diff --git a/tests/storage/sqlite/sqlite-build-delete.test.js b/tests/storage/sqlite/build-delete.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-build-delete.test.js rename to tests/storage/sqlite/build-delete.test.js diff --git a/tests/storage/sqlite/build-full-transaction.test.js b/tests/storage/sqlite/build-full-transaction.test.js new file mode 100644 index 000000000..5d3e0a7f7 --- /dev/null +++ b/tests/storage/sqlite/build-full-transaction.test.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './helpers/build-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const Database = await loadDatabaseCtor(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-build-full-transaction'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 2000; +const tokens = ['alpha', 'beta']; +const { pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 5, + tokens, + tokenVocab: ['alpha'], + chunkMaxBytes: 8192 +}); +await writePiecesManifest(indexDir, pieceEntries); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); + +const execCalls = []; +class InstrumentedDatabase extends Database { + exec(sql) { + execCalls.push(String(sql || '').trim()); + return super.exec(sql); + } +} + +const stats = {}; +const count = await buildDatabaseFromArtifacts({ + Database: InstrumentedDatabase, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + stats, + statementStrategy: 'prepared' +}); +assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); +assert.ok(fsSync.existsSync(outPath), 'expected sqlite DB to be created'); + +const beginCount = execCalls.filter((call) => call === 'BEGIN').length; +const commitCount = execCalls.filter((call) => call === 'COMMIT').length; +const rollbackCount = execCalls.filter((call) => call === 'ROLLBACK').length; +assert.equal(beginCount, 1, 'expected exactly one explicit BEGIN in full build'); +assert.equal(commitCount, 1, 'expected exactly one explicit COMMIT in full build'); +assert.equal(rollbackCount, 0, 'expected no ROLLBACK in successful full build'); + +assert.equal(stats?.transaction?.begin, 1, 'expected stats.transaction.begin=1'); +assert.equal(stats?.transaction?.commit, 1, 'expected stats.transaction.commit=1'); +assert.equal(stats?.transaction?.rollback, 0, 'expected stats.transaction.rollback=0'); + +const beginIndex = execCalls.indexOf('BEGIN'); +const commitIndex = execCalls.indexOf('COMMIT'); +assert.ok(beginIndex >= 0 && commitIndex > beginIndex, 'expected BEGIN before COMMIT'); + +const indexExecIndex = execCalls.findIndex((call) => call.includes('CREATE INDEX idx_chunks_file_id')); +assert.ok(indexExecIndex > beginIndex, 'expected CREATE_INDEXES_SQL to execute after BEGIN'); +assert.ok(indexExecIndex < commitIndex, 'expected CREATE_INDEXES_SQL to execute before COMMIT'); + +const db = new Database(outPath); +const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); +assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); +db.close(); + +console.log('sqlite build full transaction test passed'); diff --git a/tests/storage/sqlite/build-indexes.test.js b/tests/storage/sqlite/build-indexes.test.js new file mode 100644 index 000000000..668fd17ba --- /dev/null +++ b/tests/storage/sqlite/build-indexes.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; +import { hasSqlite } from '../../helpers/optional-deps.js'; +import { runNode } from '../../helpers/run-node.js'; +import { skip } from '../../helpers/skip.js'; +import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; + +if (!hasSqlite()) { + skip('better-sqlite3 not available; skipping sqlite build indexes test.'); +} + +const { default: Database } = await import('better-sqlite3'); +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('sqlite-build-indexes', { root }); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const buildIndexPath = path.join(root, 'build_index.js'); + +try { + await fsPromises.mkdir(repoRoot, { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\n'); + await fsPromises.writeFile(path.join(repoRoot, 'beta.js'), 'const beta = 2;\n'); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false + }); + + runNode( + [buildIndexPath, '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot], + 'build_index', + repoRoot, + env + ); + await runSqliteBuild(repoRoot, { mode: 'code', env }); + + await withTemporaryEnv({ + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_TESTING: '1' + }, async () => { + const userConfig = loadUserConfig(repoRoot); + const indexDir = getIndexDir(repoRoot, 'code', userConfig); + const chunkMetaPartsDir = path.join(indexDir, 'chunk_meta.parts'); + const tokenPostingsShardsDir = path.join(indexDir, 'token_postings.shards'); + const chunkMetaJson = path.join(indexDir, 'chunk_meta.json'); + const hasChunkMeta = fs.existsSync(chunkMetaJson) || fs.existsSync(chunkMetaPartsDir); + if (!hasChunkMeta) { + console.error(`Expected chunk metadata in ${chunkMetaJson} or ${chunkMetaPartsDir}`); + process.exit(1); + } + const hasTokenPostings = fs.existsSync(tokenPostingsShardsDir) + || fs.existsSync(path.join(indexDir, 'token_postings.json')); + if (!hasTokenPostings) { + console.error(`Expected token postings artifacts in ${tokenPostingsShardsDir}`); + process.exit(1); + } + const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); + const db = new Database(sqlitePaths.codePath); + const indexList = db.prepare("PRAGMA index_list('token_postings')").all(); + const indexNames = new Set(indexList.map((row) => row.name)); + if (indexNames.has('idx_token_postings_token')) { + console.error('Did not expect redundant idx_token_postings_token to exist'); + process.exit(1); + } + if (!indexList.some((row) => row.origin === 'pk')) { + console.error('Expected token_postings PRIMARY KEY index to exist'); + process.exit(1); + } + const chunkIndexList = db.prepare("PRAGMA index_list('chunks')").all(); + const chunkIndexNames = new Set(chunkIndexList.map((row) => row.name)); + if (!chunkIndexNames.has('idx_chunks_file_id')) { + console.error('Expected idx_chunks_file_id to exist'); + process.exit(1); + } + db.close(); + }); + + console.log('sqlite build indexes test passed'); +} finally { + try { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } catch {} +} diff --git a/tests/storage/sqlite/sqlite-build-manifest.test.js b/tests/storage/sqlite/build-manifest.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-build-manifest.test.js rename to tests/storage/sqlite/build-manifest.test.js diff --git a/tests/storage/sqlite/build-memory-guard.test.js b/tests/storage/sqlite/build-memory-guard.test.js new file mode 100644 index 000000000..a027ad9a0 --- /dev/null +++ b/tests/storage/sqlite/build-memory-guard.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './helpers/build-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const Database = await loadDatabaseCtor(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-build-memory-guard'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 1200; +const vocab = Array.from({ length: 600 }, (_, i) => `tok${i}`); +const postings = vocab.map(() => [[0, 1]]); +const docLengths = Array.from({ length: chunkCount }, () => 2); +const { pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 10, + tokens: ['alpha', 'beta'], + tokenVocab: vocab, + tokenPostings: postings, + docLengths, + avgDocLen: 2, + tokenShardSize: vocab.length, + chunkMaxBytes: 8192 +}); +await writePiecesManifest(indexDir, pieceEntries); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); +const stats = {}; +const count = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + batchSize: 200, + stats +}); + +assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); +assert.ok(stats.chunkMetaBatches > 1, 'expected chunk meta batches to flush'); +assert.ok(stats.tokenPostingBatches > 1, 'expected token postings to flush in batches'); +assert.ok(stats.tokenVocabBatches > 1, 'expected token vocab batches to flush'); +assert.ok(stats.docLengthBatches > 1, 'expected doc length batches to flush'); + +const db = new Database(outPath); +const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); +assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); +db.close(); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite build memory guard test passed'); diff --git a/tests/storage/sqlite/build-pragmas-dynamic.test.js b/tests/storage/sqlite/build-pragmas-dynamic.test.js new file mode 100644 index 000000000..b770dbae6 --- /dev/null +++ b/tests/storage/sqlite/build-pragmas-dynamic.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; + +import { loadSqlitePragmaDatabase, preparePragmaTestRoot } from './helpers/pragmas-fixture.js'; + +const Database = await loadSqlitePragmaDatabase(); +const tempRoot = await preparePragmaTestRoot('sqlite-build-pragmas-dynamic'); +const smallPath = path.join(tempRoot, 'small.db'); +const largePath = path.join(tempRoot, 'large.db'); + +const smallDb = new Database(smallPath); +const smallState = applyBuildPragmas(smallDb, { inputBytes: 10 * 1024 * 1024, stats: {} }); +restoreBuildPragmas(smallDb, smallState); +smallDb.close(); + +const largeDb = new Database(largePath); +const largeState = applyBuildPragmas(largeDb, { inputBytes: 3 * 1024 * 1024 * 1024, stats: {} }); +restoreBuildPragmas(largeDb, largeState); +largeDb.close(); + +const smallCache = Math.abs(Number(smallState.applied.cache_size || 0)); +const largeCache = Math.abs(Number(largeState.applied.cache_size || 0)); +assert.ok(largeCache >= smallCache, 'expected larger cache_size for large input'); + +const smallJournal = Number(smallState.applied.journal_size_limit || 0); +const largeJournal = Number(largeState.applied.journal_size_limit || 0); +assert.ok(largeJournal >= smallJournal, 'expected larger journal_size_limit for large input'); + +console.log('sqlite build pragmas dynamic test passed'); diff --git a/tests/storage/sqlite/build-pragmas-restore.test.js b/tests/storage/sqlite/build-pragmas-restore.test.js new file mode 100644 index 000000000..f654b7a91 --- /dev/null +++ b/tests/storage/sqlite/build-pragmas-restore.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; + +import { + loadSqlitePragmaDatabase, + openPragmaTestDatabase, + readPragmaValue +} from './helpers/pragmas-fixture.js'; + +const Database = await loadSqlitePragmaDatabase(); +const { db, dbPath } = await openPragmaTestDatabase({ + label: 'sqlite-build-pragmas-restore', + name: 'restore.db', + Database +}); +const readPragma = (name) => readPragmaValue(db, name); + +db.pragma('cache_size = -1234'); +db.pragma('mmap_size = 0'); +db.pragma('journal_size_limit = 0'); +db.pragma('wal_autocheckpoint = 1000'); +db.pragma('synchronous = NORMAL'); +db.pragma('temp_store = DEFAULT'); +try { db.pragma('locking_mode = NORMAL'); } catch {} + +const before = { + cache_size: readPragma('cache_size'), + mmap_size: readPragma('mmap_size'), + journal_size_limit: readPragma('journal_size_limit'), + wal_autocheckpoint: readPragma('wal_autocheckpoint'), + synchronous: readPragma('synchronous'), + temp_store: readPragma('temp_store'), + locking_mode: readPragma('locking_mode') +}; + +const state = applyBuildPragmas(db, { inputBytes: 512 * 1024 * 1024, stats: {} }); +restoreBuildPragmas(db, state); + +const after = { + cache_size: readPragma('cache_size'), + mmap_size: readPragma('mmap_size'), + journal_size_limit: readPragma('journal_size_limit'), + wal_autocheckpoint: readPragma('wal_autocheckpoint'), + synchronous: readPragma('synchronous'), + temp_store: readPragma('temp_store'), + locking_mode: readPragma('locking_mode') +}; + +db.close(); + +for (const key of Object.keys(before)) { + if (before[key] === null || before[key] === undefined) continue; + assert.equal(after[key], before[key], `expected pragma ${key} to be restored`); +} + +if (!fsSync.existsSync(dbPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite build pragmas restore test passed'); diff --git a/tests/storage/sqlite/build-prepared-statement-reuse.test.js b/tests/storage/sqlite/build-prepared-statement-reuse.test.js new file mode 100644 index 000000000..b1be160a8 --- /dev/null +++ b/tests/storage/sqlite/build-prepared-statement-reuse.test.js @@ -0,0 +1,176 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +let Database = null; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-build-prepared-statement-reuse'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const mode = 'code'; +const chunkCount = 200; +const tokens = ['alpha']; + +const createIndexDir = async (dir, shardCount) => { + await fs.rm(dir, { recursive: true, force: true }); + await fs.mkdir(dir, { recursive: true }); + + const chunkIterator = function* chunkIterator() { + for (let i = 0; i < chunkCount; i += 1) { + yield { + id: i, + file: `src/file-${i % 3}.js`, + start: 0, + end: 10, + startLine: 1, + endLine: 1, + kind: mode, + name: `fn${i}`, + tokens + }; + } + }; + + const shardResult = await writeJsonLinesSharded({ + dir, + partsDirName: 'chunk_meta.parts', + partPrefix: 'chunk_meta.part-', + items: chunkIterator(), + maxBytes: 4096, + atomic: true + }); + await writeJsonObjectFile(path.join(dir, 'chunk_meta.meta.json'), { + fields: { + schemaVersion: '0.0.1', + artifact: 'chunk_meta', + format: 'jsonl-sharded', + generatedAt: new Date().toISOString(), + compression: 'none', + totalRecords: shardResult.total, + totalBytes: shardResult.totalBytes, + maxPartRecords: shardResult.maxPartRecords, + maxPartBytes: shardResult.maxPartBytes, + targetMaxBytes: shardResult.targetMaxBytes, + parts: shardResult.parts.map((part, index) => ({ + path: part, + records: shardResult.counts[index] || 0, + bytes: shardResult.bytes[index] || 0 + })) + }, + atomic: true + }); + + const postingsDir = path.join(dir, 'token_postings.shards'); + await fs.mkdir(postingsDir, { recursive: true }); + + const parts = []; + for (let shard = 0; shard < shardCount; shard += 1) { + const name = `token_postings.part-${String(shard).padStart(5, '0')}.json`; + const fullPath = path.join(postingsDir, name); + parts.push(`token_postings.shards/${name}`); + // Keep postings small; shard count should not impact prepare count. + await writeJsonObjectFile(fullPath, { + arrays: { + vocab: [`tok${shard}`], + postings: [[[0, 1]]] + }, + atomic: true + }); + } + + const docLengths = Array.from({ length: chunkCount }, () => tokens.length); + await writeJsonObjectFile(path.join(dir, 'token_postings.meta.json'), { + fields: { + avgDocLen: tokens.length, + totalDocs: chunkCount, + format: 'sharded', + shardSize: shardCount, + vocabCount: shardCount, + parts + }, + arrays: { docLengths }, + atomic: true + }); + await writePiecesManifest(dir, [ + ...shardResult.parts.map((part) => ({ + name: 'chunk_meta', + path: part, + format: 'jsonl' + })), + { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, + ...parts.map((part) => ({ + name: 'token_postings', + path: part, + format: 'sharded' + })), + { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } + ]); +}; + +const runBuild = async ({ indexDir, outPath }) => { + const indexPieces = await loadIndexPieces(indexDir, null); + assert.ok(indexPieces, `expected loadIndexPieces to detect artifacts in ${indexDir}`); + + const stats = {}; + await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode, + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + statementStrategy: 'prepared', + buildPragmas: false, + optimize: false, + stats + }); + const prepares = stats?.prepare?.total ?? null; + assert.ok(Number.isFinite(prepares), 'expected stats.prepare.total to be recorded'); + return prepares; +}; + +const dirOne = path.join(tempRoot, 'index-one'); +const dirMany = path.join(tempRoot, 'index-many'); +await createIndexDir(dirOne, 1); +await createIndexDir(dirMany, 12); + +const preparesOne = await runBuild({ + indexDir: dirOne, + outPath: path.join(tempRoot, 'one.db') +}); +const preparesMany = await runBuild({ + indexDir: dirMany, + outPath: path.join(tempRoot, 'many.db') +}); + +assert.equal( + preparesMany, + preparesOne, + `expected prepare count to be stable across shard counts (one=${preparesOne}, many=${preparesMany})` +); + +console.log('sqlite prepared statement reuse test passed'); + diff --git a/tests/storage/sqlite/build-root-active-generation-plan.test.js b/tests/storage/sqlite/build-root-active-generation-plan.test.js new file mode 100644 index 000000000..6195a15b1 --- /dev/null +++ b/tests/storage/sqlite/build-root-active-generation-plan.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; +import { resolveModeExecutionPlan } from '../../../src/storage/sqlite/build/runner/mode-plan.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-build-root-active-generation-plan'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const userConfig = { cache: { root: cacheRoot } }; + +const normalizePath = (value) => { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const buildId = '20260323T000000Z-active'; +const activeRoot = path.join(buildsRoot, buildId); + +await fs.mkdir(path.join(activeRoot, 'index-code'), { recursive: true }); +await fs.writeFile(path.join(activeRoot, 'index-code', 'chunk_meta.jsonl.gz'), '', 'utf8'); +await fs.writeFile( + path.join(buildsRoot, 'current.json'), + JSON.stringify({ + buildId, + buildRoot: '.', + buildRootsByMode: { + code: '.' + } + }, null, 2), + 'utf8' +); + +const plan = resolveModeExecutionPlan({ + modeArg: 'code', + root: repoRoot, + argv: {}, + options: {}, + runtime: null, + userConfig +}); + +assert.equal(plan.errorMessage, undefined, 'expected sqlite mode planning to succeed'); +assert.equal( + normalizePath(plan.indexRoot), + normalizePath(activeRoot), + 'expected sqlite mode planning to prefer the active generation root' +); +assert.equal( + normalizePath(plan.modeIndexDirs.code), + normalizePath(path.join(activeRoot, 'index-code')), + 'expected sqlite mode plan to resolve code index dir under the active generation root' +); + +await fs.writeFile( + path.join(buildsRoot, 'current.json'), + JSON.stringify({ + buildId, + buildRoot: '.', + buildRootsByMode: { + code: '.' + } + }, null, 2), + 'utf8' +); +await fs.rm(activeRoot, { recursive: true, force: true }); + +const missingPlan = resolveModeExecutionPlan({ + modeArg: 'code', + root: repoRoot, + argv: {}, + options: {}, + runtime: null, + userConfig +}); + +assert.match( + missingPlan.errorMessage || '', + /active build root|missing active build root/i, + 'expected sqlite mode planning to fail closed instead of falling back to repo-root manifests' +); + +console.log('sqlite build-root active generation plan test passed'); diff --git a/tests/storage/sqlite/sqlite-build-rowcount-contract.test.js b/tests/storage/sqlite/build-rowcount-contract.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-build-rowcount-contract.test.js rename to tests/storage/sqlite/build-rowcount-contract.test.js diff --git a/tests/storage/sqlite/sqlite-build-validate-auto-fast-path.test.js b/tests/storage/sqlite/build-validate-auto-fast-path.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-build-validate-auto-fast-path.test.js rename to tests/storage/sqlite/build-validate-auto-fast-path.test.js diff --git a/tests/storage/sqlite/sqlite-build-vocab.test.js b/tests/storage/sqlite/build-vocab.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-build-vocab.test.js rename to tests/storage/sqlite/build-vocab.test.js diff --git a/tests/storage/sqlite/build/incremental-bundle-inventory-cache-key.test.js b/tests/storage/sqlite/build/incremental-bundle-inventory-cache-key.test.js index 9b9707204..894ca7b52 100644 --- a/tests/storage/sqlite/build/incremental-bundle-inventory-cache-key.test.js +++ b/tests/storage/sqlite/build/incremental-bundle-inventory-cache-key.test.js @@ -58,7 +58,7 @@ try { bundleDir, manifest: { files: { - 'src/main.js': { bundle: 'bundle-b.json' } + 'src/main.js': { bundles: ['bundle-b.json'] } } } }, secondInventory.names); @@ -81,21 +81,21 @@ try { bundleDir, manifest: { files: { - 'src/nested.js': { bundle: 'nested/bundle-c.json' } + 'src/nested.js': { bundles: ['nested/bundle-c.json'] } } } }, thirdInventory.names); assert.equal( nestedMissingCount, - 0, - 'expected nested bundle paths to fall back to fs existence checks' + 1, + 'expected nested bundle paths to be rejected by strict bundle-name validation' ); const directoryMissingCount = countMissingBundleFiles({ bundleDir, manifest: { files: { - 'src/dir.js': { bundle: 'bundle-dir.json' } + 'src/dir.js': { bundles: ['bundle-dir.json'] } } } }, thirdInventory.names); @@ -110,7 +110,7 @@ try { bundleDir, manifest: { files: { - 'src/case.js': { bundle: 'BUNDLE-B.JSON' } + 'src/case.js': { bundles: ['BUNDLE-B.JSON'] } } } }, thirdInventory.names); diff --git a/tests/storage/sqlite/build/incremental-bundle-path-guard.test.js b/tests/storage/sqlite/build/incremental-bundle-path-guard.test.js index 71856d6c1..f9a4516ab 100644 --- a/tests/storage/sqlite/build/incremental-bundle-path-guard.test.js +++ b/tests/storage/sqlite/build/incremental-bundle-path-guard.test.js @@ -15,7 +15,7 @@ try { { file: 'src/a.js', normalized: 'src/a.js', - entry: { bundle: '../escape.bundle.json' } + entry: { bundles: ['../escape.bundle.json'] } } ], bundleDir @@ -24,8 +24,8 @@ try { assert.equal(result?.ok, false, 'expected traversal bundle path to be rejected'); assert.match( String(result?.reason || ''), - /invalid bundle path/i, - 'expected explicit invalid bundle path reason for traversal entry' + /(invalid bundle path|missing bundle)/i, + 'expected traversal bundle entry to be rejected' ); console.log('sqlite incremental bundle path guard test passed'); diff --git a/tests/storage/sqlite/build/row-ledger.test.js b/tests/storage/sqlite/build/row-ledger.test.js new file mode 100644 index 000000000..f620f9923 --- /dev/null +++ b/tests/storage/sqlite/build/row-ledger.test.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + classifySqliteModeBuildFailure, + createSqliteModeRowLedger, + validateSqliteRowLedgerPostImport, + validateSqliteRowLedgerPreImport +} from '../../../../src/storage/sqlite/build/runner/row-ledger.js'; + +const rowLedger = createSqliteModeRowLedger({ + mode: 'code', + source: 'incremental', + expectedChunkCount: 5, + expectedDenseCount: 3, + expectedFileCount: 2, + inputRoot: '/tmp/code' +}); + +{ + const result = validateSqliteRowLedgerPreImport({ + rowLedger, + bundleResult: { + count: 4, + denseCount: 3, + embedStats: {} + }, + denseArtifactsRequired: true + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'ERR_SQLITE_PREIMPORT_ROW_COUNT_MISMATCH'); + assert.match(result.message, /bundle row count mismatch/i); +} + +{ + const result = validateSqliteRowLedgerPreImport({ + rowLedger, + bundleResult: { + count: 5, + denseCount: 0, + embedStats: { + filesMissingEmbeddings: 1 + } + }, + denseArtifactsRequired: true + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'ERR_SQLITE_PREIMPORT_DENSE_COVERAGE_MISMATCH'); + assert.match(result.message, /bundles missing embeddings/i); +} + +{ + const result = validateSqliteRowLedgerPreImport({ + rowLedger, + bundleResult: { + count: 5, + denseCount: 3, + reason: 'bundle read failed (/tmp/bundle): invalid bundle' + }, + denseArtifactsRequired: true + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'ERR_SQLITE_PREIMPORT_INPUT_INVALID'); + assert.match(result.message, /bundle read failed/i); +} + +{ + const result = validateSqliteRowLedgerPostImport({ + rowLedger, + Database: null, + dbPath: '/tmp/code.sqlite', + probe: { + readModeCount: () => 4, + readDenseCount: () => 3, + readTableCount: () => 3 + } + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'ERR_SQLITE_POSTIMPORT_ROW_COUNT_MISMATCH'); + assert.match(result.message, /post-import chunk count mismatch/i); +} + +{ + const result = validateSqliteRowLedgerPostImport({ + rowLedger, + Database: null, + dbPath: '/tmp/code.sqlite', + probe: { + readModeCount: () => 5, + readDenseCount: () => 3, + readTableCount: () => 2 + } + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'ERR_SQLITE_POSTIMPORT_VALIDATION_MISMATCH'); + assert.match(result.message, /ANN count mismatch/i); +} + +{ + const piecesLoadFailure = classifySqliteModeBuildFailure( + new Error('Failed to load index pieces for code: synthetic failure') + ); + assert.deepEqual(piecesLoadFailure, { + code: 'ERR_SQLITE_INPUT_PIECES_LOAD_FAILED', + failureClass: 'input_pieces_load_failed' + }); +} + +console.log('sqlite row ledger test passed'); diff --git a/tests/storage/sqlite/build/sqlite-build-core-contract.test.js b/tests/storage/sqlite/build/sqlite-core-contract.test.js similarity index 100% rename from tests/storage/sqlite/build/sqlite-build-core-contract.test.js rename to tests/storage/sqlite/build/sqlite-core-contract.test.js diff --git a/tests/storage/sqlite/bundle-dims-mismatch.test.js b/tests/storage/sqlite/bundle-dims-mismatch.test.js index 4a885bcae..ca78c2130 100644 --- a/tests/storage/sqlite/bundle-dims-mismatch.test.js +++ b/tests/storage/sqlite/bundle-dims-mismatch.test.js @@ -58,8 +58,8 @@ await writeBundleFile({ const manifest = { files: { - 'a.js': { bundle: bundleA, mtimeMs: 1, size: 1, hash: 'a' }, - 'b.js': { bundle: bundleB, mtimeMs: 2, size: 1, hash: 'b' } + 'a.js': { bundles: [bundleA], mtimeMs: 1, size: 1, hash: 'a' }, + 'b.js': { bundles: [bundleB], mtimeMs: 2, size: 1, hash: 'b' } } }; diff --git a/tests/storage/sqlite/bundle-invalid.test.js b/tests/storage/sqlite/bundle-invalid.test.js new file mode 100644 index 000000000..691dbd6ae --- /dev/null +++ b/tests/storage/sqlite/bundle-invalid.test.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +let Database; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-invalid'); +const bundleDir = path.join(tempRoot, 'bundles'); +const dbPath = path.join(tempRoot, 'index-code.db'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(bundleDir, { recursive: true }); + +const bundleName = 'bad-bundle.json'; +const bundlePath = path.join(bundleDir, bundleName); +await fsPromises.writeFile(bundlePath, JSON.stringify({ files: [] }), 'utf8'); + +const result = await buildDatabaseFromBundles({ + Database, + outPath: dbPath, + mode: 'code', + incrementalData: { + bundleDir, + manifest: { + files: { + 'src/bad.js': { bundles: [bundleName], mtimeMs: 1, size: 0 } + } + } + }, + envConfig: { bundleThreads: 1 }, + threadLimits: { fileConcurrency: 1 }, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: 'test' }, + workerPath: null, + logger: null +}); + +assert.equal(result.count, 0, 'expected bundle build to skip invalid bundle'); +assert.ok( + result.reason && result.reason.includes('invalid bundle'), + `expected invalid bundle reason, got ${result.reason}` +); + +console.log('sqlite bundle invalid test passed'); + diff --git a/tests/storage/sqlite/bundle-loader-worker.test.js b/tests/storage/sqlite/bundle-loader-worker.test.js new file mode 100644 index 000000000..dc9aea0c5 --- /dev/null +++ b/tests/storage/sqlite/bundle-loader-worker.test.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { createBundleLoader } from '../../../src/storage/sqlite/build/bundle-loader.js'; +import { + writeBundleFile, + writeBundlePatch +} from '../../../src/shared/bundle-io.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-loader-worker'); +const bundleDir = path.join(tempRoot, 'bundles'); +const relFile = 'src/example.js'; +const bundleName = 'bundle-example.json'; +const bundlePath = path.join(bundleDir, bundleName); +const workerPath = path.join(root, 'src', 'storage', 'sqlite', 'build', 'bundle-loader-worker.js'); + +const createBundleChunk = ({ targetChunkId }) => ({ + id: 0, + file: relFile, + start: 0, + end: 10, + tokens: ['alpha'], + metaV2: { + chunkId: 'chunk:0', + file: relFile, + range: { start: 0, end: 10 }, + lang: 'javascript', + ext: '.js', + relations: { calls: [{ targetChunkId }] }, + segment: null + } +}); + +const createBundle = (targetChunkId) => ({ + file: relFile, + chunks: [createBundleChunk({ targetChunkId })] +}); + +const loadExampleBundle = (loader, bundles = [bundleName]) => loader.loadBundle({ + bundleDir, + file: relFile, + entry: { bundles } +}); + +const assertLoaded = (loaded, message) => { + assert.equal(loaded.ok, true, `${message}, got: ${loaded.reason || 'unknown'}`); +}; + +const closeLoaderIdempotently = async (loader) => { + await Promise.all([loader.close(), loader.close()]); + await loader.close(); +}; + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(bundleDir, { recursive: true }); + +await writeBundleFile({ + bundlePath, + format: 'json', + bundle: createBundle('old') +}); + +await writeBundlePatch({ + bundlePath, + format: 'json', + previousBundle: createBundle('old'), + nextBundle: createBundle('new') +}); + +const loader = createBundleLoader({ bundleThreads: 2, workerPath }); +try { + const loaded = await loadExampleBundle(loader); + assertLoaded(loaded, 'expected bundle loader success'); + const firstShard = Array.isArray(loaded.bundleShards) ? loaded.bundleShards[0] : null; + const targetChunkId = firstShard?.chunks?.[0]?.metaV2?.relations?.calls?.[0]?.targetChunkId || null; + assert.equal(targetChunkId, 'new', 'expected worker loader to apply bundle patch sidecar'); +} finally { + await closeLoaderIdempotently(loader); +} + +const directLoader = createBundleLoader({ bundleThreads: 1, workerPath }); +try { + const loaded = await loadExampleBundle(directLoader); + assertLoaded(loaded, 'expected direct bundle loader success'); + assert.equal(Array.isArray(loaded.bundleShards), true, 'expected direct loader to return bundleShards'); + assert.equal(loaded.bundleShards.length, 1, 'expected direct loader to expose one shard'); + + const invalidEntry = await loadExampleBundle(directLoader, ['nested/invalid.json']); + assert.equal(invalidEntry.ok, false, 'expected invalid manifest bundle entry to fail closed'); + assert.match( + invalidEntry.reason || '', + /path separators/i, + 'expected invalid bundle-name reason to be preserved' + ); +} finally { + await closeLoaderIdempotently(directLoader); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('sqlite bundle loader worker patch parity ok'); diff --git a/tests/storage/sqlite/bundle-metav2-docid-parity.test.js b/tests/storage/sqlite/bundle-metav2-docid-parity.test.js new file mode 100644 index 000000000..bc1c05996 --- /dev/null +++ b/tests/storage/sqlite/bundle-metav2-docid-parity.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + assertMetaV2Parity, + buildBundleDatabase, + createBundleManifest, + loadSqliteBundleDatabase, + prepareBundleBuildFixture, + readBundleRows, + writeSingleChunkBundle +} from './helpers/bundle-fixture.js'; + +const Database = await loadSqliteBundleDatabase('sqlite bundle parity tests'); +const { bundleDir, dbPath } = await prepareBundleBuildFixture({ + label: 'sqlite-bundle-metav2-docid-parity', + dbName: 'index-code.db' +}); + +const fileA = 'a/FileA.swift'; +const fileB = 'b/FileB.swift'; +const bundleA = 'bundle-a.json'; +const bundleB = 'bundle-b.json'; + +const chunkMetaB = { + chunkId: 'chunk-b', + file: fileB, + range: { start: 20, end: 40 }, + lang: 'swift', + ext: '.swift', + relations: { calls: [{ targetChunkId: 'callee-b' }] }, + segment: { segmentId: 'seg-b', segmentUid: 'seguid-b', virtualPath: `vfs://${fileB}` } +}; +const chunkMetaA = { + chunkId: 'chunk-a', + file: fileA, + range: { start: 1, end: 19 }, + lang: 'swift', + ext: '.swift', + relations: { calls: [{ targetChunkId: 'callee-a' }] }, + segment: { segmentId: 'seg-a', segmentUid: 'seguid-a', virtualPath: `vfs://${fileA}` } +}; + +await writeSingleChunkBundle({ + bundleDir, + bundleName: bundleA, + file: fileA, + chunk: { + id: 1, + file: fileA, + start: 1, + end: 19, + tokens: ['alpha'], + chunkId: 'chunk-a', + metaV2: chunkMetaA + } +}); +await writeSingleChunkBundle({ + bundleDir, + bundleName: bundleB, + file: fileB, + chunk: { + id: 0, + file: fileB, + start: 20, + end: 40, + tokens: ['beta'], + chunkId: 'chunk-b', + metaV2: chunkMetaB + } +}); + +const manifest = createBundleManifest([ + { file: fileA, bundles: [bundleA], mtimeMs: 10, size: 10, hash: 'hash-a' }, + { file: fileB, bundles: [bundleB], mtimeMs: 20, size: 20, hash: 'hash-b' } +]); + +const result = await buildBundleDatabase({ + Database, + dbPath, + mode: 'code', + manifest, + bundleDir +}); + +assert.equal(result.count, 2, `expected 2 indexed chunks, got ${result.count}`); + +const rows = readBundleRows({ Database, dbPath, mode: 'code' }); +const chunkMeta = [ + { id: 0, metaV2: chunkMetaB }, + { id: 1, metaV2: chunkMetaA } +]; +assertMetaV2Parity({ mode: 'code', chunkMeta, rows }); + +assert.deepEqual( + rows.map((row) => row.id), + [0, 1], + `expected sqlite chunk ids [0,1], got ${rows.map((row) => row.id).join(',')}` +); + +console.log('sqlite bundle metaV2 docId parity test passed'); diff --git a/tests/storage/sqlite/bundle-metav2-fallback-order-parity.test.js b/tests/storage/sqlite/bundle-metav2-fallback-order-parity.test.js new file mode 100644 index 000000000..57ce60728 --- /dev/null +++ b/tests/storage/sqlite/bundle-metav2-fallback-order-parity.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + assertMetaV2Parity, + buildBundleDatabase, + createBundleManifest, + loadSqliteBundleDatabase, + prepareBundleBuildFixture, + readBundleRows, + writeSingleChunkBundle +} from './helpers/bundle-fixture.js'; + +const Database = await loadSqliteBundleDatabase('sqlite bundle parity tests'); +const { bundleDir, dbPath } = await prepareBundleBuildFixture({ + label: 'sqlite-bundle-metav2-fallback-order-parity', + dbName: 'index-prose.db' +}); + +const firstFile = 'z/README.md'; +const secondFile = 'a/README.md'; +const firstBundleName = 'bundle-z.json'; +const secondBundleName = 'bundle-a.json'; + +const firstMeta = { + chunkId: 'chunk-z', + file: firstFile, + range: { start: 1, end: 10 }, + lang: 'markdown', + ext: '.md', + relations: null, + segment: null +}; +const secondMeta = { + chunkId: 'chunk-a', + file: secondFile, + range: { start: 11, end: 20 }, + lang: 'markdown', + ext: '.md', + relations: null, + segment: null +}; + +await writeSingleChunkBundle({ + bundleDir, + bundleName: firstBundleName, + file: firstFile, + chunk: { + file: firstFile, + start: 1, + end: 10, + ext: '.md', + tokens: ['alpha'], + chunkId: firstMeta.chunkId, + metaV2: firstMeta + } +}); +await writeSingleChunkBundle({ + bundleDir, + bundleName: secondBundleName, + file: secondFile, + chunk: { + file: secondFile, + start: 11, + end: 20, + ext: '.md', + tokens: ['beta'], + chunkId: secondMeta.chunkId, + metaV2: secondMeta + } +}); + +const manifest = createBundleManifest([ + { file: firstFile, bundles: [firstBundleName], mtimeMs: 10, size: 10, hash: 'hash-z' }, + { file: secondFile, bundles: [secondBundleName], mtimeMs: 20, size: 20, hash: 'hash-a' } +]); + +const result = await buildBundleDatabase({ + Database, + dbPath, + mode: 'prose', + manifest, + bundleDir +}); + +assert.equal(result.count, 2, `expected 2 indexed chunks, got ${result.count}`); + +const rows = readBundleRows({ Database, dbPath, mode: 'prose' }); +const chunkMeta = [ + { id: 0, metaV2: firstMeta }, + { id: 1, metaV2: secondMeta } +]; +assertMetaV2Parity({ mode: 'prose', chunkMeta, rows }); + +assert.deepEqual( + rows.map((row) => row.chunk_id), + ['chunk-z', 'chunk-a'], + `expected sqlite chunk_id order chunk-z,chunk-a, got ${rows.map((row) => row.chunk_id).join(',')}` +); + +console.log('sqlite bundle fallback-order metaV2 parity test passed'); diff --git a/tests/storage/sqlite/bundle-missing.test.js b/tests/storage/sqlite/bundle-missing.test.js new file mode 100644 index 000000000..9eb94c270 --- /dev/null +++ b/tests/storage/sqlite/bundle-missing.test.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; +import { getRepoCacheRoot, loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; +import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-missing'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + syncProcess: false +}); + +const run = (args, label) => { + const result = runNode(args, label, repoRoot, env, { + stdio: 'pipe', + encoding: 'utf8', + allowFailure: true + }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); + } + return result; +}; + +await withTemporaryEnv({ + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_EMBEDDINGS: 'stub' +}, async () => { + run([ + path.join(root, 'build_index.js'), + '--incremental', + '--stub-embeddings', + '--stage', + 'stage2', + '--scm-provider', + 'none', + '--mode', + 'code', + '--repo', + repoRoot + ], 'build index'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); + const bundleDir = path.join(repoCacheRoot, 'incremental', 'code', 'files'); + if (!fs.existsSync(manifestPath)) { + console.error('Missing incremental manifest for sqlite bundle test.'); + process.exit(1); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestFiles = Object.values(manifest.files || {}); + if (!manifestFiles.length) { + console.error('Incremental manifest contains no files.'); + process.exit(1); + } + const bundleName = manifestFiles[0]?.bundles?.[0]; + if (!bundleName) { + console.error('Manifest entry missing bundle name.'); + process.exit(1); + } + const bundlePath = path.join(bundleDir, bundleName); + if (!fs.existsSync(bundlePath)) { + console.error(`Expected bundle file missing: ${bundlePath}`); + process.exit(1); + } + await fsPromises.rm(bundlePath, { force: true }); + + const sqliteLogs = []; + try { + await runSqliteBuild(repoRoot, { + mode: 'code', + incremental: true, + logger: { + log: (message) => sqliteLogs.push(message), + warn: (message) => sqliteLogs.push(message), + error: (message) => sqliteLogs.push(message) + } + }); + } catch (err) { + console.error('sqlite build failed for missing bundle test.'); + if (err?.message) console.error(err.message); + process.exit(1); + } + const output = getCombinedOutput({ stdout: sqliteLogs.join('\n'), stderr: '' }); + const outputLower = output.toLowerCase(); + if ( + !outputLower.includes('incremental bundles unavailable') + && !outputLower.includes('falling back to artifacts') + && !outputLower.includes('incremental update skipped') + && !outputLower.includes('bundle missing') + && !outputLower.includes('bundle file missing') + && !outputLower.includes('bundle build failed') + ) { + console.error('Expected bundle fallback warning not found in output.'); + process.exit(1); + } + + const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); + if (!fs.existsSync(sqlitePaths.codePath)) { + console.error(`Missing sqlite db after fallback: ${sqlitePaths.codePath}`); + process.exit(1); + } + + let Database; + try { + ({ default: Database } = await import('better-sqlite3')); + } catch { + console.error('better-sqlite3 is required for sqlite bundle test.'); + process.exit(1); + } + const db = new Database(sqlitePaths.codePath, { readonly: true }); + const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); + db.close(); + if (!Number(row?.total)) { + console.error('Expected sqlite index to contain chunks after fallback rebuild.'); + process.exit(1); + } + + console.log('sqlite bundle missing fallback test passed'); +}); + diff --git a/tests/storage/sqlite/bundle-shard-splitting.test.js b/tests/storage/sqlite/bundle-shard-splitting.test.js new file mode 100644 index 000000000..43b87c338 --- /dev/null +++ b/tests/storage/sqlite/bundle-shard-splitting.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { writeIncrementalBundle } from '../../../src/index/build/incremental.js'; +import { + buildBundleDatabase, + loadSqliteBundleDatabase, + prepareBundleBuildFixture +} from './helpers/bundle-fixture.js'; + +const Database = await loadSqliteBundleDatabase('sqlite bundle shard tests'); +const { tempRoot, bundleDir, dbPath } = await prepareBundleBuildFixture({ + label: 'sqlite-bundle-shard-splitting', + dbName: 'index-code.db' +}); + +const relKey = 'src/very-large-file.js'; +const chunkCount = 24; +const chunkText = 'x'.repeat(1024 * 1024); +const chunks = Array.from({ length: chunkCount }, (_, i) => ({ + file: relKey, + start: i * 10, + end: i * 10 + 5, + startLine: i + 1, + endLine: i + 1, + ext: '.js', + kind: 'code', + tokens: [`tok-${i}`], + text: chunkText +})); + +const manifestEntry = await writeIncrementalBundle({ + enabled: true, + bundleDir, + relKey, + fileStat: { mtimeMs: Date.now(), size: chunkText.length * chunkCount }, + fileHash: 'hash:large', + fileChunks: chunks, + fileRelations: { imports: [] }, + vfsManifestRows: [], + bundleFormat: 'json' +}); + +assert.ok(manifestEntry, 'expected manifest entry from incremental shard write'); +assert.ok(Array.isArray(manifestEntry.bundles), 'expected shard bundle names'); +assert.ok(manifestEntry.bundles.length > 1, 'expected large bundle payload to be sharded'); + +for (const bundleName of manifestEntry.bundles) { + const stat = await fs.stat(path.join(bundleDir, bundleName)); + assert.ok( + Number(stat?.size) < (256 * 1024 * 1024), + `expected shard ${bundleName} below hard read cap` + ); +} + +const result = await buildBundleDatabase({ + Database, + dbPath, + mode: 'code', + manifest: { files: { [relKey]: manifestEntry } }, + bundleDir +}); + +assert.equal(result.reason || null, null, `expected no bundle failure, got: ${result.reason || 'none'}`); +assert.equal(result.count, chunkCount, `expected ${chunkCount} indexed chunks, got ${result.count}`); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('sqlite bundle shard splitting test passed'); diff --git a/tests/storage/sqlite/bundle-vocab-cache-eviction.test.js b/tests/storage/sqlite/bundle-vocab-cache-eviction.test.js new file mode 100644 index 000000000..35fbcd36b --- /dev/null +++ b/tests/storage/sqlite/bundle-vocab-cache-eviction.test.js @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; +import { writeBundleFile } from '../../../src/shared/bundle-io.js'; +import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; + +applyTestEnv(); + +let Database; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch { + console.error('better-sqlite3 is required for sqlite bundle vocab cache eviction tests.'); + process.exit(1); +} + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-vocab-cache-eviction'); +const bundleDir = path.join(tempRoot, 'bundles'); +const dbPath = path.join(tempRoot, 'index-code.db'); + +await rmDirRecursive(tempRoot); +await fs.mkdir(bundleDir, { recursive: true }); + +const writeFixtureBundle = async (bundleName, file, token, ngram, gram) => { + await writeBundleFile({ + bundlePath: path.join(bundleDir, bundleName), + format: 'json', + bundle: { + file, + chunks: [{ + file, + start: 0, + end: 1, + ext: '.js', + tokens: [token], + ngrams: [ngram], + chargrams: [gram] + }] + } + }); +}; + +await writeFixtureBundle('bundle-a.json', 'a.js', 'alpha', 'shared phrase', 'shared-gram'); +await writeFixtureBundle('bundle-b.json', 'b.js', 'beta', 'other phrase', 'other-gram'); +await writeFixtureBundle('bundle-c.json', 'c.js', 'alpha', 'shared phrase', 'shared-gram'); + +const result = await buildDatabaseFromBundles({ + Database, + outPath: dbPath, + mode: 'code', + incrementalData: { + bundleDir, + manifest: { + files: { + 'a.js': { bundles: ['bundle-a.json'], mtimeMs: 1, size: 1, hash: 'a' }, + 'b.js': { bundles: ['bundle-b.json'], mtimeMs: 2, size: 1, hash: 'b' }, + 'c.js': { bundles: ['bundle-c.json'], mtimeMs: 3, size: 1, hash: 'c' } + } + } + }, + envConfig: { bundleThreads: 1 }, + threadLimits: { fileConcurrency: 1 }, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + workerPath: null, + vocabLookupCacheMaxEntries: 1 +}); + +assert.equal(result.reason || null, null, `expected bundle build success, got ${result.reason || 'none'}`); +assert.equal(result.count, 3, `expected 3 indexed chunks, got ${result.count}`); + +const db = new Database(dbPath, { readonly: true }); +try { + const tokenRows = db.prepare('SELECT token_id, token FROM token_vocab WHERE mode = ? ORDER BY token_id').all('code'); + const phraseRows = db.prepare('SELECT phrase_id, ngram FROM phrase_vocab WHERE mode = ? ORDER BY phrase_id').all('code'); + const gramRows = db.prepare('SELECT gram_id, gram FROM chargram_vocab WHERE mode = ? ORDER BY gram_id').all('code'); + const alphaTokenId = db.prepare('SELECT token_id AS id FROM token_vocab WHERE mode = ? AND token = ?').get('code', 'alpha')?.id; + const sharedPhraseId = db.prepare('SELECT phrase_id AS id FROM phrase_vocab WHERE mode = ? AND ngram = ?').get('code', 'shared phrase')?.id; + const sharedGramId = db.prepare('SELECT gram_id AS id FROM chargram_vocab WHERE mode = ? AND gram = ?').get('code', 'shared-gram')?.id; + const alphaPostings = db.prepare('SELECT COUNT(*) AS total FROM token_postings WHERE mode = ? AND token_id = ?').get('code', alphaTokenId)?.total; + const sharedPhrasePostings = db.prepare('SELECT COUNT(*) AS total FROM phrase_postings WHERE mode = ? AND phrase_id = ?').get('code', sharedPhraseId)?.total; + const sharedGramPostings = db.prepare('SELECT COUNT(*) AS total FROM chargram_postings WHERE mode = ? AND gram_id = ?').get('code', sharedGramId)?.total; + + assert.deepEqual( + tokenRows.map((row) => row.token), + ['alpha', 'beta'], + `expected stable token vocab rows, got ${JSON.stringify(tokenRows)}` + ); + assert.deepEqual( + phraseRows.map((row) => row.ngram), + ['shared phrase', 'other phrase'], + `expected stable phrase vocab rows, got ${JSON.stringify(phraseRows)}` + ); + assert.deepEqual( + gramRows.map((row) => row.gram), + ['shared-gram', 'other-gram'], + `expected stable chargram vocab rows, got ${JSON.stringify(gramRows)}` + ); + assert.equal(alphaPostings, 2, `expected alpha token id reuse across 2 docs, got ${alphaPostings}`); + assert.equal(sharedPhrasePostings, 2, `expected shared phrase id reuse across 2 docs, got ${sharedPhrasePostings}`); + assert.equal(sharedGramPostings, 2, `expected shared gram id reuse across 2 docs, got ${sharedGramPostings}`); +} finally { + db.close(); + await rmDirRecursive(tempRoot); +} + +console.log('sqlite bundle vocab cache eviction test passed'); diff --git a/tests/storage/sqlite/bundle-worker-autotune.test.js b/tests/storage/sqlite/bundle-worker-autotune.test.js new file mode 100644 index 000000000..22668c12d --- /dev/null +++ b/tests/storage/sqlite/bundle-worker-autotune.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { sqliteBuildRunnerInternals } from '../../../src/storage/sqlite/build/runner.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const { + resolveSqliteBundleWorkerProfilePath, + resolveBundleWorkerAutotune +} = sqliteBuildRunnerInternals; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-worker-autotune'); +const bundleDir = path.join(tempRoot, 'bundles'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const expectedProfilePath = path.join(tempRoot, 'sqlite', 'bundle-worker-autotune.json'); +assert.equal( + resolveSqliteBundleWorkerProfilePath(tempRoot), + expectedProfilePath, + 'expected bundle worker profile path under repo cache sqlite dir' +); + +const manifestFiles = {}; +for (let i = 0; i < 48; i += 1) { + const bundleName = `bundle-${i}.json`; + manifestFiles[`src/file-${i}.js`] = { bundles: [bundleName] }; + await fs.writeFile(path.join(bundleDir, bundleName), 'x'.repeat(1024), 'utf8'); +} + +const codeTuned = resolveBundleWorkerAutotune({ + mode: 'code', + manifestFiles, + bundleDir, + threadLimits: { fileConcurrency: 12 }, + envConfig: {}, + profile: { modes: {} } +}); +assert.equal(codeTuned.reason, 'autotune', 'expected autotune mode without explicit override'); +assert.ok(codeTuned.threads >= 1, 'expected positive autotuned worker count'); +assert.ok(codeTuned.threads <= 12, 'expected autotuned workers to respect thread limits'); + +const recordsTuned = resolveBundleWorkerAutotune({ + mode: 'records', + manifestFiles, + bundleDir, + threadLimits: { fileConcurrency: 12 }, + envConfig: {}, + profile: { modes: {} } +}); +assert.ok( + recordsTuned.threads <= codeTuned.threads, + 'expected records mode to use equal-or-lower bundle thread fanout' +); + +const explicit = resolveBundleWorkerAutotune({ + mode: 'code', + manifestFiles, + bundleDir, + threadLimits: { fileConcurrency: 8 }, + envConfig: { bundleThreads: 3 }, + profile: { modes: {} } +}); +assert.equal(explicit.reason, 'explicit-env', 'expected explicit env override reason'); +assert.equal(explicit.threads, 3, 'expected explicit bundle thread count to apply'); + +const lowCountManifest = { + 'src/a.js': { bundles: ['a.json'] }, + 'src/b.js': { bundles: ['b.json'] } +}; +await fs.writeFile(path.join(bundleDir, 'a.json'), 'x'.repeat(256), 'utf8'); +await fs.writeFile(path.join(bundleDir, 'b.json'), 'x'.repeat(256), 'utf8'); + +const converged = resolveBundleWorkerAutotune({ + mode: 'code', + manifestFiles: lowCountManifest, + bundleDir, + threadLimits: { fileConcurrency: 16 }, + envConfig: {}, + profile: { modes: { code: { threads: 10 } } } +}); +assert.equal( + converged.threads, + 9, + 'expected convergence guard to step previous thread count by at most one' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('sqlite bundle worker autotune test passed'); diff --git a/tests/storage/sqlite/cache-generation-tag.test.js b/tests/storage/sqlite/cache-generation-tag.test.js new file mode 100644 index 000000000..2f9a86b10 --- /dev/null +++ b/tests/storage/sqlite/cache-generation-tag.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createSqliteDbCache } from '../../../src/retrieval/sqlite-cache.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-sqlite-cache-generation-')); +const dbPath = path.join(tempRoot, 'index.db'); +await fs.writeFile(dbPath, 'initial'); + +const cache = createSqliteDbCache(); +let closedA = 0; +let closedB = 0; +const dbA = { close: () => { closedA += 1; } }; +const dbB = { close: () => { closedB += 1; } }; + +cache.set(dbPath, dbA, { + generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-a' } +}); + +assert.equal( + cache.get(dbPath, { generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-a' } }), + dbA, + 'expected matching generation tag to hit sqlite cache' +); +assert.equal( + cache.get(dbPath, { generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-b' } }), + null, + 'expected build-generation mismatch to miss sqlite cache' +); +assert.equal( + cache.get(dbPath, { generationTag: { mode: 'code', buildId: 'build-b', buildGenerationKey: 'gen-b' } }), + null, + 'expected generation tag mismatch to miss sqlite cache' +); + +cache.set(dbPath, dbB, { + generationTag: { mode: 'code', buildId: 'build-b', buildGenerationKey: 'gen-b' } +}); + +assert.equal(closedA, 1, 'expected prior generation handle to close when replaced'); +assert.equal( + cache.get(dbPath, { generationTag: { mode: 'code', buildId: 'build-a', buildGenerationKey: 'gen-a' } }), + null, + 'expected prior generation to be evicted' +); +assert.equal( + cache.get(dbPath, { generationTag: { mode: 'code', buildId: 'build-b', buildGenerationKey: 'gen-b' } }), + dbB, + 'expected replacement generation to be cached' +); + +cache.close(dbPath, { generationTag: { mode: 'code', buildId: 'build-b', buildGenerationKey: 'gen-b' } }); +assert.equal(closedB, 1, 'expected closing a specific generation to close its handle'); + +console.log('sqlite cache generation-tag invalidation ok'); diff --git a/tests/storage/sqlite/sqlite-cache.test.js b/tests/storage/sqlite/cache.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-cache.test.js rename to tests/storage/sqlite/cache.test.js diff --git a/tests/storage/sqlite/sqlite-chunk-id.test.js b/tests/storage/sqlite/chunk-id.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-chunk-id.test.js rename to tests/storage/sqlite/chunk-id.test.js diff --git a/tests/storage/sqlite/chunk-meta-binary-columnar-budget-hardening.test.js b/tests/storage/sqlite/chunk-meta-binary-columnar-budget-hardening.test.js new file mode 100644 index 000000000..910316438 --- /dev/null +++ b/tests/storage/sqlite/chunk-meta-binary-columnar-budget-hardening.test.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { encodeBinaryRowFrames } from '../../../src/shared/artifact-io/binary-columnar.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; + +applyTestEnv({ + extraEnv: { + PAIROFCLEATS_TEST_MAX_JSON_BYTES: '1024' + } +}); + +const { iterateChunkMetaSources, resolveChunkMetaSources } = await import('../../../src/storage/sqlite/build/from-artifacts/sources.js'); + +const root = process.cwd(); +const testRoot = path.join(root, '.testLogs', 'sqlite-chunk-meta-binary-columnar-budget-hardening'); +const indexDir = path.join(testRoot, 'index-code'); + +await fs.rm(testRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + +const rows = [ + { + id: 0, + fileRef: 0, + file: null, + start: 0, + end: 24, + docmeta: { doc: `alpha-${'x'.repeat(700)}` } + }, + { + id: 1, + fileRef: 0, + file: null, + start: 24, + end: 64, + docmeta: { doc: `beta-${'y'.repeat(700)}` } + } +]; +const encoded = encodeBinaryRowFrames(rows.map((row) => Buffer.from(JSON.stringify(row), 'utf8'))); + +await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.bin'), encoded.dataBuffer); +await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.offsets.bin'), encoded.offsetsBuffer); +await fs.writeFile(path.join(indexDir, 'chunk_meta.binary-columnar.lengths.varint'), encoded.lengthsBuffer); +await fs.writeFile( + path.join(indexDir, 'chunk_meta.binary-columnar.meta.json'), + JSON.stringify({ + fields: { + format: 'binary-columnar-v1', + count: rows.length, + data: 'chunk_meta.binary-columnar.bin', + offsets: 'chunk_meta.binary-columnar.offsets.bin', + lengths: 'chunk_meta.binary-columnar.lengths.varint' + }, + arrays: { + fileTable: ['src/a.js'] + } + }, null, 2) +); + +await writePiecesManifest(indexDir, [ + { name: 'chunk_meta', path: 'chunk_meta.binary-columnar.bin', format: 'binary-columnar' }, + { name: 'chunk_meta_binary_columnar_meta', path: 'chunk_meta.binary-columnar.meta.json', format: 'json' }, + { name: 'chunk_meta_binary_columnar_offsets', path: 'chunk_meta.binary-columnar.offsets.bin', format: 'binary' }, + { name: 'chunk_meta_binary_columnar_lengths', path: 'chunk_meta.binary-columnar.lengths.varint', format: 'varint' } +]); + +const sources = resolveChunkMetaSources(indexDir); +assert.equal(sources?.format, 'binary-columnar', 'expected chunk_meta source resolution to detect binary-columnar format'); + +await assert.rejects( + () => iterateChunkMetaSources(sources, () => {}), + (err) => String(err?.message || '').toLowerCase().includes('exceeds maxbytes'), + 'expected sqlite chunk_meta binary-columnar path to enforce maxBytes budget' +); + +console.log('sqlite chunk_meta binary-columnar budget hardening test passed'); diff --git a/tests/storage/sqlite/chunk-meta-json-gzip-fallback.test.js b/tests/storage/sqlite/chunk-meta-json-gzip-fallback.test.js new file mode 100644 index 000000000..c28f43aba --- /dev/null +++ b/tests/storage/sqlite/chunk-meta-json-gzip-fallback.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite chunk_meta gzip fallback test requires better-sqlite3' }); + +let Database = null; +({ default: Database } = await import('better-sqlite3')); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-json-gzip-fallback'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 24; +const chunks = Array.from({ length: chunkCount }, (_, i) => ({ + id: i, + file: `src/file-${i % 4}.js`, + start: 0, + end: 10, + startLine: 1, + endLine: 1, + kind: 'code', + name: `fn${i}`, + tokens: ['alpha', 'beta'] +})); + +await fs.writeFile( + path.join(indexDir, 'chunk_meta.json.gz'), + gzipSync(Buffer.from(JSON.stringify(chunks), 'utf8')) +); +await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { + fields: { + avgDocLen: 2, + totalDocs: chunkCount + }, + arrays: { + vocab: ['alpha', 'beta'], + postings: [ + Array.from({ length: chunkCount }, (_, docId) => [docId, 1]), + Array.from({ length: chunkCount }, (_, docId) => [docId, 1]) + ], + docLengths: Array.from({ length: chunkCount }, () => 2) + }, + atomic: true +}); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta.json.gz'); +const count = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null } +}); +assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks from gzip JSON artifact'); + +const db = new Database(outPath); +const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); +assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match gzip chunk count'); +db.close(); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite chunk_meta json gzip fallback test passed'); diff --git a/tests/storage/sqlite/chunk-meta-meta-fallback.test.js b/tests/storage/sqlite/chunk-meta-meta-fallback.test.js new file mode 100644 index 000000000..45697cd00 --- /dev/null +++ b/tests/storage/sqlite/chunk-meta-meta-fallback.test.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +let Database = null; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-meta-fallback'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 200; +const tokens = ['alpha', 'beta']; +const chunks = Array.from({ length: chunkCount }, (_, i) => ({ + id: i, + file: `src/file-${i % 10}.js`, + start: 0, + end: 10, + startLine: 1, + endLine: 1, + kind: 'code', + name: `fn${i}`, + tokens +})); + +await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), chunks, { atomic: true }); +await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { + fields: { + schemaVersion: '0.0.1', + artifact: 'chunk_meta', + format: 'jsonl-sharded', + generatedAt: new Date().toISOString(), + compression: 'none', + totalRecords: chunkCount, + totalBytes: 0, + maxPartRecords: chunkCount, + maxPartBytes: 0, + targetMaxBytes: 0 + }, + atomic: true +}); + +const postingsDir = path.join(indexDir, 'token_postings.shards'); +await fs.mkdir(postingsDir, { recursive: true }); +const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); +const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); +await writeJsonObjectFile(postingsPart, { + arrays: { + vocab: ['alpha'], + postings: [postingsEntries] + }, + atomic: true +}); +const docLengths = Array.from({ length: chunkCount }, () => tokens.length); +await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { + fields: { + avgDocLen: tokens.length, + totalDocs: chunkCount, + format: 'sharded', + shardSize: 1, + vocabCount: 1, + parts: ['token_postings.shards/token_postings.part-00000.json'] + }, + arrays: { docLengths }, + atomic: true +}); +await writePiecesManifest(indexDir, [ + { name: 'chunk_meta', path: 'chunk_meta.jsonl', format: 'jsonl' }, + { + name: 'token_postings', + path: 'token_postings.shards/token_postings.part-00000.json', + format: 'sharded' + }, + { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } +]); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect fallback chunk_meta.jsonl'); +const count = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null } +}); +assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); + +const db = new Database(outPath); +const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); +assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); +db.close(); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite chunk_meta meta fallback test passed'); diff --git a/tests/storage/sqlite/chunk-meta-streaming.test.js b/tests/storage/sqlite/chunk-meta-streaming.test.js new file mode 100644 index 000000000..68902fab0 --- /dev/null +++ b/tests/storage/sqlite/chunk-meta-streaming.test.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './helpers/build-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const Database = await loadDatabaseCtor(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-streaming'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 5000; +const tokens = ['alpha', 'beta']; + +const { shardResult, pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 10, + mode: 'code', + tokens, + chunkMaxBytes: 8192, + tokenVocab: ['alpha'] +}); +if (shardResult.parts.length < 2) { + console.error('Expected chunk_meta to be sharded for streaming test.'); + process.exit(1); +} +await writePiecesManifest(indexDir, pieceEntries); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); +const sqliteStats = {}; +const count = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + stats: sqliteStats +}); +assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); +assert.ok(sqliteStats.chunkMeta, 'expected sqlite stats to include chunkMeta metrics'); +assert.equal(sqliteStats.chunkMeta.passes, 1, 'expected single consolidated chunk_meta ingest pass'); +assert.equal(sqliteStats.chunkMeta.rows, chunkCount, 'expected chunkMeta rows metric to match ingested chunks'); +assert.equal( + sqliteStats.chunkMeta.streamedRows, + chunkCount, + 'expected sharded/jsonl chunk_meta rows to be counted as streamed' +); +assert.equal( + sqliteStats.chunkMeta.sourceRows?.jsonl, + chunkCount, + 'expected sourceRows.jsonl to match chunk count' +); +assert.ok( + Number(sqliteStats.chunkMeta.sourceFiles?.jsonl) >= 2, + 'expected sourceFiles.jsonl to include multiple shard files' +); +assert.equal( + sqliteStats.chunkMeta.tokenTextMaterialized, + chunkCount, + 'expected token text materialization count for populated token arrays' +); +assert.equal( + sqliteStats.chunkMeta.tokenTextSkipped, + 0, + 'expected no token text skips when all chunks include tokens' +); + +const db = new Database(outPath); +const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); +assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); +db.close(); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite chunk_meta streaming test passed'); + diff --git a/tests/storage/sqlite/sqlite-dense-meta-fallback.test.js b/tests/storage/sqlite/dense-meta-fallback.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-dense-meta-fallback.test.js rename to tests/storage/sqlite/dense-meta-fallback.test.js diff --git a/tests/storage/sqlite/fts-contentless-schema.test.js b/tests/storage/sqlite/fts-contentless-schema.test.js new file mode 100644 index 000000000..2fb828318 --- /dev/null +++ b/tests/storage/sqlite/fts-contentless-schema.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './helpers/build-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv({ testing: '1' }); + +const Database = await loadDatabaseCtor(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-fts-contentless-schema'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 50; +const tokens = ['hello', 'world']; +const { pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 3, + mode: 'code', + tokens, + tokenVocab: ['hello'], + decorateChunk: (_chunk, index) => ({ + docmeta: { + signature: `sig:${index}`, + doc: `hello world ${index}` + } + }) +}); +await writePiecesManifest(indexDir, pieceEntries); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); + +await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + statementStrategy: 'prepared', + optimize: false, + buildPragmas: false +}); + +assert.ok(fsSync.existsSync(outPath), 'expected sqlite DB to be created'); + +const db = new Database(outPath); +const createRow = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chunks_fts'") + .get(); +assert.equal(typeof createRow?.sql, 'string', 'expected sqlite_master SQL for chunks_fts'); +assert.match(createRow.sql, /content\s*=\s*''/i, 'expected chunks_fts to be contentless'); +assert.match(createRow.sql, /contentless_delete\s*=\s*1/i, 'expected contentless_delete=1'); + +const matches = db.prepare('SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?').all('hello'); +assert.ok(matches.length > 0, 'expected FTS MATCH to find inserted rows'); + +const probe = db.prepare('SELECT doc FROM chunks_fts WHERE rowid = ?').get(matches[0].rowid); +assert.equal(probe?.doc, null, 'expected contentless FTS doc column to return null'); + +// Verify incremental delete semantics are supported for contentless FTS. +db.prepare('DELETE FROM chunks_fts WHERE rowid = ?').run(matches[0].rowid); + +db.close(); + +console.log('sqlite fts contentless schema test passed'); + diff --git a/tests/storage/sqlite/helpers/build-fixture.js b/tests/storage/sqlite/helpers/build-fixture.js index b373efa02..27f1fc1b8 100644 --- a/tests/storage/sqlite/helpers/build-fixture.js +++ b/tests/storage/sqlite/helpers/build-fixture.js @@ -1,14 +1,17 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../../src/shared/json-stream.js'; +import { writeJsonLinesSharded } from '../../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../../src/shared/json-stream/json-writers.js'; import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../../src/storage/sqlite/build/from-artifacts.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; import { writePiecesManifest } from '../../../helpers/artifact-io-fixture.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; -const loadDatabaseCtor = async () => { +export const SQLITE_BUILD_FIXTURE_DEFAULT_TOKENS = Object.freeze(['alpha', 'beta']); + +export const loadDatabaseCtor = async () => { try { const loaded = await import('better-sqlite3'); return loaded.default; @@ -17,47 +20,73 @@ const loadDatabaseCtor = async () => { } }; -export const setupSqliteBuildFixture = async ({ - tempLabel, +export function* createSqliteBuildFixtureChunks({ chunkCount, fileCount = 3, mode = 'code', - includeRowcountArtifacts = false -}) => { - applyTestEnv({ testing: '1' }); - - const Database = await loadDatabaseCtor(); - const root = process.cwd(); - const tempRoot = resolveTestCachePath(root, tempLabel); - const indexDir = path.join(tempRoot, 'index-code'); - const outPath = path.join(tempRoot, 'index-code.db'); - - await fs.rm(tempRoot, { recursive: true, force: true }); - await fs.mkdir(indexDir, { recursive: true }); + tokens = SQLITE_BUILD_FIXTURE_DEFAULT_TOKENS, + decorateChunk = null +}) { + for (let i = 0; i < chunkCount; i += 1) { + const chunk = { + id: i, + file: `src/file-${i % fileCount}.js`, + start: 0, + end: 10, + startLine: 1, + endLine: 1, + kind: mode, + name: `fn${i}`, + tokens + }; + const extra = typeof decorateChunk === 'function' ? decorateChunk(chunk, i) : null; + yield extra && typeof extra === 'object' ? { ...chunk, ...extra } : chunk; + } +} - const tokens = ['alpha', 'beta']; - const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % fileCount}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: mode, - name: `fn${i}`, - tokens - }; - } - }; +export const createSqliteShardFixturePieceEntries = (shardResult) => [ + ...shardResult.parts.map((part) => ({ + name: 'chunk_meta', + path: part, + format: 'jsonl' + })), + { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, + { + name: 'token_postings', + path: 'token_postings.shards/token_postings.part-00000.json', + format: 'sharded' + }, + { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } +]; +export const writeSqliteShardFixtureArtifacts = async ({ + indexDir, + chunkCount, + fileCount = 3, + mode = 'code', + tokens = SQLITE_BUILD_FIXTURE_DEFAULT_TOKENS, + tokenVocab = [tokens[0] || 'alpha'], + tokenPostings = null, + docLengths = null, + avgDocLen = null, + tokenShardSize = null, + chunkMaxBytes = 4096, + compression = 'none', + decorateChunk = null +}) => { const shardResult = await writeJsonLinesSharded({ dir: indexDir, partsDirName: 'chunk_meta.parts', partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 4096, + items: createSqliteBuildFixtureChunks({ + chunkCount, + fileCount, + mode, + tokens, + decorateChunk + }), + maxBytes: chunkMaxBytes, + compression, atomic: true }); await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { @@ -66,7 +95,7 @@ export const setupSqliteBuildFixture = async ({ artifact: 'chunk_meta', format: 'jsonl-sharded', generatedAt: new Date().toISOString(), - compression: 'none', + compression, totalRecords: shardResult.total, totalBytes: shardResult.totalBytes, maxPartRecords: shardResult.maxPartRecords, @@ -85,40 +114,81 @@ export const setupSqliteBuildFixture = async ({ await fs.mkdir(postingsDir, { recursive: true }); const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); + const resolvedTokenPostings = Array.isArray(tokenPostings) + ? tokenPostings + : tokenVocab.map(() => postingsEntries); await writeJsonObjectFile(postingsPart, { arrays: { - vocab: ['alpha'], - postings: [postingsEntries] + vocab: tokenVocab, + postings: resolvedTokenPostings }, atomic: true }); - const docLengths = Array.from({ length: chunkCount }, () => tokens.length); + const resolvedDocLengths = Array.isArray(docLengths) + ? docLengths + : Array.from({ length: chunkCount }, () => tokens.length); await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { fields: { - avgDocLen: tokens.length, + avgDocLen: avgDocLen ?? tokens.length, totalDocs: chunkCount, format: 'sharded', - shardSize: 1, - vocabCount: 1, + shardSize: tokenShardSize ?? Math.max(1, tokenVocab.length), + vocabCount: tokenVocab.length, parts: ['token_postings.shards/token_postings.part-00000.json'] }, - arrays: { docLengths }, + arrays: { docLengths: resolvedDocLengths }, atomic: true }); - const pieceEntries = [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } - ]; + + return { + shardResult, + pieceEntries: createSqliteShardFixturePieceEntries(shardResult), + tokens + }; +}; + +export const setupSqliteBuildFixture = async ({ + tempLabel, + chunkCount, + fileCount = 3, + mode = 'code', + tokens = SQLITE_BUILD_FIXTURE_DEFAULT_TOKENS, + tokenVocab = [tokens[0] || 'alpha'], + tokenPostings = null, + docLengths = null, + avgDocLen = null, + tokenShardSize = null, + chunkMaxBytes = 4096, + compression = 'none', + decorateChunk = null, + includeRowcountArtifacts = false +}) => { + applyTestEnv({ testing: '1' }); + + const Database = await loadDatabaseCtor(); + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, tempLabel); + const indexDir = path.join(tempRoot, 'index-code'); + const outPath = path.join(tempRoot, 'index-code.db'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(indexDir, { recursive: true }); + + const { shardResult, pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount, + mode, + tokens, + tokenVocab, + tokenPostings, + docLengths, + avgDocLen, + tokenShardSize, + chunkMaxBytes, + compression, + decorateChunk + }); let phraseDocIds = []; if (includeRowcountArtifacts) { @@ -195,6 +265,7 @@ export const setupSqliteBuildFixture = async ({ count, chunkCount, fileCount, + shardResult, phraseDocIds, indexPieces }; diff --git a/tests/storage/sqlite/helpers/bundle-fixture.js b/tests/storage/sqlite/helpers/bundle-fixture.js new file mode 100644 index 000000000..cd9d35898 --- /dev/null +++ b/tests/storage/sqlite/helpers/bundle-fixture.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { validateSqliteMetaV2Parity } from '../../../../src/index/validate/checks.js'; +import { writeBundleFile } from '../../../../src/shared/bundle-io.js'; +import { buildDatabaseFromBundles } from '../../../../src/storage/sqlite/build/from-bundles.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const loadSqliteBundleDatabase = async (reason = 'sqlite bundle tests') => { + try { + const loaded = await import('better-sqlite3'); + return loaded.default; + } catch { + console.error(`better-sqlite3 is required for ${reason}.`); + process.exit(1); + } +}; + +export const prepareBundleBuildFixture = async ({ label, dbName }) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, label); + const bundleDir = path.join(tempRoot, 'bundles'); + const dbPath = path.join(tempRoot, dbName); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(bundleDir, { recursive: true }); + + return { tempRoot, bundleDir, dbPath }; +}; + +export const writeSingleChunkBundle = async ({ bundleDir, bundleName, file, chunk }) => { + await writeBundleFile({ + bundlePath: path.join(bundleDir, bundleName), + format: 'json', + bundle: { + file, + chunks: [chunk] + } + }); +}; + +export const createBundleManifest = (entries) => ({ + files: Object.fromEntries(entries.map((entry) => [ + entry.file, + { + bundles: entry.bundles, + mtimeMs: entry.mtimeMs, + size: entry.size, + hash: entry.hash + } + ])) +}); + +export const buildBundleDatabase = async ({ + Database, + dbPath, + mode, + manifest, + bundleDir, + threadLimits = { fileConcurrency: 1 } +}) => buildDatabaseFromBundles({ + Database, + outPath: dbPath, + mode, + incrementalData: { manifest, bundleDir }, + envConfig: { bundleThreads: 1 }, + threadLimits, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + workerPath: null +}); + +export const readBundleRows = ({ Database, dbPath, mode }) => { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare('SELECT id, chunk_id, metaV2_json FROM chunks WHERE mode = ? ORDER BY id') + .all(mode); + } finally { + db.close(); + } +}; + +export const assertMetaV2Parity = ({ mode, chunkMeta, rows }) => { + const report = { issues: [], warnings: [], hints: [] }; + validateSqliteMetaV2Parity(report, mode, chunkMeta, rows, { maxErrors: 10 }); + assert.equal(report.issues.length, 0, `expected no sqlite metaV2 parity issues: ${report.issues.join(', ')}`); +}; diff --git a/tests/storage/sqlite/helpers/incremental-bundle-db-fixture.js b/tests/storage/sqlite/helpers/incremental-bundle-db-fixture.js new file mode 100644 index 000000000..8e87b882a --- /dev/null +++ b/tests/storage/sqlite/helpers/incremental-bundle-db-fixture.js @@ -0,0 +1,117 @@ +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; + +import { writeBundleFile } from '../../../../src/shared/bundle-io.js'; +import { buildDatabaseFromBundles } from '../../../../src/storage/sqlite/build/from-bundles.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export function buildIncrementalChunks({ file, suffix, chunksPerFile }) { + const chunks = []; + for (let i = 0; i < chunksPerFile; i += 1) { + chunks.push({ + file, + start: i * 10, + end: i * 10 + 5, + startLine: i + 1, + endLine: i + 1, + kind: 'code', + name: `fn${suffix}-${i}`, + tokens: [`tok-${suffix}`, `tok-${i}`] + }); + } + return chunks; +} + +export async function setupIncrementalBundleDatabase({ + Database, + name, + fileCount, + chunksPerFile +}) { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, name); + const bundleDir = path.join(tempRoot, 'bundles'); + const outPath = path.join(tempRoot, 'index-code.db'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(bundleDir, { recursive: true }); + + const files = Array.from({ length: fileCount }, (_, i) => `src/file-${i}.js`); + const manifest = { files: {} }; + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + const bundleName = `bundle-${i}.json`; + await writeBundleFile({ + bundlePath: path.join(bundleDir, bundleName), + bundle: { + chunks: buildIncrementalChunks({ + file, + suffix: `v1-${i}`, + chunksPerFile + }) + }, + format: 'json' + }); + manifest.files[file] = { + hash: `hash-${i}`, + mtimeMs: 1000 + i, + size: 10 + i, + bundles: [bundleName] + }; + } + + const envConfig = { bundleThreads: 1 }; + const threadLimits = { fileConcurrency: 1 }; + await buildDatabaseFromBundles({ + Database, + outPath, + mode: 'code', + incrementalData: { manifest, bundleDir }, + envConfig, + threadLimits, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null } + }); + + if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created before incremental update.'); + process.exit(1); + } + + return { bundleDir, chunksPerFile, files, manifest, outPath }; +} + +export async function addChangedBundle({ + bundleDir, + chunksPerFile, + files, + manifest, + changedFileIndex, + changedBundleName = 'bundle-changed.json', + suffix = 'v2' +}) { + const updatedManifest = { files: { ...manifest.files } }; + const changedFile = files[changedFileIndex]; + await writeBundleFile({ + bundlePath: path.join(bundleDir, changedBundleName), + bundle: { + chunks: buildIncrementalChunks({ + file: changedFile, + suffix, + chunksPerFile + }) + }, + format: 'json' + }); + updatedManifest.files[changedFile] = { + ...updatedManifest.files[changedFile], + hash: 'hash-changed', + mtimeMs: 9999, + bundles: [changedBundleName] + }; + + return { changedFile, updatedManifest }; +} diff --git a/tests/storage/sqlite/helpers/incremental-scenarios.js b/tests/storage/sqlite/helpers/incremental-scenarios.js new file mode 100644 index 000000000..8eea77399 --- /dev/null +++ b/tests/storage/sqlite/helpers/incremental-scenarios.js @@ -0,0 +1,163 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { setupIncrementalRepo, ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; + +let DatabaseCtor = null; + +const writeMinimalIncrementalFixture = async (repoRoot) => { + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'index.js'), + [ + 'export function greet(name = "world") {', + ' return `hello ${name}`;', + '}', + '' + ].join('\n'), + 'utf8' + ); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'util.js'), + [ + 'export function utilValue() {', + ' return 7;', + '}', + '' + ].join('\n'), + 'utf8' + ); + await fsPromises.writeFile( + path.join(repoRoot, 'README.md'), + '# Incremental sqlite fixture\n\nsmall synthetic fixture\n', + 'utf8' + ); +}; + +export const loadDatabaseCtorOrExit = async () => { + if (DatabaseCtor) return DatabaseCtor; + try { + ({ default: DatabaseCtor } = await import('better-sqlite3')); + return DatabaseCtor; + } catch { + console.error('better-sqlite3 is required for sqlite incremental tests.'); + process.exit(1); + } +}; + +export const createIncrementalScenario = async ({ + name, + mode = null, + scmProvider = null +} = {}) => { + const fixture = await setupIncrementalRepo({ + name, + fixtureBuilder: writeMinimalIncrementalFixture, + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + } + }); + + const runBuildIndex = ({ incremental = false } = {}) => { + const args = [ + path.join(fixture.root, 'build_index.js'), + '--incremental', + '--stage', + 'stage1', + '--stub-embeddings' + ]; + if (mode) { + args.push('--mode', mode); + } + if (scmProvider) { + args.push('--scm-provider', scmProvider); + } + args.push('--repo', fixture.repoRoot); + fixture.run( + args, + incremental ? 'build index (incremental)' : 'build index', + { cwd: fixture.repoRoot, env: fixture.env, stdio: 'inherit' } + ); + }; + + const runBuildSqlite = async ({ incremental = false, logger = null } = {}) => runSqliteBuild(fixture.repoRoot, { + mode: mode || 'all', + incremental, + logger + }); + + const resolveCodeDbPath = () => ensureSqlitePaths(fixture.repoRoot, fixture.userConfig).codePath; + + const openCodeDb = async ({ readonly = true } = {}) => { + const Database = await loadDatabaseCtorOrExit(); + return new Database(resolveCodeDbPath(), { readonly }); + }; + + return { + ...fixture, + runBuildIndex, + runBuildSqlite, + resolveCodeDbPath, + openCodeDb + }; +}; + +export const appendFixtureExport = async ( + repoRoot, + { + relativePath = path.join('src', 'index.js'), + source = '\nexport function farewell(name) {\n return `bye ${name}`;\n}\n' + } = {} +) => { + const targetPath = path.join(repoRoot, relativePath); + const original = await fsPromises.readFile(targetPath, 'utf8'); + const updated = `${original}${source}`; + await fsPromises.writeFile(targetPath, updated); + return { targetPath, original, updated }; +}; + +export const runRepoSearchJson = ({ + root, + repoRoot, + env, + query, + backend = 'sqlite-fts', + mode = null +}) => { + const args = [path.join(root, 'search.js'), query, '--json', '--backend', backend]; + if (mode) { + args.push('--mode', mode); + } + args.push('--repo', repoRoot); + const result = runNode( + args, + 'sqlite incremental repo search', + repoRoot, + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + return { + ...result, + payload: JSON.parse(result.stdout || '{}') + }; +}; diff --git a/tests/storage/sqlite/helpers/jsonl-streaming-matrix.js b/tests/storage/sqlite/helpers/jsonl-streaming-matrix.js index 885fcc903..8ea162cb3 100644 --- a/tests/storage/sqlite/helpers/jsonl-streaming-matrix.js +++ b/tests/storage/sqlite/helpers/jsonl-streaming-matrix.js @@ -2,7 +2,6 @@ import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../../src/shared/json-stream.js'; import { tryRequire } from '../../../../src/shared/optional-deps.js'; import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../../src/storage/sqlite/build/from-artifacts.js'; import { skip } from '../../../helpers/skip.js'; @@ -10,15 +9,10 @@ import { applyTestEnv } from '../../../helpers/test-env.js'; import { writePiecesManifest } from '../../../helpers/artifact-io-fixture.js'; import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const loadDatabaseCtor = async () => { - try { - const loaded = await import('better-sqlite3'); - return loaded.default; - } catch (err) { - throw new Error(`better-sqlite3 missing: ${err?.message || err}`); - } -}; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './build-fixture.js'; const ensureCompressionSupport = (compression) => { if (compression === 'zstd' && !tryRequire('@mongodb-js/zstd').ok) { @@ -51,91 +45,18 @@ export const runSqliteJsonlStreamingCompressionCase = async ({ const chunkCount = 600; const tokens = ['alpha', 'beta']; - const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 10}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens - }; - } - }; - const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 8192, + const { shardResult, pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 10, + mode: 'code', + tokens, + chunkMaxBytes: 8192, compression, - atomic: true - }); - - await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression, - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true - }); - - const postingsDir = path.join(indexDir, 'token_postings.shards'); - await fs.mkdir(postingsDir, { recursive: true }); - const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); - const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); - await writeJsonObjectFile(postingsPart, { - arrays: { - vocab: ['alpha'], - postings: [postingsEntries] - }, - atomic: true - }); - const docLengths = Array.from({ length: chunkCount }, () => tokens.length); - await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 1, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true + tokenVocab: ['alpha'] }); - await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } - ]); + await writePiecesManifest(indexDir, pieceEntries); const indexPieces = await loadIndexPieces(indexDir, null); const count = await buildDatabaseFromArtifacts({ diff --git a/tests/storage/sqlite/helpers/maintenance-scenarios.js b/tests/storage/sqlite/helpers/maintenance-scenarios.js new file mode 100644 index 000000000..93bc8920f --- /dev/null +++ b/tests/storage/sqlite/helpers/maintenance-scenarios.js @@ -0,0 +1,174 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { loadUserConfig, resolveSqlitePaths } from '../../../../tools/shared/dict-utils.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const root = process.cwd(); + +const createFixture = async (name) => { + const tempRoot = resolveTestCachePath(root, name); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + + const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } + }); + + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'base.js'), + 'export function sqliteMaintenanceBase() { return "sqlite-maintenance-base"; }\n' + ); + + const run = (args, label) => { + const result = runNode(args, label, repoRoot, env, { + stdio: 'inherit', + allowFailure: true + }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } + }; + + return { + env, + repoRoot, + run + }; +}; + +export const runCompactScenario = async () => { + const { env, repoRoot, run } = await createFixture('sqlite-maintenance-compact'); + const deletableFile = path.join(repoRoot, 'src', 'deletable.js'); + const renameFile = path.join(repoRoot, 'src', 'rename_me.js'); + await fsPromises.writeFile(deletableFile, 'export const xqzflorb = "xqzflorb";\n'); + await fsPromises.writeFile(renameFile, 'export function renameToken() { return "renametoken"; }\n'); + + run( + [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', repoRoot], + 'build index' + ); + await runSqliteBuild(repoRoot, { mode: 'code', env }); + + const renamedFile = path.join(repoRoot, 'src', 'renamed.js'); + await fsPromises.rm(deletableFile, { force: true }); + await fsPromises.rename(renameFile, renamedFile); + + run( + [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', repoRoot], + 'build index (incremental)' + ); + await runSqliteBuild(repoRoot, { mode: 'code', incremental: true, env }); + run( + [path.join(root, 'tools', 'build', 'compact-sqlite-index.js'), '--repo', repoRoot], + 'compact sqlite index' + ); + + let Database; + try { + ({ default: Database } = await import('better-sqlite3')); + } catch { + console.error('better-sqlite3 is required for sqlite maintenance contract matrix.'); + process.exit(1); + } + + const sqlitePaths = resolveSqlitePaths(repoRoot, loadUserConfig(repoRoot)); + const db = new Database(sqlitePaths.codePath, { readonly: true }); + const stats = db.prepare('SELECT COUNT(*) AS total, MAX(id) AS maxId FROM chunks WHERE mode = ?').get('code') || {}; + const total = Number(stats.total) || 0; + const maxId = Number(stats.maxId); + if (total && maxId !== total - 1) { + throw new Error(`Compaction failed: expected maxId=${total - 1} got ${maxId}`); + } + + const oldFile = db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE mode = ? AND file = ?').get('code', 'src/rename_me.js'); + if (oldFile?.count) { + throw new Error('Compaction failed: old file name still present.'); + } + + const manifestOld = db.prepare('SELECT COUNT(*) AS count FROM file_manifest WHERE mode = ? AND file = ?').get('code', 'src/rename_me.js'); + if (manifestOld?.count) { + throw new Error('Compaction failed: old file name still in file_manifest.'); + } + + const manifestNew = db.prepare('SELECT COUNT(*) AS count FROM file_manifest WHERE mode = ? AND file = ?').get('code', 'src/renamed.js'); + if (!manifestNew?.count) { + throw new Error('Compaction failed: renamed file missing from file_manifest.'); + } + + const vocabHit = db.prepare('SELECT COUNT(*) AS count FROM token_vocab WHERE mode = ? AND token = ?').get('code', 'xqzflorb'); + if (vocabHit?.count) { + throw new Error('Compaction failed: deleted token still in vocab.'); + } + + db.close(); +}; + +export const runSidecarCleanupScenario = async () => { + const { repoRoot, run } = await createFixture('sqlite-maintenance-sidecar-cleanup'); + run([path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', repoRoot], 'build index'); + await runSqliteBuild(repoRoot, { mode: 'code' }); + + const userConfig = loadUserConfig(repoRoot); + let sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); + let walPath = `${sqlitePaths.codePath}-wal`; + let shmPath = `${sqlitePaths.codePath}-shm`; + await fsPromises.writeFile(walPath, 'stale-wal'); + await fsPromises.writeFile(shmPath, 'stale-shm'); + + await runSqliteBuild(repoRoot, { mode: 'code' }); + + const staleWal = fs.existsSync(walPath) ? fs.readFileSync(walPath) : null; + const staleShm = fs.existsSync(shmPath) ? fs.readFileSync(shmPath) : null; + if (staleWal && staleWal.toString('utf8') === 'stale-wal') { + throw new Error('Stale WAL sidecar was not cleaned up.'); + } + if (staleShm && staleShm.toString('utf8') === 'stale-shm') { + throw new Error('Stale SHM sidecar was not cleaned up.'); + } + + run( + [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', repoRoot], + 'build index (incremental)' + ); + sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); + walPath = `${sqlitePaths.codePath}-wal`; + shmPath = `${sqlitePaths.codePath}-shm`; + await fsPromises.mkdir(path.dirname(walPath), { recursive: true }); + await fsPromises.writeFile(walPath, 'stale-wal'); + await fsPromises.writeFile(shmPath, 'stale-shm'); + await runSqliteBuild(repoRoot, { mode: 'code', incremental: true }); + const incrementalWal = fs.existsSync(walPath) ? fs.readFileSync(walPath) : null; + const incrementalShm = fs.existsSync(shmPath) ? fs.readFileSync(shmPath) : null; + if (incrementalWal && incrementalWal.toString('utf8') === 'stale-wal') { + throw new Error('Incremental WAL sidecar was not cleaned up.'); + } + if (incrementalShm && incrementalShm.toString('utf8') === 'stale-shm') { + throw new Error('Incremental SHM sidecar was not cleaned up.'); + } +}; diff --git a/tests/storage/sqlite/helpers/pragmas-fixture.js b/tests/storage/sqlite/helpers/pragmas-fixture.js new file mode 100644 index 000000000..7850c6156 --- /dev/null +++ b/tests/storage/sqlite/helpers/pragmas-fixture.js @@ -0,0 +1,40 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const loadSqlitePragmaDatabase = async () => { + try { + const loaded = await import('better-sqlite3'); + return loaded.default; + } catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); + } +}; + +export const preparePragmaTestRoot = async (label) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, label); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + return tempRoot; +}; + +export const readPragmaValue = (db, name) => { + try { + return db.pragma(name, { simple: true }); + } catch { + return null; + } +}; + +export const openPragmaTestDatabase = async ({ label, name, Database }) => { + const tempRoot = await preparePragmaTestRoot(label); + const dbPath = path.join(tempRoot, name); + return { + tempRoot, + dbPath, + db: new Database(dbPath) + }; +}; diff --git a/tests/storage/sqlite/helpers/search-backend-scenarios.js b/tests/storage/sqlite/helpers/search-backend-scenarios.js new file mode 100644 index 000000000..04866a2eb --- /dev/null +++ b/tests/storage/sqlite/helpers/search-backend-scenarios.js @@ -0,0 +1,88 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { resolveSqlitePaths } from '../../../../tools/shared/dict-utils.js'; + +const root = process.cwd(); + +export const createSearchBackendFixture = async (name) => { + const tempRoot = resolveTestCachePath(root, name); + const cacheRoot = path.join(tempRoot, '.cache'); + const snapshotRoot = path.join(tempRoot, '.sqlite-snapshot'); + const searchPath = path.join(root, 'search.js'); + const buildIndexPath = path.join(root, 'build_index.js'); + + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + await fsPromises.writeFile( + path.join(tempRoot, 'sample.js'), + [ + 'export function greet(name) {', + ' return "hello " + name;', + '}', + '' + ].join('\n'), + 'utf8' + ); + + const buildTestEnv = (testConfig = null, extraEnv = {}) => applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: testConfig ?? { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off', + ...extraEnv + } + }); + + const run = (args, label, { testConfig = null, extraEnv = {}, allowFailure = false } = {}) => { + const result = runNode(args, label, tempRoot, buildTestEnv(testConfig, extraEnv), { + stdio: 'pipe', + encoding: 'utf8', + allowFailure: true + }); + if (result.status !== 0 && !allowFailure) { + console.error(`Failed: ${label}`); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); + } + return result; + }; + + run([buildIndexPath, '--stub-embeddings', '--stage', 'stage1', '--mode', 'code', '--repo', tempRoot], 'build index'); + await runSqliteBuild(tempRoot, { mode: 'code' }); + const initialSqlitePaths = resolveSqlitePaths(tempRoot, null); + await fsPromises.rm(snapshotRoot, { recursive: true, force: true }); + await fsPromises.cp(initialSqlitePaths.dbDir, snapshotRoot, { recursive: true }); + + return { + tempRoot, + snapshotRoot, + searchPath, + run, + resolveSqlitePaths: () => resolveSqlitePaths(tempRoot, null), + restoreSnapshot: async () => { + const sqlitePaths = resolveSqlitePaths(tempRoot, null); + await fsPromises.cp(snapshotRoot, sqlitePaths.dbDir, { recursive: true }); + } + }; +}; + +export const parseJsonPayload = (result) => JSON.parse(result.stdout || '{}'); diff --git a/tests/storage/sqlite/helpers/token-postings-streamed-fixture.js b/tests/storage/sqlite/helpers/token-postings-streamed-fixture.js new file mode 100644 index 000000000..b0f39c96a --- /dev/null +++ b/tests/storage/sqlite/helpers/token-postings-streamed-fixture.js @@ -0,0 +1,164 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { writeJsonLinesFile } from '../../../../src/shared/json-stream/jsonl-write.js'; +import { writeJsonObjectFile } from '../../../../src/shared/json-stream/json-writers.js'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../../helpers/artifact-io-fixture.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const TOKEN_POSTINGS_STREAMED_CHUNKS = Object.freeze([ + Object.freeze({ + id: 0, + file: 'src/a.js', + start: 0, + end: 8, + startLine: 1, + endLine: 1, + kind: 'code', + name: 'a', + tokens: Object.freeze(['alpha', 'beta', 'alpha']) + }), + Object.freeze({ + id: 1, + file: 'src/b.js', + start: 0, + end: 6, + startLine: 1, + endLine: 1, + kind: 'code', + name: 'b', + tokens: Object.freeze(['beta']) + }) +]); + +export const loadSqliteDatabase = async () => { + const loaded = await import('better-sqlite3'); + return loaded.default; +}; + +export const setupStreamedTokenPostingsFixture = async ({ tempLabel }) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, tempLabel); + const indexDir = path.join(tempRoot, 'index-code'); + const outPath = path.join(tempRoot, 'index-code.db'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(indexDir, { recursive: true }); + await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), TOKEN_POSTINGS_STREAMED_CHUNKS, { atomic: true }); + + return { + chunks: TOKEN_POSTINGS_STREAMED_CHUNKS, + indexDir, + outPath, + tempRoot + }; +}; + +export const loadStreamedTokenPostingsIndexPieces = async (indexDir) => loadIndexPieces(indexDir, null); + +export const setupTokenPostingsArtifactFixture = async ({ + tempLabel, + chunks, + tokenPostings, + pieceEntries = null +}) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, tempLabel); + const indexDir = path.join(tempRoot, 'index-code'); + const outPath = path.join(tempRoot, 'index-code.db'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(indexDir, { recursive: true }); + await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), chunks, { atomic: true }); + + if (tokenPostings?.sharded) { + const shardDir = path.join(indexDir, 'token_postings.shards'); + await fs.mkdir(shardDir, { recursive: true }); + await writeJsonObjectFile(path.join(shardDir, 'token_postings.part-00000.json'), { + ...tokenPostings.sharded.part, + atomic: true + }); + await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { + ...tokenPostings.sharded.meta, + atomic: true + }); + } else if (tokenPostings?.json) { + await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { + ...tokenPostings.json, + atomic: true + }); + } + + await writePiecesManifest(indexDir, pieceEntries || [ + { name: 'chunk_meta', path: 'chunk_meta.jsonl', format: 'jsonl' }, + { name: 'token_postings', path: 'token_postings.json', format: 'json' } + ]); + + return { + indexDir, + outPath, + tempRoot, + indexPieces: await loadIndexPieces(indexDir, null) + }; +}; + +export const buildStreamedTokenPostingsDatabase = async ({ + Database, + indexPieces, + indexDir, + outPath +}) => { + const warnings = []; + const count = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: true, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + logger: { + warn: (message) => warnings.push(String(message || '')), + log: () => {}, + error: () => {} + } + }); + + return { count, warnings }; +}; + +export const buildTokenPostingsArtifactDatabase = async ({ + Database, + indexPieces, + indexDir, + outPath, + emitOutput = false +}) => buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null } +}); + +export const readTokenPostingTableTotals = ({ Database, outPath }) => { + const db = new Database(outPath); + try { + return { + vocabTotal: db.prepare('SELECT COUNT(*) AS total FROM token_vocab WHERE mode = ?').get('code')?.total || 0, + postingTotal: db.prepare('SELECT COUNT(*) AS total FROM token_postings WHERE mode = ?').get('code')?.total || 0, + lengthsTotal: db.prepare('SELECT COUNT(*) AS total FROM doc_lengths WHERE mode = ?').get('code')?.total || 0 + }; + } finally { + db.close(); + } +}; diff --git a/tests/storage/sqlite/helpers/zero-state-rebuild-fixture.js b/tests/storage/sqlite/helpers/zero-state-rebuild-fixture.js new file mode 100644 index 000000000..329019fba --- /dev/null +++ b/tests/storage/sqlite/helpers/zero-state-rebuild-fixture.js @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildSqliteIndex } from '../../../../tools/build/sqlite/runner.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +export const prepareZeroStateSqliteFixture = async ({ + label, + mode, + indexDirName, + dbName +}) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, label); + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + const buildRoot = path.join(tempRoot, 'build-root'); + const sourceIndexDir = path.join(buildRoot, indexDirName); + const sqliteDir = path.join(buildRoot, 'index-sqlite'); + const outputPath = path.join(sqliteDir, dbName); + const zeroStateManifestPath = path.join(sourceIndexDir, 'pieces', 'sqlite-zero-state.json'); + + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fs.mkdir(sourceIndexDir, { recursive: true }); + await fs.mkdir(sqliteDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, 'src', 'placeholder.js'), 'export const x = 1;\n', 'utf8'); + + applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + embeddings: { enabled: false } + } + } + }); + + await fs.writeFile(path.join(sourceIndexDir, 'chunk_meta.json'), '[]\n', 'utf8'); + + return { + tempRoot, + repoRoot, + cacheRoot, + buildRoot, + sourceIndexDir, + sqliteDir, + outputPath, + zeroStateManifestPath, + mode + }; +}; + +export const runZeroStateSqliteBuild = async ({ + fixture, + modeArg, + incremental = false +}) => { + const logs = []; + await buildSqliteIndex({ + root: fixture.repoRoot, + mode: fixture.mode, + incremental, + indexRoot: fixture.buildRoot, + out: fixture.outputPath, + [modeArg]: fixture.sourceIndexDir, + emitOutput: true, + logger: { + log: (message) => logs.push(String(message || '')), + warn: (message) => logs.push(String(message || '')), + error: (message) => logs.push(String(message || '')) + }, + exitOnError: false + }); + return logs; +}; + +export const assertZeroStateSkipped = async ({ + outputPath, + zeroStateManifestPath, + logs, + message +}) => { + assert.equal( + await fs.access(outputPath).then(() => true).catch(() => false), + false, + 'expected first-run empty sqlite build to skip creating db' + ); + assert.equal( + await fs.access(zeroStateManifestPath).then(() => true).catch(() => false), + true, + 'expected zero-state manifest for empty mode' + ); + assert.equal( + logs.some((line) => line.includes(message)), + true, + 'expected zero-state skip log for empty rebuild' + ); +}; + +export const assertSeededDbUnchangedAfterZeroState = async ({ + Database, + outputPath, + runAgain, + message, + unchangedMessage +}) => { + const seedDb = new Database(outputPath); + seedDb.exec('CREATE TABLE chunks (id INTEGER PRIMARY KEY, mode TEXT NOT NULL);'); + seedDb.close(); + + const before = await fs.stat(outputPath); + const logs = await runAgain(); + const after = await fs.stat(outputPath); + + assert.equal(after.mtimeMs, before.mtimeMs, unchangedMessage); + assert.equal( + logs.some((line) => line.includes(message)), + true, + 'expected repeat zero-state skip log' + ); +}; diff --git a/tests/storage/sqlite/incremental-memory-profile.test.js b/tests/storage/sqlite/incremental-memory-profile.test.js new file mode 100644 index 000000000..a5ebfcb46 --- /dev/null +++ b/tests/storage/sqlite/incremental-memory-profile.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { incrementalUpdateDatabase } from '../../../src/storage/sqlite/build/incremental-update.js'; + +import { + addChangedBundle, + setupIncrementalBundleDatabase +} from './helpers/incremental-bundle-db-fixture.js'; + +let Database = null; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const chunksPerFile = 4; +const { + bundleDir, + files, + manifest, + outPath +} = await setupIncrementalBundleDatabase({ + Database, + name: 'sqlite-incremental-memory-profile', + fileCount: 6, + chunksPerFile +}); + +const { updatedManifest } = await addChangedBundle({ + bundleDir, + chunksPerFile, + files, + manifest, + changedFileIndex: 2 +}); + +const stats = {}; +const updateResult = await incrementalUpdateDatabase({ + Database, + outPath, + mode: 'code', + incrementalData: { manifest: updatedManifest, bundleDir }, + modelConfig: { id: null }, + vectorConfig: { enabled: false }, + emitOutput: false, + validateMode: 'off', + stats +}); + +if (!updateResult.used) { + console.error(`Incremental update skipped: ${updateResult.reason || 'unknown reason'}`); + process.exit(1); +} +const totalChunks = files.length * chunksPerFile; +assert.equal(stats.existingChunkRows, chunksPerFile, 'expected only changed file chunks to be loaded'); +assert.ok(stats.existingChunkRows < totalChunks, 'expected subset load of existing chunk ids'); + +console.log('sqlite incremental memory profile test passed'); diff --git a/tests/storage/sqlite/incremental-no-change.test.js b/tests/storage/sqlite/incremental-no-change.test.js new file mode 100644 index 000000000..c31456c76 --- /dev/null +++ b/tests/storage/sqlite/incremental-no-change.test.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node +import { applyTestEnv } from '../../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; +import { + getIndexDir, + getRepoCacheRoot, + loadUserConfig, + resolveSqlitePaths +} from '../../../tools/shared/dict-utils.js'; +import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const tempRoot = resolveTestCachePath(root, 'sqlite-incremental-no-change'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +const stripMaxOldSpaceFlag = (options) => { + if (!options) return ''; + return options + .replace(/--max-old-space-size=\d+/g, '') + .replace(/--max-old-space-size\s+\d+/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +const nodeOptions = stripMaxOldSpaceFlag(process.env.NODE_OPTIONS || ''); + +await rmDirRecursive(tempRoot, { retries: 8, delayMs: 150 }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off', + PAIROFCLEATS_MAX_OLD_SPACE_MB: '4096' + } +}); +if (nodeOptions) { + env.NODE_OPTIONS = nodeOptions; +} else { + delete env.NODE_OPTIONS; +} +function run(args, label) { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +} + +run([ + path.join(root, 'build_index.js'), + '--incremental', + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage2', + '--no-sqlite', + '--mode', + 'code', + '--repo', + repoRoot +], 'build code index'); +const initialLogs = []; +await runSqliteBuild(repoRoot, { + mode: 'code', + logger: { + log: (message) => initialLogs.push(message), + warn: (message) => initialLogs.push(message), + error: (message) => initialLogs.push(message) + } +}); +getCombinedOutput({ stdout: initialLogs.join('\n'), stderr: '' }); + +const userConfig = loadUserConfig(repoRoot); +let sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); + +let Database; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error('better-sqlite3 is required for sqlite incremental no-change test.'); + process.exit(1); +} + +const dbBefore = new Database(sqlitePaths.codePath, { readonly: true }); +const beforeCounts = { + chunks: dbBefore.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code').total, + files: dbBefore.prepare('SELECT COUNT(*) AS total FROM file_manifest WHERE mode = ?').get('code').total, + hash: (dbBefore.prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', 'src/index.js') || {}).hash || null +}; +dbBefore.close(); +const codeIndexDir = getIndexDir(repoRoot, 'code', userConfig); +const statePath = path.join(codeIndexDir, 'index_state.json'); +const stateBefore = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); + +const noChangeLogs = []; +await runSqliteBuild(repoRoot, { + mode: 'code', + incremental: true, + logger: { + log: (message) => noChangeLogs.push(message), + warn: (message) => noChangeLogs.push(message), + error: (message) => noChangeLogs.push(message) + } +}); +const noChangeOutput = getCombinedOutput({ stdout: noChangeLogs.join('\n'), stderr: '' }); +if (!noChangeOutput.toLowerCase().includes('incremental update applied')) { + console.error('Expected incremental sqlite update output for no-change run.'); + process.exit(1); +} + +sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); +const dbAfter = new Database(sqlitePaths.codePath, { readonly: true }); +const afterCounts = { + chunks: dbAfter.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code').total, + files: dbAfter.prepare('SELECT COUNT(*) AS total FROM file_manifest WHERE mode = ?').get('code').total, + hash: (dbAfter.prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', 'src/index.js') || {}).hash || null +}; +dbAfter.close(); +const stateAfter = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); +if (stateBefore?.sqlite) { + assert.equal(stateAfter.sqlite?.ready, stateBefore.sqlite.ready, 'expected sqlite ready to remain stable'); + assert.equal(stateAfter.sqlite?.pending, stateBefore.sqlite.pending, 'expected sqlite pending to remain stable'); +} + +assert.equal(afterCounts.chunks, beforeCounts.chunks, 'expected chunk counts to remain stable'); +assert.equal(afterCounts.files, beforeCounts.files, 'expected file manifest counts to remain stable'); +assert.equal(afterCounts.hash, beforeCounts.hash, 'expected file manifest hash to remain stable'); + +const triageFixturePath = path.join(root, 'tests', 'fixtures', 'triage', 'generic.json'); +run([ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', + 'generic', + '--in', + triageFixturePath, + '--repo', + repoRoot, + '--meta', + 'service=api', + '--meta', + 'env=prod' +], 'ingest generic records'); +run([ + path.join(root, 'build_index.js'), + '--incremental', + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + 'stage2', + '--no-sqlite', + '--mode', + 'records', + '--repo', + repoRoot +], 'build records index'); + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const recordsManifestPath = path.join(repoCacheRoot, 'incremental', 'records', 'manifest.json'); +const recordsManifest = JSON.parse(await fsPromises.readFile(recordsManifestPath, 'utf8')); +assert.equal( + recordsManifest.bundleRecordsIncremental, + true, + 'expected records incremental manifest capability bit' +); +assert.ok( + Object.keys(recordsManifest.files || {}).length > 0, + 'expected non-empty records incremental manifest' +); + +const recordsInitialLogs = []; +await runSqliteBuild(repoRoot, { + mode: 'records', + incremental: true, + logger: { + log: (message) => recordsInitialLogs.push(message), + warn: (message) => recordsInitialLogs.push(message), + error: (message) => recordsInitialLogs.push(message) + } +}); +const recordsInitialOutput = getCombinedOutput({ stdout: recordsInitialLogs.join('\n'), stderr: '' }); +const recordsInitialOutputLower = recordsInitialOutput.toLowerCase(); +if (!recordsInitialOutput.includes('Using incremental bundles for records')) { + console.error('Expected first records sqlite build to use incremental bundles.'); + process.exit(1); +} +if ( + recordsInitialOutputLower.includes('incremental bundles skipped for records') + || recordsInitialOutputLower.includes('using artifacts') +) { + console.error('Did not expect records sqlite incremental bundle fallback on supported manifest.'); + process.exit(1); +} + +sqlitePaths = resolveSqlitePaths(repoRoot, userConfig, { mode: 'records' }); +const recordsDbBefore = new Database(sqlitePaths.recordsPath, { readonly: true }); +const recordsBefore = recordsDbBefore + .prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?') + .get('records').total; +recordsDbBefore.close(); +assert.ok(recordsBefore > 0, 'expected records sqlite build to contain records chunks'); + +const recordsNoChangeLogs = []; +await runSqliteBuild(repoRoot, { + mode: 'records', + incremental: true, + logger: { + log: (message) => recordsNoChangeLogs.push(message), + warn: (message) => recordsNoChangeLogs.push(message), + error: (message) => recordsNoChangeLogs.push(message) + } +}); +const recordsNoChangeOutput = getCombinedOutput({ stdout: recordsNoChangeLogs.join('\n'), stderr: '' }); +const recordsNoChangeOutputLower = recordsNoChangeOutput.toLowerCase(); +if (!recordsNoChangeOutputLower.includes('incremental update applied')) { + console.error('Expected records no-change sqlite build to use incremental update.'); + process.exit(1); +} +if ( + recordsNoChangeOutputLower.includes('incremental bundles skipped for records') + || recordsNoChangeOutputLower.includes('using artifacts') +) { + console.error('Did not expect records no-change sqlite build fallback on supported manifest.'); + process.exit(1); +} +const recordsDbAfter = new Database(sqlitePaths.recordsPath, { readonly: true }); +const recordsAfter = recordsDbAfter + .prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?') + .get('records').total; +recordsDbAfter.close(); +assert.equal(recordsAfter, recordsBefore, 'expected records chunk counts to remain stable'); + +console.log('sqlite incremental no-change test passed'); + + diff --git a/tests/storage/sqlite/incremental-transaction-boundary.test.js b/tests/storage/sqlite/incremental-transaction-boundary.test.js new file mode 100644 index 000000000..cd5a5164f --- /dev/null +++ b/tests/storage/sqlite/incremental-transaction-boundary.test.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import { incrementalUpdateDatabase } from '../../../src/storage/sqlite/build/incremental-update.js'; +import { resolveSqliteBatchSize, resolveSqliteIngestPlan } from '../../../src/storage/sqlite/utils.js'; + +import { + addChangedBundle, + setupIncrementalBundleDatabase +} from './helpers/incremental-bundle-db-fixture.js'; + +let Database = null; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const chunksPerFile = 3; +const { + bundleDir, + files, + manifest, + outPath +} = await setupIncrementalBundleDatabase({ + Database, + name: 'sqlite-incremental-transaction-boundary', + fileCount: 4, + chunksPerFile +}); + +const { updatedManifest } = await addChangedBundle({ + bundleDir, + chunksPerFile, + files, + manifest, + changedFileIndex: 1 +}); + +const probeSqliteRuntime = (dbPath) => { + const runtime = { + pageSize: 4096, + journalMode: null, + walEnabled: false, + walBytes: 0, + dbBytes: 0 + }; + try { + runtime.dbBytes = Number(fsSync.statSync(dbPath).size) || 0; + } catch {} + try { + runtime.walBytes = Number(fsSync.statSync(`${dbPath}-wal`).size) || 0; + } catch {} + let db = null; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + const pageSize = Number(db.pragma('page_size', { simple: true })); + if (Number.isFinite(pageSize) && pageSize > 0) { + runtime.pageSize = Math.max(512, Math.floor(pageSize)); + } + const journalModeRaw = db.pragma('journal_mode', { simple: true }); + runtime.journalMode = typeof journalModeRaw === 'string' + ? journalModeRaw.trim().toLowerCase() + : null; + runtime.walEnabled = runtime.journalMode === 'wal' || runtime.walBytes > 0; + } catch { + runtime.walEnabled = runtime.walBytes > 0; + } finally { + try { db?.close(); } catch {} + } + return runtime; +}; + +const runtime = probeSqliteRuntime(outPath); +const adaptiveBatchConfig = { + requested: null, + pageSize: runtime.pageSize, + journalMode: runtime.journalMode, + walEnabled: true, + walBytes: Math.max(runtime.walBytes, 32 * 1024 * 1024), + rowCount: files.length * chunksPerFile, + fileCount: files.length, + inputBytes: runtime.dbBytes, + repoBytes: runtime.dbBytes +}; +const adaptivePlan = resolveSqliteIngestPlan({ batchSize: adaptiveBatchConfig }); +assert.equal( + adaptivePlan.batchSize, + resolveSqliteBatchSize({ batchSize: adaptiveBatchConfig }), + 'expected adaptive plan batch size to match resolveSqliteBatchSize' +); +const smallRepoPlan = resolveSqliteIngestPlan({ + rowCount: chunksPerFile, + fileCount: 1, + pageSize: runtime.pageSize, + walEnabled: false, + walBytes: 0 +}); +assert.ok( + adaptivePlan.transactionRows <= smallRepoPlan.transactionRows, + 'expected larger repo hints to reduce transaction row boundaries' +); +assert.ok(adaptivePlan.filesPerTransaction >= 1, 'expected adaptive filesPerTransaction to be set'); + +const stats = {}; +const updateResult = await incrementalUpdateDatabase({ + Database, + outPath, + mode: 'code', + incrementalData: { manifest: updatedManifest, bundleDir }, + modelConfig: { id: null }, + vectorConfig: { enabled: false }, + emitOutput: false, + validateMode: 'off', + inputBytes: runtime.dbBytes, + batchSize: adaptiveBatchConfig, + stats +}); + +if (!updateResult.used) { + console.error(`Incremental update skipped: ${updateResult.reason || 'unknown reason'}`); + process.exit(1); +} +assert.equal(stats.batchSize, adaptivePlan.batchSize, 'expected adaptive batch size to flow into incremental update stats'); +assert.ok(stats.transactionPhases?.deletes, 'expected delete transaction phase to run'); +assert.ok(stats.transactionPhases?.inserts, 'expected insert transaction phase to run'); +assert.equal( + stats.runtimeTelemetry?.plan?.source, + 'incremental', + 'expected incremental plan telemetry to be recorded' +); +assert.equal( + stats.runtimeTelemetry?.plan?.walPressure, + adaptivePlan.walPressure, + 'expected incremental telemetry to preserve wal pressure' +); +assert.ok( + Array.isArray(stats.runtimeTelemetry?.walSnapshots) && stats.runtimeTelemetry.walSnapshots.length >= 1, + 'expected incremental telemetry to record WAL snapshots' +); +assert.ok( + Array.isArray(stats.runtimeTelemetry?.checkpoints) && stats.runtimeTelemetry.checkpoints.length >= 1, + 'expected incremental telemetry to record checkpoint samples' +); + +console.log('sqlite incremental transaction boundary test passed'); diff --git a/tests/storage/sqlite/incremental/ann-existing-table.test.js b/tests/storage/sqlite/incremental/ann-existing-table.test.js index 1c431dc8a..66db6f5b8 100644 --- a/tests/storage/sqlite/incremental/ann-existing-table.test.js +++ b/tests/storage/sqlite/incremental/ann-existing-table.test.js @@ -58,14 +58,14 @@ await writeBundleFile({ const manifest = { files: { 'sample.txt': { - bundle: bundleName, + bundles: [bundleName], mtimeMs: 123, size: 5, hash: 'abc' }, - 'keep-1.txt': { bundle: bundleName, mtimeMs: 120, size: 1, hash: 'keep-1' }, - 'keep-2.txt': { bundle: bundleName, mtimeMs: 120, size: 1, hash: 'keep-2' }, - 'keep-3.txt': { bundle: bundleName, mtimeMs: 120, size: 1, hash: 'keep-3' } + 'keep-1.txt': { bundles: [bundleName], mtimeMs: 120, size: 1, hash: 'keep-1' }, + 'keep-2.txt': { bundles: [bundleName], mtimeMs: 120, size: 1, hash: 'keep-2' }, + 'keep-3.txt': { bundles: [bundleName], mtimeMs: 120, size: 1, hash: 'keep-3' } } }; diff --git a/tests/storage/sqlite/incremental/bundle-count-mismatch-fallback.test.js b/tests/storage/sqlite/incremental/bundle-count-mismatch-fallback.test.js new file mode 100644 index 000000000..e614f2614 --- /dev/null +++ b/tests/storage/sqlite/incremental/bundle-count-mismatch-fallback.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; + +import { + findFirstBundleEntry, + readFirstBundleShard, + runIncrementalSqliteAndGetOutput, + setupBundlePartialFallbackFixture, + writeBundleChunks +} from './bundle-partial-fallback-helper.js'; + +const { repoRoot, repoCacheRoot, manifest } = await setupBundlePartialFallbackFixture({ + name: 'bundle-count-mismatch-fallback' +}); + +const { targetEntry } = findFirstBundleEntry(manifest); +const { bundlePath, readResult } = await readFirstBundleShard(repoCacheRoot, targetEntry); +assert.ok(Array.isArray(readResult.bundle?.chunks) && readResult.bundle.chunks.length > 0, 'expected bundle chunks before mutation'); + +const mutatedChunks = readResult.bundle.chunks.slice(1); +await writeBundleChunks({ + bundlePath, + targetEntry, + readResult, + chunks: mutatedChunks +}); + +const output = await runIncrementalSqliteAndGetOutput(repoRoot); +assert.match(output, /incremental bundle build failed for code: bundle row count mismatch \(\d+ !== \d+\); using artifacts\./i); + +console.log('sqlite incremental bundle count mismatch fallback test passed'); diff --git a/tests/storage/sqlite/incremental/bundle-coverage-metadata-fallback.test.js b/tests/storage/sqlite/incremental/bundle-coverage-metadata-fallback.test.js new file mode 100644 index 000000000..34df501f6 --- /dev/null +++ b/tests/storage/sqlite/incremental/bundle-coverage-metadata-fallback.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; + +import { + runIncrementalSqliteAndGetOutput, + setupBundlePartialFallbackFixture, + writeManifest +} from './bundle-partial-fallback-helper.js'; + +const { repoRoot, manifestPath, manifest } = await setupBundlePartialFallbackFixture({ + name: 'bundle-coverage-metadata-fallback' +}); + +assert.equal(manifest.bundleEmbeddings, true, 'expected stage3 manifest to advertise bundle embeddings'); +assert.equal(manifest.bundleEmbeddingCoverageComplete, true, 'expected stage3 manifest to start complete'); + +manifest.bundleEmbeddings = true; +manifest.bundleEmbeddingCoverageComplete = true; +manifest.bundleEmbeddingCoverageEligible = 1; +manifest.bundleEmbeddingCoverageCovered = 1; +manifest.bundleEmbeddingCoverageMissingFiles = 0; +manifest.bundleEmbeddingCoverageMissingChunks = 1; +writeManifest(manifestPath, manifest); + +const output = await runIncrementalSqliteAndGetOutput(repoRoot); +assert.match( + output, + /incremental bundles skipped for code: bundle embedding coverage inconsistent .*missingChunks=1.*; using artifacts\./i +); +assert.match(output, /bundle manifest code: .*bundleEmbeddingCoverageMissingChunks=1/i); + +console.log('sqlite incremental bundle coverage metadata fallback test passed'); diff --git a/tests/storage/sqlite/incremental/bundle-embeddings-stage3-refresh.test.js b/tests/storage/sqlite/incremental/bundle-embeddings-stage3-refresh.test.js index 4e387a8c0..5df8820e7 100644 --- a/tests/storage/sqlite/incremental/bundle-embeddings-stage3-refresh.test.js +++ b/tests/storage/sqlite/incremental/bundle-embeddings-stage3-refresh.test.js @@ -42,18 +42,6 @@ if (manifestStage2.bundleEmbeddings !== false) { console.error('Expected stage2 manifest to mark bundleEmbeddings=false.'); process.exit(1); } -const firstManifestFile = Object.keys(manifestStage2.files || {})[0]; -if (!firstManifestFile) { - console.error('Expected stage2 manifest to contain at least one file entry.'); - process.exit(1); -} -manifestStage2.files['phantom/missing.js'] = { - ...manifestStage2.files[firstManifestFile], - // Keep bundle metadata intentionally valid while forcing a file key that - // will not appear in stage3 chunk mappings. - bundle: manifestStage2.files[firstManifestFile]?.bundle -}; -fs.writeFileSync(manifestPath, JSON.stringify(manifestStage2, null, 2)); run( [ @@ -78,6 +66,18 @@ if (manifestStage3.bundleEmbeddings !== true) { console.error('Expected stage3 manifest to mark bundleEmbeddings=true.'); process.exit(1); } +if (manifestStage3.bundleEmbeddingCoverageComplete !== true) { + console.error('Expected stage3 manifest to mark bundleEmbeddingCoverageComplete=true.'); + process.exit(1); +} +if ((manifestStage3.bundleEmbeddingCoverageMissingFiles || 0) !== 0) { + console.error('Expected stage3 manifest to report zero missing bundle embedding files.'); + process.exit(1); +} +if ((manifestStage3.bundleEmbeddingCoverageMissingChunks || 0) !== 0) { + console.error('Expected stage3 manifest to report zero missing bundle embedding chunks.'); + process.exit(1); +} if (manifestStage3.bundleEmbeddingStage !== 'stage3') { console.error('Expected stage3 manifest to mark bundleEmbeddingStage=stage3.'); process.exit(1); diff --git a/tests/storage/sqlite/incremental/bundle-partial-chunk-fallback.test.js b/tests/storage/sqlite/incremental/bundle-partial-chunk-fallback.test.js new file mode 100644 index 000000000..8d33604e6 --- /dev/null +++ b/tests/storage/sqlite/incremental/bundle-partial-chunk-fallback.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; + +import { + findFirstBundleEntry, + readFirstBundleShard, + runIncrementalSqliteAndGetOutput, + setupBundlePartialFallbackFixture, + writeBundleChunks, + writeLargeMultiChunkSource +} from './bundle-partial-fallback-helper.js'; + +const { repoRoot, repoCacheRoot, manifest } = await setupBundlePartialFallbackFixture({ + name: 'bundle-partial-chunk-fallback', + beforeBuild: ({ repoRoot }) => writeLargeMultiChunkSource(repoRoot) +}); + +const { targetEntry } = findFirstBundleEntry(manifest, ({ file }) => file === 'src/multi-chunk.js'); +const { bundlePath, readResult } = await readFirstBundleShard(repoCacheRoot, targetEntry); +assert.ok(Array.isArray(readResult.bundle?.chunks) && readResult.bundle.chunks.length > 1, 'expected multi-chunk bundle before mutation'); + +const mutatedChunks = readResult.bundle.chunks.map((chunk, index) => ( + index === 0 + ? { + ...chunk, + embedding: null, + embedding_u8: null + } + : chunk +)); +await writeBundleChunks({ + bundlePath, + targetEntry, + readResult, + chunks: mutatedChunks +}); + +const output = await runIncrementalSqliteAndGetOutput(repoRoot); +assert.match(output, /incremental bundle build failed for code: bundles missing embeddings; using artifacts\./i); +assert.match(output, /bundle embeddings code: .*partial 1.*missingChunks=1.*sample missing:/i); + +console.log('sqlite incremental partial chunk fallback test passed'); diff --git a/tests/storage/sqlite/incremental/bundle-partial-embeddings-fallback.test.js b/tests/storage/sqlite/incremental/bundle-partial-embeddings-fallback.test.js new file mode 100644 index 000000000..8d2fefbbb --- /dev/null +++ b/tests/storage/sqlite/incremental/bundle-partial-embeddings-fallback.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; + +import { + findFirstBundleEntry, + readFirstBundleShard, + readManifest, + runIncrementalSqliteAndGetOutput, + setupBundlePartialFallbackFixture, + writeBundleChunks, + writeManifest +} from './bundle-partial-fallback-helper.js'; + +const { repoRoot, repoCacheRoot, manifestPath, manifest } = await setupBundlePartialFallbackFixture({ + name: 'bundle-partial-embeddings-fallback' +}); + +const { targetEntry } = findFirstBundleEntry(manifest); +const { bundlePath, readResult } = await readFirstBundleShard(repoCacheRoot, targetEntry); + +const mutatedChunks = (readResult.bundle.chunks || []).map((chunk) => ({ + ...chunk, + embedding: null, + embedding_u8: null +})); +await writeBundleChunks({ + bundlePath, + targetEntry, + readResult, + chunks: mutatedChunks +}); + +manifest.bundleEmbeddings = false; +manifest.bundleEmbeddingCoverageComplete = false; +manifest.bundleEmbeddingCoverageEligible = 1; +manifest.bundleEmbeddingCoverageCovered = 0; +manifest.bundleEmbeddingCoverageMissingFiles = 1; +manifest.bundleEmbeddingCoverageMissingChunks = mutatedChunks.length; +writeManifest(manifestPath, manifest); + +const output = await runIncrementalSqliteAndGetOutput(repoRoot); +assert.match(output, /incremental bundles skipped for code: bundles omit embeddings .*coverage incomplete .*; using artifacts\./i); +assert.match(output, /bundle manifest code: .*bundleEmbeddingCoverageMissingChunks=\d+/i); + +const manifestAfter = readManifest(manifestPath); +assert.equal(manifestAfter.bundleEmbeddings, false, 'expected partial coverage to fail closed in manifest'); +assert.equal(manifestAfter.bundleEmbeddingCoverageComplete, false, 'expected manifest to record incomplete embedding coverage'); +assert.equal(manifestAfter.bundleEmbeddingCoverageMissingFiles, 1, 'expected manifest to record missing file coverage'); +assert.equal( + manifestAfter.bundleEmbeddingCoverageMissingChunks, + mutatedChunks.length, + 'expected manifest to record missing chunk coverage' +); + +console.log('sqlite incremental partial bundle embeddings fallback test passed'); diff --git a/tests/storage/sqlite/incremental/bundle-partial-fallback-helper.js b/tests/storage/sqlite/incremental/bundle-partial-fallback-helper.js new file mode 100644 index 000000000..f26f49240 --- /dev/null +++ b/tests/storage/sqlite/incremental/bundle-partial-fallback-helper.js @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { getCombinedOutput } from '../../../helpers/stdio.js'; +import { getRepoCacheRoot } from '../../../../tools/shared/dict-utils.js'; +import { setupIncrementalRepo } from '../../../helpers/sqlite-incremental.js'; +import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { + readBundleFile, + writeBundleFile +} from '../../../../src/shared/bundle-io.js'; + +function createBundleFallbackTestConfig() { + return { + indexing: { + scm: { provider: 'none' }, + treeSitter: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false + } + }; +} + +function runIncrementalStage({ root, repoRoot, env, run, stage }) { + run( + [ + path.join(root, 'build_index.js'), + '--incremental', + '--stub-embeddings', + '--scm-provider', + 'none', + '--stage', + stage, + '--no-sqlite', + '--mode', + 'code', + '--repo', + repoRoot + ], + `${stage} build`, + { cwd: repoRoot, env, stdio: 'inherit' } + ); +} + +export function writeLargeMultiChunkSource(repoRoot) { + const largeSourcePath = path.join(repoRoot, 'src', 'multi-chunk.js'); + fs.mkdirSync(path.dirname(largeSourcePath), { recursive: true }); + fs.writeFileSync( + largeSourcePath, + Array.from({ length: 256 }, (_, index) => `export function value${index}() { return ${index}; }`).join('\n'), + 'utf8' + ); +} + +export async function setupBundlePartialFallbackFixture({ name, beforeBuild } = {}) { + const { root, repoRoot, env, userConfig, run } = await setupIncrementalRepo({ + name, + testConfig: createBundleFallbackTestConfig() + }); + + await beforeBuild?.({ repoRoot }); + + runIncrementalStage({ root, repoRoot, env, run, stage: 'stage2' }); + runIncrementalStage({ root, repoRoot, env, run, stage: 'stage3' }); + + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); + assert.equal(fs.existsSync(manifestPath), true, 'expected incremental manifest after stage3 build'); + + const manifest = readManifest(manifestPath); + assert.equal(manifest.bundleEmbeddings, true, 'expected stage3 manifest to advertise bundle embeddings'); + assert.equal(manifest.bundleEmbeddingCoverageComplete, true, 'expected stage3 manifest coverage to start complete'); + + return { repoRoot, repoCacheRoot, manifestPath, manifest }; +} + +export function readManifest(manifestPath) { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +} + +export function writeManifest(manifestPath, manifest) { + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); +} + +export function findFirstBundleEntry(manifest, predicate = () => true) { + const [targetFile, targetEntry] = Object.entries(manifest.files || {}).find(([file, entry]) => ( + predicate({ file, entry }) + && Array.isArray(entry?.bundles) + && entry.bundles.length + )) + || []; + assert.ok(targetFile && targetEntry, 'expected a manifest file entry with bundle shards'); + return { targetFile, targetEntry }; +} + +export async function readFirstBundleShard(repoCacheRoot, targetEntry) { + const bundleName = targetEntry.bundles[0]; + const bundlePath = path.join(repoCacheRoot, 'incremental', 'code', 'files', bundleName); + const readResult = await readBundleFile(bundlePath, { format: targetEntry.bundleFormat || null }); + assert.equal(readResult.ok, true, `expected readable bundle before mutation: ${readResult.reason || 'unknown error'}`); + return { bundleName, bundlePath, readResult }; +} + +export async function writeBundleChunks({ bundlePath, targetEntry, readResult, chunks }) { + await writeBundleFile({ + bundlePath, + format: targetEntry.bundleFormat || null, + bundle: { + ...readResult.bundle, + chunks + } + }); +} + +export async function runIncrementalSqliteAndGetOutput(repoRoot) { + const sqliteLogs = []; + await runSqliteBuild(repoRoot, { + mode: 'code', + incremental: true, + logger: { + log: (message) => sqliteLogs.push(message), + warn: (message) => sqliteLogs.push(message), + error: (message) => sqliteLogs.push(message) + } + }); + + return getCombinedOutput({ stdout: sqliteLogs.join('\n'), stderr: '' }); +} diff --git a/tests/storage/sqlite/incremental/doc-id-reuse.test.js b/tests/storage/sqlite/incremental/doc-id-reuse.test.js index eae36c064..e3e13e32a 100644 --- a/tests/storage/sqlite/incremental/doc-id-reuse.test.js +++ b/tests/storage/sqlite/incremental/doc-id-reuse.test.js @@ -1,74 +1,12 @@ #!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { setupIncrementalRepo, ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { runDocIdReuseScenario } from './update-contract-cases.js'; -const { root, repoRoot, env, userConfig, run } = await setupIncrementalRepo({ name: 'doc-id-reuse' }); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -let Database; try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite incremental tests.'); - process.exit(1); -} - -const sqlitePaths = ensureSqlitePaths(repoRoot, userConfig); -const initialIndexRoot = path.dirname(path.dirname(sqlitePaths.codePath)); -const dbBefore = new Database(sqlitePaths.codePath, { readonly: true }); -const deletedIds = dbBefore - .prepare('SELECT id FROM chunks WHERE mode = ? AND file = ? ORDER BY id') - .all('code', 'src/util.js') - .map((row) => row.id); -dbBefore.close(); - -if (!deletedIds.length) { - console.error('Expected at least one doc id for src/util.js.'); - process.exit(1); -} - -await fsPromises.rm(path.join(repoRoot, 'src', 'util.js')); -await fsPromises.writeFile( - path.join(repoRoot, 'src', 'new-file.js'), - 'export const meaning = 42;\n' -); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index (incremental)', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot, { - mode: 'code', - incremental: true, - indexRoot: initialIndexRoot -}); - -const dbAfter = new Database(sqlitePaths.codePath, { readonly: true }); -const newIds = dbAfter - .prepare('SELECT id FROM chunks WHERE mode = ? AND file = ? ORDER BY id') - .all('code', 'src/new-file.js') - .map((row) => row.id); -dbAfter.close(); - -if (!newIds.length) { - console.error('Expected doc ids for src/new-file.js after incremental update.'); - process.exit(1); -} - -const deletedSet = new Set(deletedIds); -const reused = newIds.every((id) => deletedSet.has(id)); -if (!reused) { - console.error(`Expected doc ids for new file to reuse deleted ids; got: ${newIds.join(', ')}`); + await runDocIdReuseScenario(); +} catch (error) { + console.error('sqlite incremental doc-id reuse failed'); + console.error(error?.stack || error?.message || String(error)); process.exit(1); } -console.log('SQLite incremental doc-id reuse ok.'); +console.log('sqlite incremental doc-id reuse test passed'); diff --git a/tests/storage/sqlite/incremental/file-manifest-updates.test.js b/tests/storage/sqlite/incremental/file-manifest-updates.test.js index 7cd9df686..05ef412c4 100644 --- a/tests/storage/sqlite/incremental/file-manifest-updates.test.js +++ b/tests/storage/sqlite/incremental/file-manifest-updates.test.js @@ -1,67 +1,12 @@ #!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { setupIncrementalRepo, ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { runFileManifestScenario } from './update-contract-cases.js'; -const { root, repoRoot, env, userConfig, run } = await setupIncrementalRepo({ name: 'file-manifest-updates' }); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -let Database; try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite incremental tests.'); - process.exit(1); -} - -const sqlitePaths = ensureSqlitePaths(repoRoot, userConfig); -const dbBefore = new Database(sqlitePaths.codePath, { readonly: true }); -const beforeRow = dbBefore - .prepare('SELECT hash, chunk_count FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', 'src/index.js'); -dbBefore.close(); -if (!beforeRow) { - console.error('Missing file_manifest entry for src/index.js'); - process.exit(1); -} - -const targetFile = path.join(repoRoot, 'src', 'index.js'); -const original = await fsPromises.readFile(targetFile, 'utf8'); -const updated = `${original}\nexport function farewell(name) {\n return \`bye \${name}\`;\n}\n`; -await fsPromises.writeFile(targetFile, updated); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--scm-provider', 'none', '--repo', repoRoot], - 'build index (incremental)', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot, { incremental: true }); - -const sqlitePathsAfter = ensureSqlitePaths(repoRoot, userConfig); -const dbAfter = new Database(sqlitePathsAfter.codePath, { readonly: true }); -const afterRow = dbAfter - .prepare('SELECT hash, chunk_count FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', 'src/index.js'); -dbAfter.close(); - -if (!afterRow) { - console.error('Missing file_manifest entry after incremental update.'); - process.exit(1); -} -if (beforeRow.hash && afterRow.hash && beforeRow.hash === afterRow.hash) { - console.error('file_manifest hash did not update after incremental change.'); - process.exit(1); -} -if (!afterRow.chunk_count) { - console.error('file_manifest chunk_count missing after incremental update.'); + await runFileManifestScenario(); +} catch (error) { + console.error('sqlite incremental file-manifest updates failed'); + console.error(error?.stack || error?.message || String(error)); process.exit(1); } -console.log('SQLite incremental file manifest updates ok.'); +console.log('sqlite incremental file-manifest updates test passed'); diff --git a/tests/storage/sqlite/incremental/manifest-hash-fill.test.js b/tests/storage/sqlite/incremental/manifest-hash-fill.test.js index 80e60bf6b..54bb4172b 100644 --- a/tests/storage/sqlite/incremental/manifest-hash-fill.test.js +++ b/tests/storage/sqlite/incremental/manifest-hash-fill.test.js @@ -1,57 +1,12 @@ #!/usr/bin/env node -import path from 'node:path'; -import { setupIncrementalRepo, ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { runManifestHashFillScenario } from './update-contract-cases.js'; -const { root, repoRoot, env, userConfig, run } = await setupIncrementalRepo({ name: 'manifest-hash-fill' }); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -let Database; try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite incremental tests.'); - process.exit(1); -} - -const sqlitePaths = ensureSqlitePaths(repoRoot, userConfig); -const db = new Database(sqlitePaths.codePath); -const targetFile = 'src/index.js'; -const before = db - .prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', targetFile); -if (!before) { - console.error('Missing file_manifest entry for src/index.js.'); - db.close(); - process.exit(1); -} -db.prepare('UPDATE file_manifest SET hash = NULL WHERE mode = ? AND file = ?') - .run('code', targetFile); -db.close(); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index (incremental)', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot, { incremental: true }); - -const sqlitePathsAfter = ensureSqlitePaths(repoRoot, userConfig); -const dbAfter = new Database(sqlitePathsAfter.codePath, { readonly: true }); -const after = dbAfter - .prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', targetFile); -dbAfter.close(); - -if (!after?.hash) { - console.error('Expected file_manifest hash to be restored after incremental update.'); + await runManifestHashFillScenario(); +} catch (error) { + console.error('sqlite incremental manifest hash fill failed'); + console.error(error?.stack || error?.message || String(error)); process.exit(1); } -console.log('SQLite incremental manifest hash fill ok.'); +console.log('sqlite incremental manifest hash fill test passed'); diff --git a/tests/storage/sqlite/incremental/manifest-normalization.test.js b/tests/storage/sqlite/incremental/manifest-normalization.test.js index ce2dccbe7..704e4a48a 100644 --- a/tests/storage/sqlite/incremental/manifest-normalization.test.js +++ b/tests/storage/sqlite/incremental/manifest-normalization.test.js @@ -1,52 +1,12 @@ #!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../../../tools/shared/dict-utils.js'; -import { setupIncrementalRepo } from '../../../helpers/sqlite-incremental.js'; -import { getCombinedOutput } from '../../../helpers/stdio.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { runManifestNormalizationScenario } from './update-contract-cases.js'; -const { root, repoRoot, env, userConfig, run, runCapture } = await setupIncrementalRepo({ - name: 'manifest-normalization' -}); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); -let manifest = null; try { - manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); -} catch { - console.error('Failed to load incremental manifest for normalization test.'); - process.exit(1); -} -if (!manifest?.files?.['src/index.js']) { - console.error('Expected manifest entry for src/index.js.'); - process.exit(1); -} -manifest.files['src\\index.js'] = manifest.files['src/index.js']; -delete manifest.files['src/index.js']; -await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); - -const normalizedLogs = []; -await runSqliteBuild(repoRoot, { - incremental: true, - logger: { - log: (message) => normalizedLogs.push(message), - warn: (message) => normalizedLogs.push(message), - error: (message) => normalizedLogs.push(message) - } -}); -const normalizedOutput = getCombinedOutput({ stdout: normalizedLogs.join('\n'), stderr: '' }); -if (!normalizedOutput.includes('[sqlite] indexes updated.') && !normalizedOutput.includes('[sqlite] index updated.')) { - console.error('Expected incremental sqlite update with normalized manifest.'); + await runManifestNormalizationScenario(); +} catch (error) { + console.error('sqlite incremental manifest normalization failed'); + console.error(error?.stack || error?.message || String(error)); process.exit(1); } -console.log('SQLite incremental manifest normalization ok.'); +console.log('sqlite incremental manifest normalization test passed'); diff --git a/tests/storage/sqlite/incremental/search-after-update.test.js b/tests/storage/sqlite/incremental/search-after-update.test.js deleted file mode 100644 index 80ff8d8d4..000000000 --- a/tests/storage/sqlite/incremental/search-after-update.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { setupIncrementalRepo } from '../../../helpers/sqlite-incremental.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; - -const { root, repoRoot, env, run } = await setupIncrementalRepo({ name: 'search-after-update' }); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -const targetFile = path.join(repoRoot, 'src', 'index.js'); -const original = await fsPromises.readFile(targetFile, 'utf8'); -const updated = `${original}\nexport function farewell(name) {\n return \`bye \${name}\`;\n}\n`; -await fsPromises.writeFile(targetFile, updated); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index (incremental)', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot, { incremental: true }); - -const searchResult = spawnSync( - process.execPath, - [path.join(root, 'search.js'), 'farewell', '--json', '--backend', 'sqlite-fts', '--repo', repoRoot], - { cwd: repoRoot, env, encoding: 'utf8' } -); -if (searchResult.status !== 0) { - console.error('Search failed after incremental update.'); - process.exit(searchResult.status ?? 1); -} -const payload = JSON.parse(searchResult.stdout || '{}'); -if (!payload.code?.length && !payload.prose?.length) { - console.error('Incremental sqlite update produced no search results.'); - process.exit(1); -} - -console.log('SQLite incremental search after update ok.'); diff --git a/tests/storage/sqlite/incremental/update-contract-cases.js b/tests/storage/sqlite/incremental/update-contract-cases.js new file mode 100644 index 000000000..5030ae9c0 --- /dev/null +++ b/tests/storage/sqlite/incremental/update-contract-cases.js @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { getRepoCacheRoot } from '../../../../tools/shared/dict-utils.js'; +import { + appendFixtureExport, + createIncrementalScenario, + runRepoSearchJson +} from '../helpers/incremental-scenarios.js'; +import { ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; + +const getPreparedCodeScenario = async (scenario, name) => { + if (scenario) return scenario; + const preparedScenario = await createIncrementalScenario({ name, mode: 'code' }); + preparedScenario.runBuildIndex(); + await preparedScenario.runBuildSqlite(); + return preparedScenario; +}; + +export const runDocIdReuseScenario = async () => { + const scenario = await createIncrementalScenario({ name: 'doc-id-reuse', mode: 'code' }); + scenario.runBuildIndex(); + await scenario.runBuildSqlite(); + + const dbBefore = await scenario.openCodeDb(); + const deletedIds = dbBefore + .prepare('SELECT id FROM chunks WHERE mode = ? AND file = ? ORDER BY id') + .all('code', 'src/util.js') + .map((row) => row.id); + const beforeStats = dbBefore + .prepare('SELECT COUNT(*) AS total, MAX(id) AS maxId FROM chunks WHERE mode = ?') + .get('code'); + dbBefore.close(); + + assert.ok(deletedIds.length > 0, 'expected at least one doc id for src/util.js'); + + await fsPromises.rm(path.join(scenario.repoRoot, 'src', 'util.js')); + await fsPromises.writeFile( + path.join(scenario.repoRoot, 'src', 'new-file.js'), + 'export const meaning = 42;\n' + ); + + scenario.runBuildIndex({ incremental: true }); + await scenario.runBuildSqlite({ incremental: true }); + + const dbAfter = await scenario.openCodeDb(); + const newIds = dbAfter + .prepare('SELECT id FROM chunks WHERE mode = ? AND (file = ? OR file = ?) ORDER BY id') + .all('code', 'src/new-file.js', 'src\\new-file.js') + .map((row) => row.id); + const removedRows = dbAfter + .prepare('SELECT COUNT(*) AS count FROM chunks WHERE mode = ? AND file = ?') + .get('code', 'src/util.js'); + const afterStats = dbAfter + .prepare('SELECT COUNT(*) AS total, MAX(id) AS maxId FROM chunks WHERE mode = ?') + .get('code'); + dbAfter.close(); + + assert.ok(newIds.length > 0, 'expected doc ids for src/new-file.js after incremental update'); + assert.equal(Number(removedRows?.count || 0), 0, 'expected src/util.js rows to be removed'); + assert.ok(Number.isFinite(Number(beforeStats?.maxId)), 'expected valid max doc id before update'); + assert.ok(Number.isFinite(Number(afterStats?.maxId)), 'expected valid max doc id after update'); + assert.ok( + Number(afterStats.maxId) <= Number(beforeStats.maxId), + `expected doc id reuse after incremental update; before=${beforeStats.maxId}, after=${afterStats.maxId}` + ); +}; + +export const runFileManifestScenario = async () => { + const scenario = await createIncrementalScenario({ + name: 'file-manifest-updates', + mode: 'code', + scmProvider: 'none' + }); + scenario.runBuildIndex(); + await scenario.runBuildSqlite(); + + const dbBefore = await scenario.openCodeDb(); + const beforeRow = dbBefore + .prepare('SELECT hash, chunk_count FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', 'src/index.js'); + dbBefore.close(); + + assert.ok(beforeRow, 'missing file_manifest entry for src/index.js'); + + await appendFixtureExport(scenario.repoRoot); + + scenario.runBuildIndex({ incremental: true }); + await scenario.runBuildSqlite({ incremental: true }); + + const dbAfter = await scenario.openCodeDb(); + const afterRow = dbAfter + .prepare('SELECT hash, chunk_count FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', 'src/index.js'); + dbAfter.close(); + + assert.ok(afterRow, 'missing file_manifest entry after incremental update'); + assert.notEqual(beforeRow.hash, afterRow.hash, 'expected file_manifest hash to change after update'); + assert.ok(afterRow.chunk_count, 'expected file_manifest chunk_count after incremental update'); +}; + +export const runManifestHashFillScenario = async () => { + const scenario = await createIncrementalScenario({ + name: 'manifest-hash-fill', + mode: 'code' + }); + scenario.runBuildIndex(); + await scenario.runBuildSqlite(); + + const db = await scenario.openCodeDb({ readonly: false }); + const targetFile = 'src/index.js'; + const before = db + .prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', targetFile); + assert.ok(before, 'missing file_manifest entry for src/index.js'); + db.prepare('UPDATE file_manifest SET hash = NULL WHERE mode = ? AND file = ?') + .run('code', targetFile); + db.close(); + + scenario.runBuildIndex({ incremental: true }); + await scenario.runBuildSqlite({ incremental: true }); + + const dbAfter = await scenario.openCodeDb(); + const after = dbAfter + .prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') + .get('code', targetFile); + dbAfter.close(); + + assert.ok(after?.hash, 'expected file_manifest hash to be restored after incremental update'); +}; + +export const runManifestNormalizationScenario = async () => { + const scenario = await createIncrementalScenario({ name: 'manifest-normalization', mode: 'code' }); + scenario.runBuildIndex(); + await scenario.runBuildSqlite(); + + const repoCacheRoot = getRepoCacheRoot(scenario.repoRoot, scenario.userConfig); + const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); + assert.ok(manifest?.files?.['src/index.js'], 'expected manifest entry for src/index.js'); + + manifest.files['src\\index.js'] = manifest.files['src/index.js']; + delete manifest.files['src/index.js']; + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + + const logs = []; + await scenario.runBuildSqlite({ + incremental: true, + logger: { + log: (message) => logs.push(message), + warn: (message) => logs.push(message), + error: (message) => logs.push(message) + } + }); + + const output = logs.join('\n'); + assert.ok( + output.includes('[sqlite] indexes updated.') || output.includes('[sqlite] index updated.'), + 'expected incremental sqlite update with normalized manifest' + ); +}; + +export const runSearchAfterUpdateScenario = async (scenario = null) => { + const activeScenario = await getPreparedCodeScenario(scenario, 'search-after-update'); + await appendFixtureExport(activeScenario.repoRoot); + + activeScenario.runBuildIndex({ incremental: true }); + await activeScenario.runBuildSqlite({ incremental: true }); + + const searchResult = runRepoSearchJson({ + root: activeScenario.root, + repoRoot: activeScenario.repoRoot, + env: activeScenario.env, + query: 'farewell', + mode: 'code' + }); + assert.equal(searchResult.status, 0, 'search should succeed after incremental update'); + assert.ok( + searchResult.payload.code?.length || searchResult.payload.prose?.length, + 'expected sqlite search results after incremental update' + ); +}; + +export const runWalCheckpointScenario = async (scenario = null) => { + const activeScenario = await getPreparedCodeScenario(scenario, 'wal-checkpoint'); + + const targetFile = path.join(activeScenario.repoRoot, 'src', 'index.js'); + const original = await fsPromises.readFile(targetFile, 'utf8'); + await fsPromises.writeFile(targetFile, `${original}\nexport const walCheck = true;\n`); + + activeScenario.runBuildIndex({ incremental: true }); + await activeScenario.runBuildSqlite({ mode: 'code', incremental: true }); + + const sqlitePaths = ensureSqlitePaths(activeScenario.repoRoot, activeScenario.userConfig); + const walPath = `${sqlitePaths.codePath}-wal`; + const shmPath = `${sqlitePaths.codePath}-shm`; + const walSize = fs.existsSync(walPath) ? fs.statSync(walPath).size : 0; + const shmSize = fs.existsSync(shmPath) ? fs.statSync(shmPath).size : 0; + const maxBytes = 1024; + assert.ok(walSize <= maxBytes, `Expected WAL to be truncated; size ${walSize} bytes.`); + assert.ok(shmSize <= maxBytes, `Expected SHM to be truncated; size ${shmSize} bytes.`); +}; diff --git a/tests/storage/sqlite/incremental/update-contract-matrix.test.js b/tests/storage/sqlite/incremental/update-contract-matrix.test.js new file mode 100644 index 000000000..006f07ff0 --- /dev/null +++ b/tests/storage/sqlite/incremental/update-contract-matrix.test.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { + runSearchAfterUpdateScenario +} from './update-contract-cases.js'; + +const cases = [ + { name: 'search after update', run: () => runSearchAfterUpdateScenario() } +]; + +for (const testCase of cases) { + try { + await testCase.run(); + } catch (error) { + console.error(`sqlite incremental update contract matrix failed: ${testCase.name}`); + console.error(error?.stack || error?.message || String(error)); + process.exit(1); + } +} + +console.log(`sqlite incremental update contract matrix passed (${cases.length} cases)`); diff --git a/tests/storage/sqlite/incremental/wal-checkpoint-contract.test.js b/tests/storage/sqlite/incremental/wal-checkpoint-contract.test.js new file mode 100644 index 000000000..5b0baed59 --- /dev/null +++ b/tests/storage/sqlite/incremental/wal-checkpoint-contract.test.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { runWalCheckpointScenario } from './update-contract-cases.js'; + +try { + await runWalCheckpointScenario(); +} catch (error) { + console.error('sqlite incremental WAL checkpoint contract failed'); + console.error(error?.stack || error?.message || String(error)); + process.exit(1); +} + +console.log('sqlite incremental WAL checkpoint contract passed'); diff --git a/tests/storage/sqlite/incremental/wal-checkpoint.test.js b/tests/storage/sqlite/incremental/wal-checkpoint.test.js deleted file mode 100644 index b38acc2e9..000000000 --- a/tests/storage/sqlite/incremental/wal-checkpoint.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { setupIncrementalRepo, ensureSqlitePaths } from '../../../helpers/sqlite-incremental.js'; -import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; - -const { root, repoRoot, env, userConfig, run } = await setupIncrementalRepo({ name: 'wal-checkpoint' }); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot); - -const targetFile = path.join(repoRoot, 'src', 'index.js'); -const original = await fsPromises.readFile(targetFile, 'utf8'); -await fsPromises.writeFile(targetFile, `${original}\nexport const walCheck = true;\n`); - -run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], - 'build index (incremental)', - { cwd: repoRoot, env, stdio: 'inherit' } -); -await runSqliteBuild(repoRoot, { incremental: true }); - -const sqlitePaths = ensureSqlitePaths(repoRoot, userConfig); -const walPath = `${sqlitePaths.codePath}-wal`; -const shmPath = `${sqlitePaths.codePath}-shm`; -const walSize = fs.existsSync(walPath) ? fs.statSync(walPath).size : 0; -const shmSize = fs.existsSync(shmPath) ? fs.statSync(shmPath).size : 0; -const maxBytes = 1024; -if (walSize > maxBytes) { - console.error(`Expected WAL to be truncated; size ${walSize} bytes.`); - process.exit(1); -} -if (shmSize > maxBytes) { - console.error(`Expected SHM to be truncated; size ${shmSize} bytes.`); - process.exit(1); -} - -console.log('SQLite incremental WAL checkpoint ok.'); diff --git a/tests/storage/sqlite/index-state-fail-closed.test.js b/tests/storage/sqlite/index-state-fail-closed.test.js new file mode 100644 index 000000000..9f8677a16 --- /dev/null +++ b/tests/storage/sqlite/index-state-fail-closed.test.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { getIndexDir, getRepoCacheRoot, loadUserConfig, resolveIndexRoot } from '../../../tools/shared/dict-utils.js'; +import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-index-state-fail'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(tempRoot, { recursive: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'token.js'), + 'export function tokenFailClosed() { return "token"; }\n', + 'utf8' +); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + }, + extraEnv: { + PAIROFCLEATS_WORKER_POOL: 'off' + } +}); + +const run = (args, label) => { + const result = runNode(args, label, repoRoot, env, { stdio: 'inherit', allowFailure: true }); + if (result.status !== 0) { + console.error(`Failed: ${label}`); + process.exit(result.status ?? 1); + } +}; + +run([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot +], 'build index'); + +const userConfig = loadUserConfig(repoRoot); +const indexRoot = resolveIndexRoot(repoRoot, userConfig); +const codeDir = getIndexDir(repoRoot, 'code', userConfig, { indexRoot }); +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const statePath = path.join(codeDir, 'index_state.json'); +if (!fs.existsSync(statePath)) { + console.error('Expected index_state.json after initial build.'); + process.exit(1); +} + +const chunkMetaJson = path.join(codeDir, 'chunk_meta.json'); +const chunkMetaJsonl = path.join(codeDir, 'chunk_meta.jsonl'); +const chunkMetaMeta = path.join(codeDir, 'chunk_meta.meta.json'); +const chunkMetaParts = path.join(codeDir, 'chunk_meta.parts'); +const chunkMetaColumnar = path.join(codeDir, 'chunk_meta.columnar.json'); +const chunkMetaBinaryMeta = path.join(codeDir, 'chunk_meta.binary-columnar.meta.json'); +const chunkMetaBinaryData = path.join(codeDir, 'chunk_meta.binary-columnar.bin'); +const chunkMetaBinaryOffsets = path.join(codeDir, 'chunk_meta.binary-columnar.offsets.bin'); +const chunkMetaBinaryLengths = path.join(codeDir, 'chunk_meta.binary-columnar.lengths.varint'); +const chunkMetaColdJsonl = path.join(codeDir, 'chunk_meta_cold.jsonl'); +const chunkMetaColdMeta = path.join(codeDir, 'chunk_meta_cold.meta.json'); +const chunkMetaColdParts = path.join(codeDir, 'chunk_meta_cold.parts'); +await fsPromises.rm(chunkMetaJson, { force: true }); +await fsPromises.rm(chunkMetaJsonl, { force: true }); +await fsPromises.rm(chunkMetaMeta, { force: true }); +await fsPromises.rm(chunkMetaParts, { recursive: true, force: true }); +await fsPromises.rm(chunkMetaColumnar, { force: true }); +await fsPromises.rm(chunkMetaBinaryMeta, { force: true }); +await fsPromises.rm(chunkMetaBinaryData, { force: true }); +await fsPromises.rm(chunkMetaBinaryOffsets, { force: true }); +await fsPromises.rm(chunkMetaBinaryLengths, { force: true }); +await fsPromises.rm(chunkMetaColdJsonl, { force: true }); +await fsPromises.rm(chunkMetaColdMeta, { force: true }); +await fsPromises.rm(chunkMetaColdParts, { recursive: true, force: true }); +const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); +await fsPromises.rm(manifestPath, { force: true }); + +let sqliteFailed = false; +try { + await runSqliteBuild(repoRoot, { mode: 'code' }); +} catch { + sqliteFailed = true; +} +if (!sqliteFailed) { + console.error('Expected sqlite build to fail with missing artifacts.'); + process.exit(1); +} + +const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); +if (!state?.sqlite) { + console.error('index_state.json missing sqlite section after failure.'); + process.exit(1); +} +if (state.sqlite.status !== 'failed') { + console.error(`Expected sqlite status=failed, got ${state.sqlite.status}`); + process.exit(1); +} + +run([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--mode', + 'code', + '--repo', + repoRoot +], 'rebuild index'); +await runSqliteBuild(repoRoot, { mode: 'code' }); + +const refreshedIndexRoot = resolveIndexRoot(repoRoot, userConfig); +const refreshedCodeDir = getIndexDir(repoRoot, 'code', userConfig, { indexRoot: refreshedIndexRoot }); +const refreshedStatePath = path.join(refreshedCodeDir, 'index_state.json'); +const stateAfter = JSON.parse(fs.readFileSync(refreshedStatePath, 'utf8')); +if (stateAfter.sqlite?.status !== 'ready') { + console.error(`Expected sqlite status=ready after success, got ${stateAfter.sqlite?.status}`); + process.exit(1); +} + +console.log('sqlite index state fail-closed test passed'); + diff --git a/tests/storage/sqlite/sqlite-jsonl-streaming-gzip.test.js b/tests/storage/sqlite/jsonl-streaming-gzip.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-jsonl-streaming-gzip.test.js rename to tests/storage/sqlite/jsonl-streaming-gzip.test.js diff --git a/tests/storage/sqlite/sqlite-jsonl-streaming-zstd.test.js b/tests/storage/sqlite/jsonl-streaming-zstd.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-jsonl-streaming-zstd.test.js rename to tests/storage/sqlite/jsonl-streaming-zstd.test.js diff --git a/tests/storage/sqlite/sqlite-lmdb-path-traversal.test.js b/tests/storage/sqlite/lmdb-path-traversal.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-lmdb-path-traversal.test.js rename to tests/storage/sqlite/lmdb-path-traversal.test.js diff --git a/tests/storage/sqlite/maintenance-contract-matrix.test.js b/tests/storage/sqlite/maintenance-contract-matrix.test.js new file mode 100644 index 000000000..f401bd40d --- /dev/null +++ b/tests/storage/sqlite/maintenance-contract-matrix.test.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { runCompactScenario } from './helpers/maintenance-scenarios.js'; + +try { + await runCompactScenario(); +} catch (error) { + console.error('sqlite maintenance contract matrix failed: sqlite compaction'); + console.error(error?.stack || error?.message || String(error)); + process.exit(1); +} + +console.log('sqlite maintenance contract matrix passed (1 case)'); diff --git a/tests/storage/sqlite/maintenance-sidecar-cleanup.test.js b/tests/storage/sqlite/maintenance-sidecar-cleanup.test.js new file mode 100644 index 000000000..2fbc85be3 --- /dev/null +++ b/tests/storage/sqlite/maintenance-sidecar-cleanup.test.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { runSidecarCleanupScenario } from './helpers/maintenance-scenarios.js'; + +try { + await runSidecarCleanupScenario(); +} catch (error) { + console.error('sqlite maintenance sidecar cleanup failed'); + console.error(error?.stack || error?.message || String(error)); + process.exit(1); +} + +console.log('sqlite maintenance sidecar cleanup passed'); diff --git a/tests/storage/sqlite/manifest-legacy-bundle-key.test.js b/tests/storage/sqlite/manifest-legacy-bundle-key.test.js new file mode 100644 index 000000000..42701fb64 --- /dev/null +++ b/tests/storage/sqlite/manifest-legacy-bundle-key.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveManifestBundleNames } from '../../../src/shared/bundle-io-paths.js'; +import { validateIncrementalManifest } from '../../../src/storage/sqlite/build/manifest.js'; + +const legacyOnly = resolveManifestBundleNames({ bundle: 'abc123.json' }); +assert.deepEqual(legacyOnly, ['abc123.json'], 'expected legacy `bundle` key to resolve as a single bundle name'); + +const preferModern = resolveManifestBundleNames({ + bundle: 'legacy.json', + bundles: ['new-a.json', 'new-b.json'] +}); +assert.deepEqual( + preferModern, + ['new-a.json', 'new-b.json'], + 'expected explicit `bundles` list to take precedence when present' +); + +const validation = validateIncrementalManifest({ + files: { + 'src/file.js': { + hash: 'abc', + mtimeMs: 1, + size: 2, + bundle: 'abc123.json' + } + } +}); +assert.equal(validation.ok, true, 'expected incremental manifest validator to accept legacy `bundle` entries'); + +console.log('sqlite manifest legacy bundle key test passed'); diff --git a/tests/storage/sqlite/migrations/schema-mismatch-rebuild.test.js b/tests/storage/sqlite/migrations/schema-mismatch-rebuild.test.js index eb6c51d7d..c76320010 100644 --- a/tests/storage/sqlite/migrations/schema-mismatch-rebuild.test.js +++ b/tests/storage/sqlite/migrations/schema-mismatch-rebuild.test.js @@ -10,11 +10,11 @@ const { root, repoRoot, env, userConfig, run, runCapture } = await setupIncremen }); run( - [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], + [path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], 'build index', { cwd: repoRoot, env, stdio: 'inherit' } ); -await runSqliteBuild(repoRoot); +await runSqliteBuild(repoRoot, { mode: 'code' }); let Database; try { @@ -32,6 +32,7 @@ dbDowngrade.close(); const rebuildLogs = []; await runSqliteBuild(repoRoot, { + mode: 'code', incremental: true, logger: { log: (message) => rebuildLogs.push(message), diff --git a/tests/storage/sqlite/quantization/quantization-parity.test.js b/tests/storage/sqlite/quantization/parity.test.js similarity index 100% rename from tests/storage/sqlite/quantization/quantization-parity.test.js rename to tests/storage/sqlite/quantization/parity.test.js diff --git a/tests/storage/sqlite/replace-database-fallbacks.test.js b/tests/storage/sqlite/replace-database-fallbacks.test.js new file mode 100644 index 000000000..ddff9977a --- /dev/null +++ b/tests/storage/sqlite/replace-database-fallbacks.test.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { replaceSqliteDatabase } from '../../../src/storage/sqlite/utils.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'sqlite-replace-database-fallbacks'); +await fsPromises.rm(outDir, { recursive: true, force: true }); +await fsPromises.mkdir(outDir, { recursive: true }); + +const runReplaceWithRenameFault = async ({ + tempPath, + finalPath, + shouldFailRename, + failureMessage +}) => { + const originalRename = fsPromises.rename; + fsPromises.rename = async (from, to) => { + const code = shouldFailRename(from, to); + if (code) { + const err = new Error(code); + err.code = code; + throw err; + } + return originalRename(from, to); + }; + + let failed = null; + try { + await replaceSqliteDatabase(tempPath, finalPath); + } catch (err) { + failed = err; + } finally { + fsPromises.rename = originalRename; + } + + assert.ok(failed, failureMessage); + return failed; +}; + +const runCrossDeviceFallbackCase = async () => { + const finalPath = path.join(outDir, 'cross-device.sqlite'); + const tempPath = path.join(outDir, 'cross-device.sqlite.tmp'); + const backupPath = `${finalPath}.bak`; + + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + const failed = await runReplaceWithRenameFault({ + tempPath, + finalPath, + shouldFailRename: (from, to) => (from === tempPath && to === finalPath ? 'EXDEV' : null), + failureMessage: 'expected sqlite replace to fail closed on EXDEV' + }); + assert.equal(failed?.code, 'ERR_SQLITE_REPLACE_CROSS_DEVICE'); + const contents = await fsPromises.readFile(finalPath, 'utf8'); + assert.equal(contents, 'before', 'expected original sqlite db restored after EXDEV failure'); + assert.ok(fs.existsSync(tempPath), 'expected temp sqlite db preserved after EXDEV failure'); + assert.ok(!fs.existsSync(backupPath), 'expected backup consumed during restore'); +}; + +const runRestoreBackupCase = async () => { + const finalPath = path.join(outDir, 'restore.sqlite'); + const tempPath = path.join(outDir, 'restore.sqlite.tmp'); + const backupPath = `${finalPath}.bak`; + + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + await runReplaceWithRenameFault({ + tempPath, + finalPath, + shouldFailRename: (from, to) => (from === tempPath && to === finalPath ? 'ENOENT' : null), + failureMessage: 'expected sqlite replace to fail when temp promote cannot complete' + }); + assert.ok(fs.existsSync(finalPath), 'expected original sqlite db restored from backup'); + assert.ok(!fs.existsSync(backupPath), 'expected restore to consume backup when keepBackup=false'); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); + assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); +}; + +const runStaleBackupNoRestoreCase = async () => { + const finalPath = path.join(outDir, 'stale-backup.sqlite'); + const tempPath = path.join(outDir, 'stale-backup.sqlite.tmp'); + const backupPath = `${finalPath}.bak`; + + await fsPromises.writeFile(backupPath, 'stale', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + await runReplaceWithRenameFault({ + tempPath, + finalPath, + shouldFailRename: (from, to) => (from === tempPath && to === finalPath ? 'ENOENT' : null), + failureMessage: 'expected sqlite replace to fail when temp promote cannot complete' + }); + assert.ok(!fs.existsSync(finalPath), 'expected stale backup to remain un-restored when final db did not exist'); + assert.ok(fs.existsSync(backupPath), 'expected stale backup to remain untouched on failure'); + assert.equal(await fsPromises.readFile(backupPath, 'utf8'), 'stale'); + assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); +}; + +const runCrossDeviceBackupMoveCase = async () => { + const finalPath = path.join(outDir, 'cross-device-backup.sqlite'); + const tempPath = path.join(outDir, 'cross-device-backup.sqlite.tmp'); + const backupPath = `${finalPath}.bak`; + + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + const failed = await runReplaceWithRenameFault({ + tempPath, + finalPath, + shouldFailRename: (from, to) => (from === finalPath && to === backupPath ? 'EXDEV' : null), + failureMessage: 'expected sqlite replace to fail when final->backup crosses devices' + }); + assert.equal(failed?.code, 'ERR_SQLITE_REPLACE_CROSS_DEVICE'); + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); + assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); + assert.ok(!fs.existsSync(backupPath), 'expected no backup file when backup move never succeeded'); +}; + +const runBackupCleanupFailureNonFatalCase = async () => { + const finalPath = path.join(outDir, 'backup-cleanup.sqlite'); + const tempPath = path.join(outDir, 'backup-cleanup.sqlite.tmp'); + const backupPath = `${finalPath}.bak`; + + await fsPromises.writeFile(finalPath, 'before', 'utf8'); + await fsPromises.writeFile(tempPath, 'after', 'utf8'); + + const originalRm = fsPromises.rm; + fsPromises.rm = async (targetPath, options) => { + if (targetPath === backupPath) { + const err = new Error('EACCES'); + err.code = 'EACCES'; + throw err; + } + return originalRm(targetPath, options); + }; + + try { + await replaceSqliteDatabase(tempPath, finalPath); + } finally { + fsPromises.rm = originalRm; + } + + assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'after'); + assert.ok(fs.existsSync(backupPath), 'expected failed backup cleanup to leave backup behind'); +}; + +await runCrossDeviceFallbackCase(); +await runRestoreBackupCase(); +await runStaleBackupNoRestoreCase(); +await runCrossDeviceBackupMoveCase(); +await runBackupCleanupFailureNonFatalCase(); + +console.log('sqlite replace database fallback tests passed'); diff --git a/tests/storage/sqlite/runner-no-process-exit.test.js b/tests/storage/sqlite/runner-no-process-exit.test.js new file mode 100644 index 000000000..f6fae251c --- /dev/null +++ b/tests/storage/sqlite/runner-no-process-exit.test.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + runBuildSqliteIndexWithConfig +} from '../../../src/storage/sqlite/build/runner.js'; +import { executeSqliteModeBuilds } from '../../../src/storage/sqlite/build/runner/execution-orchestration.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'sqlite-runner-no-process-exit'); +await fsPromises.rm(outDir, { recursive: true, force: true }); +await fsPromises.mkdir(outDir, { recursive: true }); + +const originalProcessExit = process.exit; +const exitCalls = []; +process.exit = ((code = 0) => { + exitCalls.push(code); + throw new Error(`process.exit invoked with code ${code}`); +}); + +try { + const parsed = { + argv: { + repo: root, + mode: 'code', + incremental: false, + compact: true, + 'no-compact': true, + validate: 'off', + out: null, + 'index-root': null, + 'as-of': null, + snapshot: null, + 'code-dir': null, + 'prose-dir': null, + 'extracted-prose-dir': null, + 'records-dir': null, + 'batch-size': null, + progress: 'auto', + verbose: false, + quiet: true + }, + emitOutput: false, + exitOnError: true, + validateMode: 'off', + modeArg: 'code', + rawArgs: [] + }; + + let runnerFailure = null; + try { + await runBuildSqliteIndexWithConfig(parsed, { + logger: { log() {}, warn() {}, error() {} }, + root + }); + } catch (err) { + runnerFailure = err; + } + + assert.ok(runnerFailure, 'expected sqlite runner to fail for conflicting compact flags'); + assert.equal(runnerFailure?.code, 'ERR_SQLITE_BUILD_FAILED'); + assert.equal(runnerFailure?.exitCode, 1); + assert.equal(runnerFailure?.exitOnError, true); + assert.equal(exitCalls.length, 0, 'runner should not invoke process.exit'); + + const indexRoot = path.join(outDir, 'index-root'); + const metricsDir = path.join(outDir, 'metrics'); + const repoCacheRoot = path.join(outDir, 'repo-cache'); + const modeIndexDir = path.join(indexRoot, 'code'); + await fsPromises.mkdir(modeIndexDir, { recursive: true }); + await fsPromises.mkdir(metricsDir, { recursive: true }); + await fsPromises.mkdir(repoCacheRoot, { recursive: true }); + + const modeOutputPath = path.join(outDir, 'mode-code.sqlite'); + const syntheticFailure = new Error('synthetic index piece load error'); + + let orchestrationFailure = null; + try { + await executeSqliteModeBuilds({ + Database: function MockDatabase() {}, + argv: { compact: false, 'no-compact': false }, + validateMode: 'off', + emitOutput: false, + exitOnError: true, + externalLogger: { log() {}, warn() {}, error() {} }, + taskFactory: () => ({ set() {} }), + logger: { log() {}, warn() {}, error() {} }, + schemaVersion: '1.0.0', + bail: (message) => { + const err = new Error(message || 'bail'); + err.code = 'ERR_SQLITE_BAIL'; + throw err; + }, + modeList: ['code'], + indexRoot, + modeIndexDirs: { code: modeIndexDir }, + modeOutputPaths: { code: modeOutputPath }, + modeChunkCountHints: { code: 1 }, + root, + userConfig: {}, + metricsDir, + modelConfig: {}, + vectorExtension: { enabled: false }, + vectorAnnEnabled: false, + vectorConfig: { hasVectorTable: () => false }, + sqliteSharedDb: false, + logPrefix: '[sqlite]', + repoCacheRoot, + bundleWorkerProfilePath: path.join(outDir, 'bundle-profile.json'), + bundleWorkerProfile: { modes: {} }, + incrementalRequested: false, + batchSizeOverride: null, + resolveAdaptiveBatchConfig: () => ({ + config: {}, + plan: { + batchSize: 1000, + transactionRows: 64000, + filesPerTransaction: 100, + walPressure: 'off' + } + }), + indexPieces: {}, + indexPieceErrors: { code: syntheticFailure }, + compactMode: false, + envConfig: {}, + threadLimits: { + cpuCount: 1, + maxConcurrencyCap: 1, + fileConcurrency: 1, + importConcurrency: 1, + ioConcurrency: 1, + cpuConcurrency: 1 + } + }); + } catch (err) { + orchestrationFailure = err; + } + + assert.ok(orchestrationFailure, 'expected orchestration failure from synthetic piece error'); + assert.equal(orchestrationFailure?.code, 'ERR_SQLITE_INPUT_PIECES_LOAD_FAILED'); + assert.equal(orchestrationFailure?.exitCode, 1); + assert.equal(orchestrationFailure?.exitOnError, true); + assert.equal(exitCalls.length, 0, 'orchestration should not invoke process.exit'); +} finally { + process.exit = originalProcessExit; +} + +console.log('sqlite runner no-process-exit test passed'); diff --git a/tests/storage/sqlite/runtime-telemetry-contract.test.js b/tests/storage/sqlite/runtime-telemetry-contract.test.js new file mode 100644 index 000000000..1f918cb7e --- /dev/null +++ b/tests/storage/sqlite/runtime-telemetry-contract.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + checkpointSqliteWithTelemetry, + recordSqliteCommitTelemetry, + recordSqlitePlanTelemetry, + recordSqliteWalSnapshot, + resolveSqliteIngestPlan +} from '../../../src/storage/sqlite/utils.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const MB = 1024 * 1024; +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-runtime-telemetry-contract'); +const dbPath = path.join(tempRoot, 'index.db'); + +await fsp.rm(tempRoot, { recursive: true, force: true }); +await fsp.mkdir(tempRoot, { recursive: true }); +await fsp.writeFile(dbPath, Buffer.alloc(4096)); +await fsp.writeFile(`${dbPath}-wal`, Buffer.alloc(12 * 1024)); +await fsp.writeFile(`${dbPath}-shm`, Buffer.alloc(1024)); + +const stats = {}; +const plan = resolveSqliteIngestPlan({ + inputBytes: 600 * MB, + repoBytes: 600 * MB, + rowCount: 150_000, + fileCount: 1200, + pageSize: 8192, + journalMode: 'wal', + walEnabled: true, + walBytes: 32 * MB +}); +recordSqlitePlanTelemetry(stats, plan, { source: 'contract-test' }); +recordSqliteWalSnapshot(stats, { + stage: 'before', + dbPath, + pageSize: plan.pageSize, + journalMode: plan.journalMode, + walEnabled: plan.walEnabled, + walPressure: plan.walPressure, + source: 'contract-test' +}); +recordSqliteCommitTelemetry(stats, { + stage: 'commit', + durationMs: 250, + dbPath, + pageSize: plan.pageSize, + journalMode: plan.journalMode, + walEnabled: plan.walEnabled, + walPressure: plan.walPressure, + source: 'contract-test' +}); + +const fakeDb = { + pragma(sql) { + if (sql !== 'wal_checkpoint(TRUNCATE)') { + throw new Error(`Unexpected pragma: ${sql}`); + } + fs.writeFileSync(`${dbPath}-wal`, Buffer.alloc(0)); + return [{ busy: 0, log: 0, checkpointed: 0 }]; + } +}; + +checkpointSqliteWithTelemetry(fakeDb, { + stats, + dbPath, + stage: 'checkpoint', + pageSize: plan.pageSize, + journalMode: plan.journalMode, + walEnabled: plan.walEnabled, + walPressure: plan.walPressure, + source: 'contract-test' +}); + +assert.equal(stats.runtimeTelemetry.plan.source, 'contract-test'); +assert.equal(stats.runtimeTelemetry.plan.telemetry.planVersion, 1); +assert.equal(stats.runtimeTelemetry.walSnapshots.length, 2, 'expected before and post-checkpoint snapshots'); +assert.equal(stats.runtimeTelemetry.commits.length, 1, 'expected one commit sample'); +assert.equal(stats.runtimeTelemetry.checkpoints.length, 1, 'expected one checkpoint sample'); +assert.equal(stats.runtimeTelemetry.commits[0].durationMs, 250, 'expected commit duration sample'); +assert.equal( + stats.runtimeTelemetry.checkpoints[0].after.walBytes, + 0, + 'expected checkpoint sample to capture truncated WAL' +); +assert.equal(stats.runtimeTelemetry.stallCounts.commit, 1, 'expected slow commit to increment stall count'); + +console.log('sqlite runtime telemetry contract test passed'); diff --git a/tests/storage/sqlite/search-backend-contract-matrix.test.js b/tests/storage/sqlite/search-backend-contract-matrix.test.js new file mode 100644 index 000000000..4c43d41a0 --- /dev/null +++ b/tests/storage/sqlite/search-backend-contract-matrix.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; + +import { + createSearchBackendFixture, + parseJsonPayload +} from './helpers/search-backend-scenarios.js'; + +const fixture = await createSearchBackendFixture('sqlite-search-backend-contract-matrix'); +const { tempRoot, searchPath, run } = fixture; + +const cases = [ + { + name: 'auto chooses sqlite when thresholds are met', + run: () => { + const result = run( + [searchPath, 'greet', '--json', '--mode', 'code', '--repo', tempRoot], + 'search auto sqlite threshold', + { testConfig: { search: { sqliteAutoChunkThreshold: 1 } } } + ); + assert.equal(parseJsonPayload(result).backend, 'sqlite-fts'); + } + }, + { + name: 'auto stays on memory when thresholds are not met', + run: () => { + const result = run( + [searchPath, 'greet', '--json', '--stats', '--mode', 'code', '--repo', tempRoot], + 'search auto memory threshold', + { testConfig: { search: { sqliteAutoChunkThreshold: 9999 } } } + ); + const payload = parseJsonPayload(result); + assert.equal(payload.backend, 'memory'); + assert.match(String(payload?.stats?.backendPolicy?.reason || ''), /thresholds not met/); + } + }, + { + name: 'zero thresholds force sqlite auto backend', + run: () => { + const result = run( + [searchPath, 'greet', '--json', '--mode', 'code', '--repo', tempRoot], + 'search auto sqlite threshold disabled', + { testConfig: { search: { sqliteAutoChunkThreshold: 0, sqliteAutoArtifactBytes: 0 } } } + ); + assert.equal(parseJsonPayload(result).backend, 'sqlite-fts'); + } + }, + { + name: 'auto falls back to memory when sqlite artifacts are missing', + run: async () => { + const sqlitePaths = fixture.resolveSqlitePaths(); + await fsPromises.rm(sqlitePaths.codePath, { force: true }); + await fsPromises.rm(sqlitePaths.prosePath, { force: true }); + await fsPromises.rm(sqlitePaths.extractedProsePath, { force: true }); + await fsPromises.rm(sqlitePaths.recordsPath, { force: true }); + await fsPromises.rm(sqlitePaths.dbDir, { recursive: true, force: true }); + + const result = run( + [searchPath, 'greet', '--json', '--mode', 'code', '--repo', tempRoot], + 'search auto memory' + ); + assert.equal(parseJsonPayload(result).backend, 'memory'); + + await fixture.restoreSnapshot(); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log(`sqlite search backend contract matrix passed (${cases.length} cases)`); diff --git a/tests/storage/sqlite/search-backend-disabled-dependency.test.js b/tests/storage/sqlite/search-backend-disabled-dependency.test.js new file mode 100644 index 000000000..4326e4396 --- /dev/null +++ b/tests/storage/sqlite/search-backend-disabled-dependency.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createSearchBackendFixture, + parseJsonPayload +} from './helpers/search-backend-scenarios.js'; + +const fixture = await createSearchBackendFixture('sqlite-search-backend-disabled-dependency'); +const { tempRoot, searchPath, run } = fixture; + +const cases = [ + { + name: 'auto falls back to memory when sqlite dependency is disabled', + run: () => { + const result = run( + [searchPath, 'greet', '--json', '--mode', 'code', '--repo', tempRoot], + 'search auto with sqlite disabled', + { extraEnv: { NODE_OPTIONS: '--no-addons' } } + ); + assert.equal(parseJsonPayload(result).backend, 'memory'); + } + }, + { + name: 'forced sqlite fails closed when sqlite dependency is disabled', + run: () => { + const result = run( + [searchPath, 'greet', '--json', '--mode', 'code', '--backend', 'sqlite', '--repo', tempRoot], + 'search forced sqlite with sqlite disabled', + { extraEnv: { NODE_OPTIONS: '--no-addons' }, allowFailure: true } + ); + assert.notEqual(result.status, 0, 'expected forced sqlite search to fail when sqlite is disabled'); + const stdout = String(result.stdout || '').trim(); + const stderr = String(result.stderr || '').trim(); + let message = ''; + try { + message = parseJsonPayload(result)?.message || ''; + } catch { + message = stderr; + } + assert.match(message || stdout || stderr, /better-sqlite3 is required/); + } + } +]; + +for (const testCase of cases) { + await testCase.run(); +} + +console.log(`sqlite search backend disabled dependency contract passed (${cases.length} cases)`); diff --git a/tests/storage/sqlite/skip-empty-code-rebuild.test.js b/tests/storage/sqlite/skip-empty-code-rebuild.test.js new file mode 100644 index 000000000..ea425f8a4 --- /dev/null +++ b/tests/storage/sqlite/skip-empty-code-rebuild.test.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + assertSeededDbUnchangedAfterZeroState, + assertZeroStateSkipped, + prepareZeroStateSqliteFixture, + runZeroStateSqliteBuild +} from './helpers/zero-state-rebuild-fixture.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite empty code rebuild test requires better-sqlite3' }); + +let Database = null; +({ default: Database } = await import('better-sqlite3')); + +const fixture = await prepareZeroStateSqliteFixture({ + label: 'sqlite-skip-empty-code-rebuild', + mode: 'code', + indexDirName: 'index-code', + dbName: 'index-code.db' +}); + +const modeArg = 'codeDir'; +const skipMessage = 'skipping sqlite rebuild (artifacts empty; zero-state).'; +const logs = await runZeroStateSqliteBuild({ fixture, modeArg }); + +await assertZeroStateSkipped({ + outputPath: fixture.outputPath, + zeroStateManifestPath: fixture.zeroStateManifestPath, + logs, + message: skipMessage +}); + +await assertSeededDbUnchangedAfterZeroState({ + Database, + outputPath: fixture.outputPath, + runAgain: () => runZeroStateSqliteBuild({ fixture, modeArg }), + message: skipMessage, + unchangedMessage: 'expected empty code sqlite db to remain unchanged' +}); + +console.log('sqlite skip empty code rebuild test passed'); diff --git a/tests/storage/sqlite/skip-empty-records-rebuild.test.js b/tests/storage/sqlite/skip-empty-records-rebuild.test.js new file mode 100644 index 000000000..723a6f10b --- /dev/null +++ b/tests/storage/sqlite/skip-empty-records-rebuild.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { setRecordsIncrementalCapability } from '../../../src/storage/sqlite/build/index.js'; +import { + assertSeededDbUnchangedAfterZeroState, + assertZeroStateSkipped, + prepareZeroStateSqliteFixture, + runZeroStateSqliteBuild +} from './helpers/zero-state-rebuild-fixture.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite empty records rebuild test requires better-sqlite3' }); + +let Database = null; +({ default: Database } = await import('better-sqlite3')); + +const fixture = await prepareZeroStateSqliteFixture({ + label: 'sqlite-skip-empty-records-rebuild', + mode: 'records', + indexDirName: 'index-records', + dbName: 'index-records.db' +}); + +const modeArg = 'recordsDir'; +const skipMessage = 'skipping records sqlite rebuild (artifacts empty; zero-state).'; +const logs = await runZeroStateSqliteBuild({ fixture, modeArg }); + +await assertZeroStateSkipped({ + outputPath: fixture.outputPath, + zeroStateManifestPath: fixture.zeroStateManifestPath, + logs, + message: skipMessage +}); + +await assertSeededDbUnchangedAfterZeroState({ + Database, + outputPath: fixture.outputPath, + runAgain: () => runZeroStateSqliteBuild({ fixture, modeArg }), + message: skipMessage, + unchangedMessage: 'expected empty records sqlite db to remain unchanged' +}); + +const userConfig = loadUserConfig(fixture.repoRoot); +const repoCacheRoot = getRepoCacheRoot(fixture.repoRoot, userConfig); +const recordsIncrementalDir = path.join(repoCacheRoot, 'incremental', 'records'); +const seedUnsupportedDb = new Database(fixture.outputPath); +seedUnsupportedDb.exec('INSERT INTO chunks (id, mode) VALUES (1, \'records\');'); +seedUnsupportedDb.close(); +await fs.mkdir(path.join(recordsIncrementalDir, 'files'), { recursive: true }); +const unsupportedManifest = { + version: 5, + mode: 'records', + files: {} +}; +setRecordsIncrementalCapability(unsupportedManifest, false); +await fs.writeFile( + path.join(recordsIncrementalDir, 'manifest.json'), + `${JSON.stringify(unsupportedManifest, null, 2)}\n`, + 'utf8' +); + +const unsupportedLogs = []; +unsupportedLogs.push(...await runZeroStateSqliteBuild({ fixture, modeArg, incremental: true })); +const unsupportedOutput = unsupportedLogs.join('\n').toLowerCase(); +assert.equal( + unsupportedOutput.includes('records incremental bundles unsupported') + || unsupportedOutput.includes('incremental bundles skipped for records'), + true, + 'expected unsupported records incremental capability warning' +); +assert.equal( + unsupportedOutput.includes('using artifacts'), + true, + 'expected unsupported records incremental manifest to fall back to artifacts' +); + +console.log('sqlite skip empty records rebuild test passed'); diff --git a/tests/storage/sqlite/sqlite-auto-backend.test.js b/tests/storage/sqlite/sqlite-auto-backend.test.js deleted file mode 100644 index 114a36874..000000000 --- a/tests/storage/sqlite/sqlite-auto-backend.test.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-auto'); -const cacheRoot = path.join(tempRoot, '.cache'); -const searchPath = path.join(root, 'search.js'); -const buildIndexPath = path.join(root, 'build_index.js'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const sampleCode = ` -export function greet(name) { - return "hello " + name; -} -`; -await fsPromises.writeFile(path.join(tempRoot, 'sample.js'), sampleCode); - -const buildTestEnv = (testConfig = null) => applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: testConfig ?? null, - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off' - } -}); - -const run = (args, label, config = null) => { - const result = spawnSync(process.execPath, args, { - cwd: tempRoot, - env: buildTestEnv(config), - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -run([buildIndexPath, '--stub-embeddings', '--repo', tempRoot], 'build index'); -await runSqliteBuild(tempRoot); - -const backendA = JSON.parse(run( - [searchPath, 'greet', '--json', '--repo', tempRoot], - 'search auto sqlite threshold', - { search: { sqliteAutoChunkThreshold: 1 } } -)).backend; -if (backendA !== 'sqlite-fts') { - console.error(`Expected sqlite-fts backend for threshold=1, got ${backendA}`); - process.exit(1); -} - -const resultB = JSON.parse(run( - [searchPath, 'greet', '--json', '--stats', '--repo', tempRoot], - 'search auto memory threshold', - { search: { sqliteAutoChunkThreshold: 9999 } } -)); -if (resultB.backend !== 'memory') { - console.error(`Expected memory backend for threshold=9999, got ${resultB.backend}`); - process.exit(1); -} -const reasonB = resultB?.stats?.backendPolicy?.reason || ''; -if (!reasonB.includes('thresholds not met')) { - console.error(`Expected auto sqlite threshold reason, got ${reasonB || 'missing'}`); - process.exit(1); -} - -const backendC = JSON.parse(run( - [searchPath, 'greet', '--json', '--repo', tempRoot], - 'search auto sqlite threshold disabled', - { search: { sqliteAutoChunkThreshold: 0, sqliteAutoArtifactBytes: 0 } } -)).backend; -if (backendC !== 'sqlite-fts') { - console.error(`Expected sqlite-fts backend for threshold=0, got ${backendC}`); - process.exit(1); -} - -const sqlitePaths = resolveSqlitePaths(tempRoot, null); -await fsPromises.rm(sqlitePaths.codePath, { force: true }); -await fsPromises.rm(sqlitePaths.prosePath, { force: true }); -await fsPromises.rm(sqlitePaths.extractedProsePath, { force: true }); -await fsPromises.rm(sqlitePaths.recordsPath, { force: true }); -await fsPromises.rm(sqlitePaths.dbDir, { recursive: true, force: true }); - -const backendD = JSON.parse(run([searchPath, 'greet', '--json', '--repo', tempRoot], 'search auto memory')).backend; -if (backendD !== 'memory') { - console.error(`Expected memory backend when sqlite is missing, got ${backendD}`); - process.exit(1); -} - -console.log('SQLite auto backend test passed'); diff --git a/tests/storage/sqlite/sqlite-batch-size-adaptive.test.js b/tests/storage/sqlite/sqlite-batch-size-adaptive.test.js deleted file mode 100644 index 5a2d6b5b7..000000000 --- a/tests/storage/sqlite/sqlite-batch-size-adaptive.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { resolveSqliteBatchSize } from '../../../src/storage/sqlite/utils.js'; - -const MB = 1024 * 1024; - -assert.equal(resolveSqliteBatchSize({ batchSize: 10 }), 50, 'min clamp expected'); -assert.equal(resolveSqliteBatchSize({ batchSize: 5000 }), 2000, 'max clamp expected'); - -assert.equal(resolveSqliteBatchSize({ inputBytes: 3000 * MB }), 200, 'large inputBytes should reduce batch size'); -assert.equal(resolveSqliteBatchSize({ inputBytes: 700 * MB }), 400, 'mid inputBytes should reduce batch size'); -assert.equal(resolveSqliteBatchSize({ inputBytes: 200 * MB }), 700, 'smaller inputBytes should reduce batch size'); -assert.equal(resolveSqliteBatchSize({ inputBytes: 10 * MB }), 1000, 'small inputBytes should keep default'); - -assert.equal( - resolveSqliteBatchSize({ inputBytes: 200 * MB, rowCount: 1_000_000 }), - 200, - 'rowCount should cap batch size' -); -assert.equal( - resolveSqliteBatchSize({ inputBytes: 200 * MB, rowCount: 100_000 }), - 700, - 'rowCount should not increase batch size' -); - -console.log('sqlite batch size adaptive test passed'); diff --git a/tests/storage/sqlite/sqlite-build-bench-contract.test.js b/tests/storage/sqlite/sqlite-build-bench-contract.test.js deleted file mode 100644 index 4625ecea3..000000000 --- a/tests/storage/sqlite/sqlite-build-bench-contract.test.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { setupSqliteBuildFixture } from './helpers/build-fixture.js'; - -const root = process.cwd(); -const fixture = await setupSqliteBuildFixture({ - tempLabel: 'sqlite-build-bench-contract', - chunkCount: 50, - fileCount: 3, - mode: 'code' -}); - -const benchScript = path.join(root, 'tools', 'bench', 'sqlite', 'build-from-artifacts.js'); -const result = spawnSync(process.execPath, [ - benchScript, - '--mode', - 'current', - '--index-dir', - fixture.indexDir, - '--statement-strategy', - 'prepared' -], { cwd: root, env: process.env, encoding: 'utf8' }); - -if (result.status !== 0) { - console.error(result.stdout || ''); - console.error(result.stderr || ''); - process.exit(result.status ?? 1); -} - -const output = `${result.stdout || ''}${result.stderr || ''}`; -assert.match(output, /\[bench\] build-from-artifacts current chunks=/, 'expected bench to report run'); -assert.match(output, /\[bench\] current statementStrategy=/, 'expected bench to print strategy line'); -assert.match(output, /\[bench\] current tables/, 'expected bench to print per-table stats'); - -console.log('sqlite build bench contract test passed'); - diff --git a/tests/storage/sqlite/sqlite-build-full-transaction.test.js b/tests/storage/sqlite/sqlite-build-full-transaction.test.js deleted file mode 100644 index 5e1090fd2..000000000 --- a/tests/storage/sqlite/sqlite-build-full-transaction.test.js +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-full-transaction'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 2000; -const tokens = ['alpha', 'beta']; -const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 5}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens - }; - } -}; - -const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 8192, - atomic: true -}); -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); -const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); -await writeJsonObjectFile(postingsPart, { - arrays: { - vocab: ['alpha'], - postings: [postingsEntries] - }, - atomic: true -}); -const docLengths = Array.from({ length: chunkCount }, () => tokens.length); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 1, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); - -const execCalls = []; -class InstrumentedDatabase extends Database { - exec(sql) { - execCalls.push(String(sql || '').trim()); - return super.exec(sql); - } -} - -const stats = {}; -const count = await buildDatabaseFromArtifacts({ - Database: InstrumentedDatabase, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - stats, - statementStrategy: 'prepared' -}); -assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); -assert.ok(fsSync.existsSync(outPath), 'expected sqlite DB to be created'); - -const beginCount = execCalls.filter((call) => call === 'BEGIN').length; -const commitCount = execCalls.filter((call) => call === 'COMMIT').length; -const rollbackCount = execCalls.filter((call) => call === 'ROLLBACK').length; -assert.equal(beginCount, 1, 'expected exactly one explicit BEGIN in full build'); -assert.equal(commitCount, 1, 'expected exactly one explicit COMMIT in full build'); -assert.equal(rollbackCount, 0, 'expected no ROLLBACK in successful full build'); - -assert.equal(stats?.transaction?.begin, 1, 'expected stats.transaction.begin=1'); -assert.equal(stats?.transaction?.commit, 1, 'expected stats.transaction.commit=1'); -assert.equal(stats?.transaction?.rollback, 0, 'expected stats.transaction.rollback=0'); - -const beginIndex = execCalls.indexOf('BEGIN'); -const commitIndex = execCalls.indexOf('COMMIT'); -assert.ok(beginIndex >= 0 && commitIndex > beginIndex, 'expected BEGIN before COMMIT'); - -const indexExecIndex = execCalls.findIndex((call) => call.includes('CREATE INDEX idx_chunks_file_id')); -assert.ok(indexExecIndex > beginIndex, 'expected CREATE_INDEXES_SQL to execute after BEGIN'); -assert.ok(indexExecIndex < commitIndex, 'expected CREATE_INDEXES_SQL to execute before COMMIT'); - -const db = new Database(outPath); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); -db.close(); - -console.log('sqlite build full transaction test passed'); diff --git a/tests/storage/sqlite/sqlite-build-indexes.test.js b/tests/storage/sqlite/sqlite-build-indexes.test.js deleted file mode 100644 index b9b1df03c..000000000 --- a/tests/storage/sqlite/sqlite-build-indexes.test.js +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-indexes'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -await fsPromises.writeFile(path.join(repoRoot, 'alpha.js'), 'const alpha = 1;\n'); -await fsPromises.writeFile(path.join(repoRoot, 'beta.js'), 'const beta = 2;\n'); - -const env = { - ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -applyTestEnv(); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const runNode = (label, args) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -runNode('build_index', [path.join(root, 'build_index.js'), '--stub-embeddings', '--stage', 'stage2', '--mode', 'code', '--repo', repoRoot]); -await runSqliteBuild(repoRoot, { mode: 'code' }); - -const previousCacheRoot = process.env.PAIROFCLEATS_CACHE_ROOT; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -const userConfig = loadUserConfig(repoRoot); -const indexDir = getIndexDir(repoRoot, 'code', userConfig); -const chunkMetaPartsDir = path.join(indexDir, 'chunk_meta.parts'); -const tokenPostingsShardsDir = path.join(indexDir, 'token_postings.shards'); -const chunkMetaJson = path.join(indexDir, 'chunk_meta.json'); -const hasChunkMeta = fs.existsSync(chunkMetaJson) || fs.existsSync(chunkMetaPartsDir); -if (!hasChunkMeta) { - console.error(`Expected chunk metadata in ${chunkMetaJson} or ${chunkMetaPartsDir}`); - process.exit(1); -} -const hasTokenPostings = fs.existsSync(tokenPostingsShardsDir) - || fs.existsSync(path.join(indexDir, 'token_postings.json')); -if (!hasTokenPostings) { - console.error(`Expected token postings artifacts in ${tokenPostingsShardsDir}`); - process.exit(1); -} -const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -if (previousCacheRoot === undefined) { - delete process.env.PAIROFCLEATS_CACHE_ROOT; -} else { - process.env.PAIROFCLEATS_CACHE_ROOT = previousCacheRoot; -} -const db = new Database(sqlitePaths.codePath); -const indexList = db.prepare("PRAGMA index_list('token_postings')").all(); -const indexNames = new Set(indexList.map((row) => row.name)); -if (indexNames.has('idx_token_postings_token')) { - console.error('Did not expect redundant idx_token_postings_token to exist'); - process.exit(1); -} -if (!indexList.some((row) => row.origin === 'pk')) { - console.error('Expected token_postings PRIMARY KEY index to exist'); - process.exit(1); -} -const chunkIndexList = db.prepare("PRAGMA index_list('chunks')").all(); -const chunkIndexNames = new Set(chunkIndexList.map((row) => row.name)); -if (!chunkIndexNames.has('idx_chunks_file_id')) { - console.error('Expected idx_chunks_file_id to exist'); - process.exit(1); -} -db.close(); - -console.log('sqlite build indexes test passed'); - diff --git a/tests/storage/sqlite/sqlite-build-memory-guard.test.js b/tests/storage/sqlite/sqlite-build-memory-guard.test.js deleted file mode 100644 index 1a52b0b20..000000000 --- a/tests/storage/sqlite/sqlite-build-memory-guard.test.js +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-memory-guard'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 1200; -const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 10}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens: ['alpha', 'beta'] - }; - } -}; - -const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 8192, - atomic: true -}); -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -const vocab = Array.from({ length: 600 }, (_, i) => `tok${i}`); -const postings = vocab.map(() => [[0, 1]]); -await writeJsonObjectFile(path.join(postingsDir, 'token_postings.part-00000.json'), { - arrays: { vocab, postings }, - atomic: true -}); -const docLengths = Array.from({ length: chunkCount }, () => 2); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: 2, - totalDocs: chunkCount, - format: 'sharded', - shardSize: vocab.length, - vocabCount: vocab.length, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); -const stats = {}; -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - batchSize: 200, - stats -}); - -assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); -assert.ok(stats.chunkMetaBatches > 1, 'expected chunk meta batches to flush'); -assert.ok(stats.tokenPostingBatches > 1, 'expected token postings to flush in batches'); -assert.ok(stats.tokenVocabBatches > 1, 'expected token vocab batches to flush'); -assert.ok(stats.docLengthBatches > 1, 'expected doc length batches to flush'); - -const db = new Database(outPath); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); -db.close(); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite build memory guard test passed'); diff --git a/tests/storage/sqlite/sqlite-build-pragmas-dynamic.test.js b/tests/storage/sqlite/sqlite-build-pragmas-dynamic.test.js deleted file mode 100644 index bdbabe9ce..000000000 --- a/tests/storage/sqlite/sqlite-build-pragmas-dynamic.test.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-pragmas-dynamic'); -const smallPath = path.join(tempRoot, 'small.db'); -const largePath = path.join(tempRoot, 'large.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const smallDb = new Database(smallPath); -const smallState = applyBuildPragmas(smallDb, { inputBytes: 10 * 1024 * 1024, stats: {} }); -restoreBuildPragmas(smallDb, smallState); -smallDb.close(); - -const largeDb = new Database(largePath); -const largeState = applyBuildPragmas(largeDb, { inputBytes: 3 * 1024 * 1024 * 1024, stats: {} }); -restoreBuildPragmas(largeDb, largeState); -largeDb.close(); - -const smallCache = Math.abs(Number(smallState.applied.cache_size || 0)); -const largeCache = Math.abs(Number(largeState.applied.cache_size || 0)); -assert.ok(largeCache >= smallCache, 'expected larger cache_size for large input'); - -const smallJournal = Number(smallState.applied.journal_size_limit || 0); -const largeJournal = Number(largeState.applied.journal_size_limit || 0); -assert.ok(largeJournal >= smallJournal, 'expected larger journal_size_limit for large input'); - -console.log('sqlite build pragmas dynamic test passed'); diff --git a/tests/storage/sqlite/sqlite-build-pragmas-restore.test.js b/tests/storage/sqlite/sqlite-build-pragmas-restore.test.js deleted file mode 100644 index 9fe906b4b..000000000 --- a/tests/storage/sqlite/sqlite-build-pragmas-restore.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import fsSync from 'node:fs'; -import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-pragmas-restore'); -const dbPath = path.join(tempRoot, 'restore.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const db = new Database(dbPath); -const readPragma = (name) => { - try { - return db.pragma(name, { simple: true }); - } catch { - return null; - } -}; - -db.pragma('cache_size = -1234'); -db.pragma('mmap_size = 0'); -db.pragma('journal_size_limit = 0'); -db.pragma('wal_autocheckpoint = 1000'); -db.pragma('synchronous = NORMAL'); -db.pragma('temp_store = DEFAULT'); -try { db.pragma('locking_mode = NORMAL'); } catch {} - -const before = { - cache_size: readPragma('cache_size'), - mmap_size: readPragma('mmap_size'), - journal_size_limit: readPragma('journal_size_limit'), - wal_autocheckpoint: readPragma('wal_autocheckpoint'), - synchronous: readPragma('synchronous'), - temp_store: readPragma('temp_store'), - locking_mode: readPragma('locking_mode') -}; - -const state = applyBuildPragmas(db, { inputBytes: 512 * 1024 * 1024, stats: {} }); -restoreBuildPragmas(db, state); - -const after = { - cache_size: readPragma('cache_size'), - mmap_size: readPragma('mmap_size'), - journal_size_limit: readPragma('journal_size_limit'), - wal_autocheckpoint: readPragma('wal_autocheckpoint'), - synchronous: readPragma('synchronous'), - temp_store: readPragma('temp_store'), - locking_mode: readPragma('locking_mode') -}; - -db.close(); - -for (const key of Object.keys(before)) { - if (before[key] === null || before[key] === undefined) continue; - assert.equal(after[key], before[key], `expected pragma ${key} to be restored`); -} - -if (!fsSync.existsSync(dbPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite build pragmas restore test passed'); diff --git a/tests/storage/sqlite/sqlite-build-prepared-statement-reuse.test.js b/tests/storage/sqlite/sqlite-build-prepared-statement-reuse.test.js deleted file mode 100644 index 3dffec96f..000000000 --- a/tests/storage/sqlite/sqlite-build-prepared-statement-reuse.test.js +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-build-prepared-statement-reuse'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const mode = 'code'; -const chunkCount = 200; -const tokens = ['alpha']; - -const createIndexDir = async (dir, shardCount) => { - await fs.rm(dir, { recursive: true, force: true }); - await fs.mkdir(dir, { recursive: true }); - - const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 3}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: mode, - name: `fn${i}`, - tokens - }; - } - }; - - const shardResult = await writeJsonLinesSharded({ - dir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 4096, - atomic: true - }); - await writeJsonObjectFile(path.join(dir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true - }); - - const postingsDir = path.join(dir, 'token_postings.shards'); - await fs.mkdir(postingsDir, { recursive: true }); - - const parts = []; - for (let shard = 0; shard < shardCount; shard += 1) { - const name = `token_postings.part-${String(shard).padStart(5, '0')}.json`; - const fullPath = path.join(postingsDir, name); - parts.push(`token_postings.shards/${name}`); - // Keep postings small; shard count should not impact prepare count. - await writeJsonObjectFile(fullPath, { - arrays: { - vocab: [`tok${shard}`], - postings: [[[0, 1]]] - }, - atomic: true - }); - } - - const docLengths = Array.from({ length: chunkCount }, () => tokens.length); - await writeJsonObjectFile(path.join(dir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: shardCount, - vocabCount: shardCount, - parts - }, - arrays: { docLengths }, - atomic: true - }); - await writePiecesManifest(dir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - ...parts.map((part) => ({ - name: 'token_postings', - path: part, - format: 'sharded' - })), - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } - ]); -}; - -const runBuild = async ({ indexDir, outPath }) => { - const indexPieces = await loadIndexPieces(indexDir, null); - assert.ok(indexPieces, `expected loadIndexPieces to detect artifacts in ${indexDir}`); - - const stats = {}; - await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode, - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - statementStrategy: 'prepared', - buildPragmas: false, - optimize: false, - stats - }); - const prepares = stats?.prepare?.total ?? null; - assert.ok(Number.isFinite(prepares), 'expected stats.prepare.total to be recorded'); - return prepares; -}; - -const dirOne = path.join(tempRoot, 'index-one'); -const dirMany = path.join(tempRoot, 'index-many'); -await createIndexDir(dirOne, 1); -await createIndexDir(dirMany, 12); - -const preparesOne = await runBuild({ - indexDir: dirOne, - outPath: path.join(tempRoot, 'one.db') -}); -const preparesMany = await runBuild({ - indexDir: dirMany, - outPath: path.join(tempRoot, 'many.db') -}); - -assert.equal( - preparesMany, - preparesOne, - `expected prepare count to be stable across shard counts (one=${preparesOne}, many=${preparesMany})` -); - -console.log('sqlite prepared statement reuse test passed'); - diff --git a/tests/storage/sqlite/sqlite-bundle-invalid.test.js b/tests/storage/sqlite/sqlite-bundle-invalid.test.js deleted file mode 100644 index d5349e3d7..000000000 --- a/tests/storage/sqlite/sqlite-bundle-invalid.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-invalid'); -const bundleDir = path.join(tempRoot, 'bundles'); -const dbPath = path.join(tempRoot, 'index-code.db'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(bundleDir, { recursive: true }); - -const bundleName = 'bad-bundle.json'; -const bundlePath = path.join(bundleDir, bundleName); -await fsPromises.writeFile(bundlePath, JSON.stringify({ files: [] }), 'utf8'); - -const result = await buildDatabaseFromBundles({ - Database, - outPath: dbPath, - mode: 'code', - incrementalData: { - bundleDir, - manifest: { - files: { - 'src/bad.js': { bundle: bundleName, mtimeMs: 1, size: 0 } - } - } - }, - envConfig: { bundleThreads: 1 }, - threadLimits: { fileConcurrency: 1 }, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: 'test' }, - workerPath: null, - logger: null -}); - -assert.equal(result.count, 0, 'expected bundle build to skip invalid bundle'); -assert.ok( - result.reason && result.reason.includes('invalid bundle'), - `expected invalid bundle reason, got ${result.reason}` -); - -console.log('sqlite bundle invalid test passed'); - diff --git a/tests/storage/sqlite/sqlite-bundle-loader-worker.test.js b/tests/storage/sqlite/sqlite-bundle-loader-worker.test.js deleted file mode 100644 index 9cb09fa96..000000000 --- a/tests/storage/sqlite/sqlite-bundle-loader-worker.test.js +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; - -import { createBundleLoader } from '../../../src/storage/sqlite/build/bundle-loader.js'; -import { writeBundleFile, writeBundlePatch } from '../../../src/shared/bundle-io.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-loader-worker'); -const bundleDir = path.join(tempRoot, 'bundles'); -const relFile = 'src/example.js'; -const bundleName = 'bundle-example.json'; -const bundlePath = path.join(bundleDir, bundleName); -const workerPath = path.join(root, 'src', 'storage', 'sqlite', 'build', 'bundle-loader-worker.js'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(bundleDir, { recursive: true }); - -await writeBundleFile({ - bundlePath, - format: 'json', - bundle: { - file: relFile, - chunks: [{ - id: 0, - file: relFile, - start: 0, - end: 10, - tokens: ['alpha'], - metaV2: { - chunkId: 'chunk:0', - file: relFile, - range: { start: 0, end: 10 }, - lang: 'javascript', - ext: '.js', - relations: { calls: [{ targetChunkId: 'old' }] }, - segment: null - } - }] - } -}); - -await writeBundlePatch({ - bundlePath, - format: 'json', - previousBundle: { - file: relFile, - chunks: [{ - id: 0, - file: relFile, - start: 0, - end: 10, - tokens: ['alpha'], - metaV2: { - chunkId: 'chunk:0', - file: relFile, - range: { start: 0, end: 10 }, - lang: 'javascript', - ext: '.js', - relations: { calls: [{ targetChunkId: 'old' }] }, - segment: null - } - }] - }, - nextBundle: { - file: relFile, - chunks: [{ - id: 0, - file: relFile, - start: 0, - end: 10, - tokens: ['alpha'], - metaV2: { - chunkId: 'chunk:0', - file: relFile, - range: { start: 0, end: 10 }, - lang: 'javascript', - ext: '.js', - relations: { calls: [{ targetChunkId: 'new' }] }, - segment: null - } - }] - } -}); - -const loader = createBundleLoader({ bundleThreads: 2, workerPath }); -try { - const loaded = await loader.loadBundle({ - bundleDir, - file: relFile, - entry: { bundle: bundleName } - }); - assert.equal(loaded.ok, true, `expected bundle loader success, got: ${loaded.reason || 'unknown'}`); - const targetChunkId = loaded.bundle?.chunks?.[0]?.metaV2?.relations?.calls?.[0]?.targetChunkId || null; - assert.equal(targetChunkId, 'new', 'expected worker loader to apply bundle patch sidecar'); -} finally { - await loader.close(); - await fsPromises.rm(tempRoot, { recursive: true, force: true }); -} - -console.log('sqlite bundle loader worker patch parity ok'); diff --git a/tests/storage/sqlite/sqlite-bundle-metav2-docid-parity.test.js b/tests/storage/sqlite/sqlite-bundle-metav2-docid-parity.test.js deleted file mode 100644 index 85ab45547..000000000 --- a/tests/storage/sqlite/sqlite-bundle-metav2-docid-parity.test.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; - -import { writeBundleFile } from '../../../src/shared/bundle-io.js'; -import { validateSqliteMetaV2Parity } from '../../../src/index/validate/checks.js'; -import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite bundle parity tests.'); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-metav2-docid-parity'); -const bundleDir = path.join(tempRoot, 'bundles'); -const dbPath = path.join(tempRoot, 'index-code.db'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(bundleDir, { recursive: true }); - -const fileA = 'a/FileA.swift'; -const fileB = 'b/FileB.swift'; -const bundleA = 'bundle-a.json'; -const bundleB = 'bundle-b.json'; - -const chunkMetaB = { - chunkId: 'chunk-b', - file: fileB, - range: { start: 20, end: 40 }, - lang: 'swift', - ext: '.swift', - relations: { calls: [{ targetChunkId: 'callee-b' }] }, - segment: { segmentId: 'seg-b', segmentUid: 'seguid-b', virtualPath: `vfs://${fileB}` } -}; -const chunkMetaA = { - chunkId: 'chunk-a', - file: fileA, - range: { start: 1, end: 19 }, - lang: 'swift', - ext: '.swift', - relations: { calls: [{ targetChunkId: 'callee-a' }] }, - segment: { segmentId: 'seg-a', segmentUid: 'seguid-a', virtualPath: `vfs://${fileA}` } -}; - -await writeBundleFile({ - bundlePath: path.join(bundleDir, bundleA), - format: 'json', - bundle: { - file: fileA, - chunks: [{ - id: 1, - file: fileA, - start: 1, - end: 19, - tokens: ['alpha'], - chunkId: 'chunk-a', - metaV2: chunkMetaA - }] - } -}); -await writeBundleFile({ - bundlePath: path.join(bundleDir, bundleB), - format: 'json', - bundle: { - file: fileB, - chunks: [{ - id: 0, - file: fileB, - start: 20, - end: 40, - tokens: ['beta'], - chunkId: 'chunk-b', - metaV2: chunkMetaB - }] - } -}); - -const manifest = { - files: { - [fileA]: { bundle: bundleA, mtimeMs: 10, size: 10, hash: 'hash-a' }, - [fileB]: { bundle: bundleB, mtimeMs: 20, size: 20, hash: 'hash-b' } - } -}; - -const result = await buildDatabaseFromBundles({ - Database, - outPath: dbPath, - mode: 'code', - incrementalData: { manifest, bundleDir }, - envConfig: { bundleThreads: 1 }, - threadLimits: { fileConcurrency: 1 }, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - workerPath: null -}); - -assert.equal(result.count, 2, `expected 2 indexed chunks, got ${result.count}`); - -const db = new Database(dbPath, { readonly: true }); -const rows = db - .prepare('SELECT id, chunk_id, metaV2_json FROM chunks WHERE mode = ? ORDER BY id') - .all('code'); -db.close(); - -const report = { issues: [], warnings: [], hints: [] }; -const chunkMeta = [ - { id: 0, metaV2: chunkMetaB }, - { id: 1, metaV2: chunkMetaA } -]; -validateSqliteMetaV2Parity(report, 'code', chunkMeta, rows, { maxErrors: 10 }); - -assert.equal(report.issues.length, 0, `expected no sqlite metaV2 parity issues: ${report.issues.join(', ')}`); -assert.deepEqual( - rows.map((row) => row.id), - [0, 1], - `expected sqlite chunk ids [0,1], got ${rows.map((row) => row.id).join(',')}` -); - -console.log('sqlite bundle metaV2 docId parity test passed'); diff --git a/tests/storage/sqlite/sqlite-bundle-metav2-fallback-order-parity.test.js b/tests/storage/sqlite/sqlite-bundle-metav2-fallback-order-parity.test.js deleted file mode 100644 index c29586361..000000000 --- a/tests/storage/sqlite/sqlite-bundle-metav2-fallback-order-parity.test.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; - -import { writeBundleFile } from '../../../src/shared/bundle-io.js'; -import { validateSqliteMetaV2Parity } from '../../../src/index/validate/checks.js'; -import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite bundle parity tests.'); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-metav2-fallback-order-parity'); -const bundleDir = path.join(tempRoot, 'bundles'); -const dbPath = path.join(tempRoot, 'index-prose.db'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(bundleDir, { recursive: true }); - -const firstFile = 'z/README.md'; -const secondFile = 'a/README.md'; -const firstBundleName = 'bundle-z.json'; -const secondBundleName = 'bundle-a.json'; - -const firstMeta = { - chunkId: 'chunk-z', - file: firstFile, - range: { start: 1, end: 10 }, - lang: 'markdown', - ext: '.md', - relations: null, - segment: null -}; -const secondMeta = { - chunkId: 'chunk-a', - file: secondFile, - range: { start: 11, end: 20 }, - lang: 'markdown', - ext: '.md', - relations: null, - segment: null -}; - -await writeBundleFile({ - bundlePath: path.join(bundleDir, firstBundleName), - format: 'json', - bundle: { - file: firstFile, - chunks: [{ - file: firstFile, - start: 1, - end: 10, - ext: '.md', - tokens: ['alpha'], - chunkId: firstMeta.chunkId, - metaV2: firstMeta - }] - } -}); -await writeBundleFile({ - bundlePath: path.join(bundleDir, secondBundleName), - format: 'json', - bundle: { - file: secondFile, - chunks: [{ - file: secondFile, - start: 11, - end: 20, - ext: '.md', - tokens: ['beta'], - chunkId: secondMeta.chunkId, - metaV2: secondMeta - }] - } -}); - -const manifest = { - files: { - [firstFile]: { bundle: firstBundleName, mtimeMs: 10, size: 10, hash: 'hash-z' }, - [secondFile]: { bundle: secondBundleName, mtimeMs: 20, size: 20, hash: 'hash-a' } - } -}; - -const result = await buildDatabaseFromBundles({ - Database, - outPath: dbPath, - mode: 'prose', - incrementalData: { manifest, bundleDir }, - envConfig: { bundleThreads: 1 }, - threadLimits: { fileConcurrency: 1 }, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - workerPath: null -}); - -assert.equal(result.count, 2, `expected 2 indexed chunks, got ${result.count}`); - -const db = new Database(dbPath, { readonly: true }); -const rows = db - .prepare('SELECT id, chunk_id, metaV2_json FROM chunks WHERE mode = ? ORDER BY id') - .all('prose'); -db.close(); - -const report = { issues: [], warnings: [], hints: [] }; -const chunkMeta = [ - { id: 0, metaV2: firstMeta }, - { id: 1, metaV2: secondMeta } -]; -validateSqliteMetaV2Parity(report, 'prose', chunkMeta, rows, { maxErrors: 10 }); - -assert.equal(report.issues.length, 0, `expected no sqlite metaV2 parity issues: ${report.issues.join(', ')}`); -assert.deepEqual( - rows.map((row) => row.chunk_id), - ['chunk-z', 'chunk-a'], - `expected sqlite chunk_id order chunk-z,chunk-a, got ${rows.map((row) => row.chunk_id).join(',')}` -); - -console.log('sqlite bundle fallback-order metaV2 parity test passed'); diff --git a/tests/storage/sqlite/sqlite-bundle-missing.test.js b/tests/storage/sqlite/sqlite-bundle-missing.test.js deleted file mode 100644 index bea2bdf77..000000000 --- a/tests/storage/sqlite/sqlite-bundle-missing.test.js +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCombinedOutput } from '../../helpers/stdio.js'; -import { getRepoCacheRoot, loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-missing'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const run = (args, label, options = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - encoding: 'utf8', - ...options - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result; -}; - -run([ - path.join(root, 'build_index.js'), - '--incremental', - '--stub-embeddings', - '--stage', - 'stage2', - '--scm-provider', - 'none', - '--mode', - 'code', - '--repo', - repoRoot -], 'build index'); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); -const bundleDir = path.join(repoCacheRoot, 'incremental', 'code', 'files'); -if (!fs.existsSync(manifestPath)) { - console.error('Missing incremental manifest for sqlite bundle test.'); - process.exit(1); -} -const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); -const manifestFiles = Object.values(manifest.files || {}); -if (!manifestFiles.length) { - console.error('Incremental manifest contains no files.'); - process.exit(1); -} -const bundleName = manifestFiles[0]?.bundle; -if (!bundleName) { - console.error('Manifest entry missing bundle name.'); - process.exit(1); -} -const bundlePath = path.join(bundleDir, bundleName); -if (!fs.existsSync(bundlePath)) { - console.error(`Expected bundle file missing: ${bundlePath}`); - process.exit(1); -} -await fsPromises.rm(bundlePath, { force: true }); - -const sqliteLogs = []; -try { - await runSqliteBuild(repoRoot, { - mode: 'code', - incremental: true, - logger: { - log: (message) => sqliteLogs.push(message), - warn: (message) => sqliteLogs.push(message), - error: (message) => sqliteLogs.push(message) - } - }); -} catch (err) { - console.error('sqlite build failed for missing bundle test.'); - if (err?.message) console.error(err.message); - process.exit(1); -} -const output = getCombinedOutput({ stdout: sqliteLogs.join('\n'), stderr: '' }); -const outputLower = output.toLowerCase(); -if ( - !outputLower.includes('incremental bundles unavailable') - && !outputLower.includes('falling back to artifacts') - && !outputLower.includes('incremental update skipped') - && !outputLower.includes('bundle missing') - && !outputLower.includes('bundle file missing') - && !outputLower.includes('bundle build failed') -) { - console.error('Expected bundle fallback warning not found in output.'); - process.exit(1); -} - -const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -if (!fs.existsSync(sqlitePaths.codePath)) { - console.error(`Missing sqlite db after fallback: ${sqlitePaths.codePath}`); - process.exit(1); -} - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite bundle test.'); - process.exit(1); -} -const db = new Database(sqlitePaths.codePath, { readonly: true }); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -db.close(); -if (!Number(row?.total)) { - console.error('Expected sqlite index to contain chunks after fallback rebuild.'); - process.exit(1); -} - -console.log('sqlite bundle missing fallback test passed'); - diff --git a/tests/storage/sqlite/sqlite-bundle-worker-autotune.test.js b/tests/storage/sqlite/sqlite-bundle-worker-autotune.test.js deleted file mode 100644 index 157a229fb..000000000 --- a/tests/storage/sqlite/sqlite-bundle-worker-autotune.test.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { sqliteBuildRunnerInternals } from '../../../src/storage/sqlite/build/runner.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const { - resolveSqliteBundleWorkerProfilePath, - resolveBundleWorkerAutotune -} = sqliteBuildRunnerInternals; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-bundle-worker-autotune'); -const bundleDir = path.join(tempRoot, 'bundles'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const expectedProfilePath = path.join(tempRoot, 'sqlite', 'bundle-worker-autotune.json'); -assert.equal( - resolveSqliteBundleWorkerProfilePath(tempRoot), - expectedProfilePath, - 'expected bundle worker profile path under repo cache sqlite dir' -); - -const manifestFiles = {}; -for (let i = 0; i < 48; i += 1) { - const bundleName = `bundle-${i}.json`; - manifestFiles[`src/file-${i}.js`] = { bundle: bundleName }; - await fs.writeFile(path.join(bundleDir, bundleName), 'x'.repeat(1024), 'utf8'); -} - -const codeTuned = resolveBundleWorkerAutotune({ - mode: 'code', - manifestFiles, - bundleDir, - threadLimits: { fileConcurrency: 12 }, - envConfig: {}, - profile: { modes: {} } -}); -assert.equal(codeTuned.reason, 'autotune', 'expected autotune mode without explicit override'); -assert.ok(codeTuned.threads >= 1, 'expected positive autotuned worker count'); -assert.ok(codeTuned.threads <= 12, 'expected autotuned workers to respect thread limits'); - -const recordsTuned = resolveBundleWorkerAutotune({ - mode: 'records', - manifestFiles, - bundleDir, - threadLimits: { fileConcurrency: 12 }, - envConfig: {}, - profile: { modes: {} } -}); -assert.ok( - recordsTuned.threads <= codeTuned.threads, - 'expected records mode to use equal-or-lower bundle thread fanout' -); - -const explicit = resolveBundleWorkerAutotune({ - mode: 'code', - manifestFiles, - bundleDir, - threadLimits: { fileConcurrency: 8 }, - envConfig: { bundleThreads: 3 }, - profile: { modes: {} } -}); -assert.equal(explicit.reason, 'explicit-env', 'expected explicit env override reason'); -assert.equal(explicit.threads, 3, 'expected explicit bundle thread count to apply'); - -const lowCountManifest = { - 'src/a.js': { bundle: 'a.json' }, - 'src/b.js': { bundle: 'b.json' } -}; -await fs.writeFile(path.join(bundleDir, 'a.json'), 'x'.repeat(256), 'utf8'); -await fs.writeFile(path.join(bundleDir, 'b.json'), 'x'.repeat(256), 'utf8'); - -const converged = resolveBundleWorkerAutotune({ - mode: 'code', - manifestFiles: lowCountManifest, - bundleDir, - threadLimits: { fileConcurrency: 16 }, - envConfig: {}, - profile: { modes: { code: { threads: 10 } } } -}); -assert.equal( - converged.threads, - 9, - 'expected convergence guard to step previous thread count by at most one' -); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('sqlite bundle worker autotune test passed'); diff --git a/tests/storage/sqlite/sqlite-chunk-meta-json-gzip-fallback.test.js b/tests/storage/sqlite/sqlite-chunk-meta-json-gzip-fallback.test.js deleted file mode 100644 index 346c12a9e..000000000 --- a/tests/storage/sqlite/sqlite-chunk-meta-json-gzip-fallback.test.js +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { gzipSync } from 'node:zlib'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); -requireOrSkip({ capability: 'sqlite', reason: 'sqlite chunk_meta gzip fallback test requires better-sqlite3' }); - -let Database = null; -({ default: Database } = await import('better-sqlite3')); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-json-gzip-fallback'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 24; -const chunks = Array.from({ length: chunkCount }, (_, i) => ({ - id: i, - file: `src/file-${i % 4}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens: ['alpha', 'beta'] -})); - -await fs.writeFile( - path.join(indexDir, 'chunk_meta.json.gz'), - gzipSync(Buffer.from(JSON.stringify(chunks), 'utf8')) -); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { - fields: { - avgDocLen: 2, - totalDocs: chunkCount - }, - arrays: { - vocab: ['alpha', 'beta'], - postings: [ - Array.from({ length: chunkCount }, (_, docId) => [docId, 1]), - Array.from({ length: chunkCount }, (_, docId) => [docId, 1]) - ], - docLengths: Array.from({ length: chunkCount }, () => 2) - }, - atomic: true -}); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta.json.gz'); -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null } -}); -assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks from gzip JSON artifact'); - -const db = new Database(outPath); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match gzip chunk count'); -db.close(); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite chunk_meta json gzip fallback test passed'); diff --git a/tests/storage/sqlite/sqlite-chunk-meta-meta-fallback.test.js b/tests/storage/sqlite/sqlite-chunk-meta-meta-fallback.test.js deleted file mode 100644 index ea2e51cf7..000000000 --- a/tests/storage/sqlite/sqlite-chunk-meta-meta-fallback.test.js +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-meta-fallback'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 200; -const tokens = ['alpha', 'beta']; -const chunks = Array.from({ length: chunkCount }, (_, i) => ({ - id: i, - file: `src/file-${i % 10}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens -})); - -await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), chunks, { atomic: true }); -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: chunkCount, - totalBytes: 0, - maxPartRecords: chunkCount, - maxPartBytes: 0, - targetMaxBytes: 0 - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); -const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); -await writeJsonObjectFile(postingsPart, { - arrays: { - vocab: ['alpha'], - postings: [postingsEntries] - }, - atomic: true -}); -const docLengths = Array.from({ length: chunkCount }, () => tokens.length); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 1, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - { name: 'chunk_meta', path: 'chunk_meta.jsonl', format: 'jsonl' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect fallback chunk_meta.jsonl'); -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null } -}); -assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); - -const db = new Database(outPath); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); -db.close(); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite chunk_meta meta fallback test passed'); diff --git a/tests/storage/sqlite/sqlite-chunk-meta-streaming.test.js b/tests/storage/sqlite/sqlite-chunk-meta-streaming.test.js deleted file mode 100644 index d47e6751c..000000000 --- a/tests/storage/sqlite/sqlite-chunk-meta-streaming.test.js +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-chunk-meta-streaming'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 5000; -const tokens = ['alpha', 'beta']; -const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 10}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens - }; - } -}; - -const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 8192, - atomic: true -}); -if (shardResult.parts.length < 2) { - console.error('Expected chunk_meta to be sharded for streaming test.'); - process.exit(1); -} -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); -const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); -await writeJsonObjectFile(postingsPart, { - arrays: { - vocab: ['alpha'], - postings: [postingsEntries] - }, - atomic: true -}); -const docLengths = Array.from({ length: chunkCount }, () => tokens.length); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 1, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); -const sqliteStats = {}; -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - stats: sqliteStats -}); -assert.equal(count, chunkCount, 'expected sqlite build to ingest all chunks'); -assert.ok(sqliteStats.chunkMeta, 'expected sqlite stats to include chunkMeta metrics'); -assert.equal(sqliteStats.chunkMeta.passes, 1, 'expected single consolidated chunk_meta ingest pass'); -assert.equal(sqliteStats.chunkMeta.rows, chunkCount, 'expected chunkMeta rows metric to match ingested chunks'); -assert.equal( - sqliteStats.chunkMeta.streamedRows, - chunkCount, - 'expected sharded/jsonl chunk_meta rows to be counted as streamed' -); -assert.equal( - sqliteStats.chunkMeta.sourceRows?.jsonl, - chunkCount, - 'expected sourceRows.jsonl to match chunk count' -); -assert.ok( - Number(sqliteStats.chunkMeta.sourceFiles?.jsonl) >= 2, - 'expected sourceFiles.jsonl to include multiple shard files' -); -assert.equal( - sqliteStats.chunkMeta.tokenTextMaterialized, - chunkCount, - 'expected token text materialization count for populated token arrays' -); -assert.equal( - sqliteStats.chunkMeta.tokenTextSkipped, - 0, - 'expected no token text skips when all chunks include tokens' -); - -const db = new Database(outPath); -const row = db.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code'); -assert.equal(row?.total, chunkCount, 'expected sqlite chunks table to match chunk_meta count'); -db.close(); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite chunk_meta streaming test passed'); - diff --git a/tests/storage/sqlite/sqlite-compact.test.js b/tests/storage/sqlite/sqlite-compact.test.js deleted file mode 100644 index 4f1e800a5..000000000 --- a/tests/storage/sqlite/sqlite-compact.test.js +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-compact'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const deletableFile = path.join(repoRoot, 'src', 'deletable.js'); -const renameFile = path.join(repoRoot, 'src', 'rename_me.js'); -await fsPromises.writeFile( - deletableFile, - 'export const xqzflorb = "xqzflorb";\n' -); -await fsPromises.writeFile( - renameFile, - 'export function renameToken() { return "renametoken"; }\n' -); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -function run(args, label) { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -} - -run([path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], 'build index'); -await runSqliteBuild(repoRoot); - -const renamedFile = path.join(repoRoot, 'src', 'renamed.js'); -await fsPromises.rm(deletableFile, { force: true }); -await fsPromises.rename(renameFile, renamedFile); - -run([path.join(root, 'build_index.js'), '--incremental', '--stub-embeddings', '--repo', repoRoot], 'build index (incremental)'); -await runSqliteBuild(repoRoot, { incremental: true }); -run([path.join(root, 'tools', 'build', 'compact-sqlite-index.js'), '--repo', repoRoot], 'compact sqlite index'); - -const userConfig = loadUserConfig(repoRoot); -const sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.error('better-sqlite3 is required for sqlite-compact test.'); - process.exit(1); -} - -const db = new Database(sqlitePaths.codePath, { readonly: true }); -const stats = db.prepare('SELECT COUNT(*) AS total, MAX(id) AS maxId FROM chunks WHERE mode = ?').get('code') || {}; -const total = Number(stats.total) || 0; -const maxId = Number(stats.maxId); -if (total && maxId !== total - 1) { - console.error(`Compaction failed: expected maxId=${total - 1} got ${maxId}`); - process.exit(1); -} - -const oldFile = db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE mode = ? AND file = ?').get('code', 'src/rename_me.js'); -if (oldFile?.count) { - console.error('Compaction failed: old file name still present.'); - process.exit(1); -} - -const manifestOld = db.prepare('SELECT COUNT(*) AS count FROM file_manifest WHERE mode = ? AND file = ?').get('code', 'src/rename_me.js'); -if (manifestOld?.count) { - console.error('Compaction failed: old file name still in file_manifest.'); - process.exit(1); -} - -const manifestNew = db.prepare('SELECT COUNT(*) AS count FROM file_manifest WHERE mode = ? AND file = ?').get('code', 'src/renamed.js'); -if (!manifestNew?.count) { - console.error('Compaction failed: renamed file missing from file_manifest.'); - process.exit(1); -} - -const vocabHit = db.prepare('SELECT COUNT(*) AS count FROM token_vocab WHERE mode = ? AND token = ?').get('code', 'xqzflorb'); -if (vocabHit?.count) { - console.error('Compaction failed: deleted token still in vocab.'); - process.exit(1); -} - -db.close(); -console.log('SQLite compaction test passed'); - diff --git a/tests/storage/sqlite/sqlite-fts-contentless-schema.test.js b/tests/storage/sqlite/sqlite-fts-contentless-schema.test.js deleted file mode 100644 index 6c545ac36..000000000 --- a/tests/storage/sqlite/sqlite-fts-contentless-schema.test.js +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv({ testing: '1' }); - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-fts-contentless-schema'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 50; -const tokens = ['hello', 'world']; -const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 3}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens, - docmeta: { - signature: `sig:${i}`, - doc: `hello world ${i}` - } - }; - } -}; - -const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 4096, - atomic: true -}); -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -const postingsPart = path.join(postingsDir, 'token_postings.part-00000.json'); -const postingsEntries = Array.from({ length: chunkCount }, (_, i) => [i, 1]); -await writeJsonObjectFile(postingsPart, { - arrays: { - vocab: ['hello'], - postings: [postingsEntries] - }, - atomic: true -}); -const docLengths = Array.from({ length: chunkCount }, () => tokens.length); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: tokens.length, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 1, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { docLengths }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta parts'); - -await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - statementStrategy: 'prepared', - optimize: false, - buildPragmas: false -}); - -assert.ok(fsSync.existsSync(outPath), 'expected sqlite DB to be created'); - -const db = new Database(outPath); -const createRow = db - .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chunks_fts'") - .get(); -assert.equal(typeof createRow?.sql, 'string', 'expected sqlite_master SQL for chunks_fts'); -assert.match(createRow.sql, /content\s*=\s*''/i, 'expected chunks_fts to be contentless'); -assert.match(createRow.sql, /contentless_delete\s*=\s*1/i, 'expected contentless_delete=1'); - -const matches = db.prepare('SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?').all('hello'); -assert.ok(matches.length > 0, 'expected FTS MATCH to find inserted rows'); - -const probe = db.prepare('SELECT doc FROM chunks_fts WHERE rowid = ?').get(matches[0].rowid); -assert.equal(probe?.doc, null, 'expected contentless FTS doc column to return null'); - -// Verify incremental delete semantics are supported for contentless FTS. -db.prepare('DELETE FROM chunks_fts WHERE rowid = ?').run(matches[0].rowid); - -db.close(); - -console.log('sqlite fts contentless schema test passed'); - diff --git a/tests/storage/sqlite/sqlite-incremental-memory-profile.test.js b/tests/storage/sqlite/sqlite-incremental-memory-profile.test.js deleted file mode 100644 index f0a264412..000000000 --- a/tests/storage/sqlite/sqlite-incremental-memory-profile.test.js +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeBundleFile } from '../../../src/shared/bundle-io.js'; -import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; -import { incrementalUpdateDatabase } from '../../../src/storage/sqlite/build/incremental-update.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-incremental-memory-profile'); -const bundleDir = path.join(tempRoot, 'bundles'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const files = Array.from({ length: 6 }, (_, i) => `src/file-${i}.js`); -const chunksPerFile = 4; - -const buildChunks = (file, suffix) => { - const chunks = []; - for (let i = 0; i < chunksPerFile; i += 1) { - chunks.push({ - file, - start: i * 10, - end: i * 10 + 5, - startLine: i + 1, - endLine: i + 1, - kind: 'code', - name: `fn${suffix}-${i}`, - tokens: [`tok-${suffix}`, `tok-${i}`] - }); - } - return chunks; -}; - -const manifest = { files: {} }; -for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - const bundleName = `bundle-${i}.json`; - await writeBundleFile({ - bundlePath: path.join(bundleDir, bundleName), - bundle: { chunks: buildChunks(file, `v1-${i}`) }, - format: 'json' - }); - manifest.files[file] = { - hash: `hash-${i}`, - mtimeMs: 1000 + i, - size: 10 + i, - bundle: bundleName - }; -} - -const envConfig = { bundleThreads: 1 }; -const threadLimits = { fileConcurrency: 1 }; -await buildDatabaseFromBundles({ - Database, - outPath, - mode: 'code', - incrementalData: { manifest, bundleDir }, - envConfig, - threadLimits, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null } -}); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created before incremental update.'); - process.exit(1); -} - -const updatedManifest = { files: { ...manifest.files } }; -const changedFile = files[2]; -const changedBundleName = 'bundle-changed.json'; -await writeBundleFile({ - bundlePath: path.join(bundleDir, changedBundleName), - bundle: { chunks: buildChunks(changedFile, 'v2') }, - format: 'json' -}); -updatedManifest.files[changedFile] = { - ...updatedManifest.files[changedFile], - hash: 'hash-changed', - mtimeMs: 9999, - bundle: changedBundleName -}; - -const stats = {}; -const updateResult = await incrementalUpdateDatabase({ - Database, - outPath, - mode: 'code', - incrementalData: { manifest: updatedManifest, bundleDir }, - modelConfig: { id: null }, - vectorConfig: { enabled: false }, - emitOutput: false, - validateMode: 'off', - stats -}); - -if (!updateResult.used) { - console.error(`Incremental update skipped: ${updateResult.reason || 'unknown reason'}`); - process.exit(1); -} -const totalChunks = files.length * chunksPerFile; -assert.equal(stats.existingChunkRows, chunksPerFile, 'expected only changed file chunks to be loaded'); -assert.ok(stats.existingChunkRows < totalChunks, 'expected subset load of existing chunk ids'); - -console.log('sqlite incremental memory profile test passed'); diff --git a/tests/storage/sqlite/sqlite-incremental-no-change.test.js b/tests/storage/sqlite/sqlite-incremental-no-change.test.js deleted file mode 100644 index cd183db42..000000000 --- a/tests/storage/sqlite/sqlite-incremental-no-change.test.js +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCombinedOutput } from '../../helpers/stdio.js'; -import { - getIndexDir, - getRepoCacheRoot, - loadUserConfig, - resolveSqlitePaths -} from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; -import { rmDirRecursive } from '../../helpers/temp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-incremental-no-change'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -const stripMaxOldSpaceFlag = (options) => { - if (!options) return ''; - return options - .replace(/--max-old-space-size=\d+/g, '') - .replace(/--max-old-space-size\s+\d+/g, '') - .replace(/\s+/g, ' ') - .trim(); -}; - -const nodeOptions = stripMaxOldSpaceFlag(process.env.NODE_OPTIONS || ''); - -await rmDirRecursive(tempRoot, { retries: 8, delayMs: 150 }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - extraEnv: { - PAIROFCLEATS_WORKER_POOL: 'off', - PAIROFCLEATS_MAX_OLD_SPACE_MB: '4096' - } -}); -if (nodeOptions) { - env.NODE_OPTIONS = nodeOptions; -} else { - delete env.NODE_OPTIONS; -} -function run(args, label) { - const result = spawnSync(process.execPath, args, { - cwd: repoRoot, - env, - stdio: 'inherit' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -} - -run([ - path.join(root, 'build_index.js'), - '--incremental', - '--stub-embeddings', - '--scm-provider', - 'none', - '--stage', - 'stage2', - '--no-sqlite', - '--mode', - 'code', - '--repo', - repoRoot -], 'build code index'); -const initialLogs = []; -await runSqliteBuild(repoRoot, { - mode: 'code', - logger: { - log: (message) => initialLogs.push(message), - warn: (message) => initialLogs.push(message), - error: (message) => initialLogs.push(message) - } -}); -getCombinedOutput({ stdout: initialLogs.join('\n'), stderr: '' }); - -const userConfig = loadUserConfig(repoRoot); -let sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); - -let Database; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error('better-sqlite3 is required for sqlite incremental no-change test.'); - process.exit(1); -} - -const dbBefore = new Database(sqlitePaths.codePath, { readonly: true }); -const beforeCounts = { - chunks: dbBefore.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code').total, - files: dbBefore.prepare('SELECT COUNT(*) AS total FROM file_manifest WHERE mode = ?').get('code').total, - hash: (dbBefore.prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', 'src/index.js') || {}).hash || null -}; -dbBefore.close(); -const codeIndexDir = getIndexDir(repoRoot, 'code', userConfig); -const statePath = path.join(codeIndexDir, 'index_state.json'); -const stateBefore = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); - -const noChangeLogs = []; -await runSqliteBuild(repoRoot, { - mode: 'code', - incremental: true, - logger: { - log: (message) => noChangeLogs.push(message), - warn: (message) => noChangeLogs.push(message), - error: (message) => noChangeLogs.push(message) - } -}); -const noChangeOutput = getCombinedOutput({ stdout: noChangeLogs.join('\n'), stderr: '' }); -if (!noChangeOutput.toLowerCase().includes('incremental update applied')) { - console.error('Expected incremental sqlite update output for no-change run.'); - process.exit(1); -} - -sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -const dbAfter = new Database(sqlitePaths.codePath, { readonly: true }); -const afterCounts = { - chunks: dbAfter.prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?').get('code').total, - files: dbAfter.prepare('SELECT COUNT(*) AS total FROM file_manifest WHERE mode = ?').get('code').total, - hash: (dbAfter.prepare('SELECT hash FROM file_manifest WHERE mode = ? AND file = ?') - .get('code', 'src/index.js') || {}).hash || null -}; -dbAfter.close(); -const stateAfter = JSON.parse(await fsPromises.readFile(statePath, 'utf8')); -if (stateBefore?.sqlite) { - assert.equal(stateAfter.sqlite?.ready, stateBefore.sqlite.ready, 'expected sqlite ready to remain stable'); - assert.equal(stateAfter.sqlite?.pending, stateBefore.sqlite.pending, 'expected sqlite pending to remain stable'); -} - -assert.equal(afterCounts.chunks, beforeCounts.chunks, 'expected chunk counts to remain stable'); -assert.equal(afterCounts.files, beforeCounts.files, 'expected file manifest counts to remain stable'); -assert.equal(afterCounts.hash, beforeCounts.hash, 'expected file manifest hash to remain stable'); - -const triageFixturePath = path.join(root, 'tests', 'fixtures', 'triage', 'generic.json'); -run([ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', - 'generic', - '--in', - triageFixturePath, - '--repo', - repoRoot, - '--meta', - 'service=api', - '--meta', - 'env=prod' -], 'ingest generic records'); -run([ - path.join(root, 'build_index.js'), - '--incremental', - '--stub-embeddings', - '--scm-provider', - 'none', - '--stage', - 'stage2', - '--no-sqlite', - '--mode', - 'records', - '--repo', - repoRoot -], 'build records index'); - -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const recordsManifestPath = path.join(repoCacheRoot, 'incremental', 'records', 'manifest.json'); -const recordsManifest = JSON.parse(await fsPromises.readFile(recordsManifestPath, 'utf8')); -assert.equal( - recordsManifest.bundleRecordsIncremental, - true, - 'expected records incremental manifest capability bit' -); -assert.ok( - Object.keys(recordsManifest.files || {}).length > 0, - 'expected non-empty records incremental manifest' -); - -const recordsInitialLogs = []; -await runSqliteBuild(repoRoot, { - mode: 'records', - incremental: true, - logger: { - log: (message) => recordsInitialLogs.push(message), - warn: (message) => recordsInitialLogs.push(message), - error: (message) => recordsInitialLogs.push(message) - } -}); -const recordsInitialOutput = getCombinedOutput({ stdout: recordsInitialLogs.join('\n'), stderr: '' }); -const recordsInitialOutputLower = recordsInitialOutput.toLowerCase(); -if (!recordsInitialOutput.includes('Using incremental bundles for records')) { - console.error('Expected first records sqlite build to use incremental bundles.'); - process.exit(1); -} -if ( - recordsInitialOutputLower.includes('incremental bundles skipped for records') - || recordsInitialOutputLower.includes('using artifacts') -) { - console.error('Did not expect records sqlite incremental bundle fallback on supported manifest.'); - process.exit(1); -} - -sqlitePaths = resolveSqlitePaths(repoRoot, userConfig, { mode: 'records' }); -const recordsDbBefore = new Database(sqlitePaths.recordsPath, { readonly: true }); -const recordsBefore = recordsDbBefore - .prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?') - .get('records').total; -recordsDbBefore.close(); -assert.ok(recordsBefore > 0, 'expected records sqlite build to contain records chunks'); - -const recordsNoChangeLogs = []; -await runSqliteBuild(repoRoot, { - mode: 'records', - incremental: true, - logger: { - log: (message) => recordsNoChangeLogs.push(message), - warn: (message) => recordsNoChangeLogs.push(message), - error: (message) => recordsNoChangeLogs.push(message) - } -}); -const recordsNoChangeOutput = getCombinedOutput({ stdout: recordsNoChangeLogs.join('\n'), stderr: '' }); -const recordsNoChangeOutputLower = recordsNoChangeOutput.toLowerCase(); -if (!recordsNoChangeOutputLower.includes('incremental update applied')) { - console.error('Expected records no-change sqlite build to use incremental update.'); - process.exit(1); -} -if ( - recordsNoChangeOutputLower.includes('incremental bundles skipped for records') - || recordsNoChangeOutputLower.includes('using artifacts') -) { - console.error('Did not expect records no-change sqlite build fallback on supported manifest.'); - process.exit(1); -} -const recordsDbAfter = new Database(sqlitePaths.recordsPath, { readonly: true }); -const recordsAfter = recordsDbAfter - .prepare('SELECT COUNT(*) AS total FROM chunks WHERE mode = ?') - .get('records').total; -recordsDbAfter.close(); -assert.equal(recordsAfter, recordsBefore, 'expected records chunk counts to remain stable'); - -console.log('sqlite incremental no-change test passed'); - - diff --git a/tests/storage/sqlite/sqlite-incremental-transaction-boundary.test.js b/tests/storage/sqlite/sqlite-incremental-transaction-boundary.test.js deleted file mode 100644 index 1dc1ce78d..000000000 --- a/tests/storage/sqlite/sqlite-incremental-transaction-boundary.test.js +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeBundleFile } from '../../../src/shared/bundle-io.js'; -import { buildDatabaseFromBundles } from '../../../src/storage/sqlite/build/from-bundles.js'; -import { incrementalUpdateDatabase } from '../../../src/storage/sqlite/build/incremental-update.js'; -import { resolveSqliteBatchSize, resolveSqliteIngestPlan } from '../../../src/storage/sqlite/utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-incremental-transaction-boundary'); -const bundleDir = path.join(tempRoot, 'bundles'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(bundleDir, { recursive: true }); - -const files = Array.from({ length: 4 }, (_, i) => `src/file-${i}.js`); -const chunksPerFile = 3; - -const buildChunks = (file, suffix) => { - const chunks = []; - for (let i = 0; i < chunksPerFile; i += 1) { - chunks.push({ - file, - start: i * 10, - end: i * 10 + 5, - startLine: i + 1, - endLine: i + 1, - kind: 'code', - name: `fn${suffix}-${i}`, - tokens: [`tok-${suffix}`, `tok-${i}`] - }); - } - return chunks; -}; - -const manifest = { files: {} }; -for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - const bundleName = `bundle-${i}.json`; - await writeBundleFile({ - bundlePath: path.join(bundleDir, bundleName), - bundle: { chunks: buildChunks(file, `v1-${i}`) }, - format: 'json' - }); - manifest.files[file] = { - hash: `hash-${i}`, - mtimeMs: 1000 + i, - size: 10 + i, - bundle: bundleName - }; -} - -const envConfig = { bundleThreads: 1 }; -const threadLimits = { fileConcurrency: 1 }; -await buildDatabaseFromBundles({ - Database, - outPath, - mode: 'code', - incrementalData: { manifest, bundleDir }, - envConfig, - threadLimits, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null } -}); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created before incremental update.'); - process.exit(1); -} - -const updatedManifest = { files: { ...manifest.files } }; -const changedFile = files[1]; -const changedBundleName = 'bundle-changed.json'; -await writeBundleFile({ - bundlePath: path.join(bundleDir, changedBundleName), - bundle: { chunks: buildChunks(changedFile, 'v2') }, - format: 'json' -}); -updatedManifest.files[changedFile] = { - ...updatedManifest.files[changedFile], - hash: 'hash-changed', - mtimeMs: 9999, - bundle: changedBundleName -}; - -const probeSqliteRuntime = (dbPath) => { - const runtime = { - pageSize: 4096, - journalMode: null, - walEnabled: false, - walBytes: 0, - dbBytes: 0 - }; - try { - runtime.dbBytes = Number(fsSync.statSync(dbPath).size) || 0; - } catch {} - try { - runtime.walBytes = Number(fsSync.statSync(`${dbPath}-wal`).size) || 0; - } catch {} - let db = null; - try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - const pageSize = Number(db.pragma('page_size', { simple: true })); - if (Number.isFinite(pageSize) && pageSize > 0) { - runtime.pageSize = Math.max(512, Math.floor(pageSize)); - } - const journalModeRaw = db.pragma('journal_mode', { simple: true }); - runtime.journalMode = typeof journalModeRaw === 'string' - ? journalModeRaw.trim().toLowerCase() - : null; - runtime.walEnabled = runtime.journalMode === 'wal' || runtime.walBytes > 0; - } catch { - runtime.walEnabled = runtime.walBytes > 0; - } finally { - try { db?.close(); } catch {} - } - return runtime; -}; - -const runtime = probeSqliteRuntime(outPath); -const adaptiveBatchConfig = { - requested: null, - pageSize: runtime.pageSize, - journalMode: runtime.journalMode, - walEnabled: true, - walBytes: Math.max(runtime.walBytes, 32 * 1024 * 1024), - rowCount: files.length * chunksPerFile, - fileCount: files.length, - inputBytes: runtime.dbBytes, - repoBytes: runtime.dbBytes -}; -const adaptivePlan = resolveSqliteIngestPlan({ batchSize: adaptiveBatchConfig }); -assert.equal( - adaptivePlan.batchSize, - resolveSqliteBatchSize({ batchSize: adaptiveBatchConfig }), - 'expected adaptive plan batch size to match resolveSqliteBatchSize' -); -const smallRepoPlan = resolveSqliteIngestPlan({ - rowCount: chunksPerFile, - fileCount: 1, - pageSize: runtime.pageSize, - walEnabled: false, - walBytes: 0 -}); -assert.ok( - adaptivePlan.transactionRows <= smallRepoPlan.transactionRows, - 'expected larger repo hints to reduce transaction row boundaries' -); -assert.ok(adaptivePlan.filesPerTransaction >= 1, 'expected adaptive filesPerTransaction to be set'); - -const stats = {}; -const updateResult = await incrementalUpdateDatabase({ - Database, - outPath, - mode: 'code', - incrementalData: { manifest: updatedManifest, bundleDir }, - modelConfig: { id: null }, - vectorConfig: { enabled: false }, - emitOutput: false, - validateMode: 'off', - inputBytes: runtime.dbBytes, - batchSize: adaptiveBatchConfig, - stats -}); - -if (!updateResult.used) { - console.error(`Incremental update skipped: ${updateResult.reason || 'unknown reason'}`); - process.exit(1); -} -assert.equal(stats.batchSize, adaptivePlan.batchSize, 'expected adaptive batch size to flow into incremental update stats'); -assert.ok(stats.transactionPhases?.deletes, 'expected delete transaction phase to run'); -assert.ok(stats.transactionPhases?.inserts, 'expected insert transaction phase to run'); - -console.log('sqlite incremental transaction boundary test passed'); diff --git a/tests/storage/sqlite/sqlite-index-state-fail-closed.test.js b/tests/storage/sqlite/sqlite-index-state-fail-closed.test.js deleted file mode 100644 index cddd2af52..000000000 --- a/tests/storage/sqlite/sqlite-index-state-fail-closed.test.js +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, getRepoCacheRoot, loadUserConfig, resolveIndexRoot } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-index-state-fail'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run([ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--mode', - 'code', - '--repo', - repoRoot -], 'build index'); - -const userConfig = loadUserConfig(repoRoot); -const indexRoot = resolveIndexRoot(repoRoot, userConfig); -const codeDir = getIndexDir(repoRoot, 'code', userConfig, { indexRoot }); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const statePath = path.join(codeDir, 'index_state.json'); -if (!fs.existsSync(statePath)) { - console.error('Expected index_state.json after initial build.'); - process.exit(1); -} - -const chunkMetaJson = path.join(codeDir, 'chunk_meta.json'); -const chunkMetaJsonl = path.join(codeDir, 'chunk_meta.jsonl'); -const chunkMetaMeta = path.join(codeDir, 'chunk_meta.meta.json'); -const chunkMetaParts = path.join(codeDir, 'chunk_meta.parts'); -const chunkMetaColumnar = path.join(codeDir, 'chunk_meta.columnar.json'); -const chunkMetaBinaryMeta = path.join(codeDir, 'chunk_meta.binary-columnar.meta.json'); -const chunkMetaBinaryData = path.join(codeDir, 'chunk_meta.binary-columnar.bin'); -const chunkMetaBinaryOffsets = path.join(codeDir, 'chunk_meta.binary-columnar.offsets.bin'); -const chunkMetaBinaryLengths = path.join(codeDir, 'chunk_meta.binary-columnar.lengths.varint'); -const chunkMetaColdJsonl = path.join(codeDir, 'chunk_meta_cold.jsonl'); -const chunkMetaColdMeta = path.join(codeDir, 'chunk_meta_cold.meta.json'); -const chunkMetaColdParts = path.join(codeDir, 'chunk_meta_cold.parts'); -await fsPromises.rm(chunkMetaJson, { force: true }); -await fsPromises.rm(chunkMetaJsonl, { force: true }); -await fsPromises.rm(chunkMetaMeta, { force: true }); -await fsPromises.rm(chunkMetaParts, { recursive: true, force: true }); -await fsPromises.rm(chunkMetaColumnar, { force: true }); -await fsPromises.rm(chunkMetaBinaryMeta, { force: true }); -await fsPromises.rm(chunkMetaBinaryData, { force: true }); -await fsPromises.rm(chunkMetaBinaryOffsets, { force: true }); -await fsPromises.rm(chunkMetaBinaryLengths, { force: true }); -await fsPromises.rm(chunkMetaColdJsonl, { force: true }); -await fsPromises.rm(chunkMetaColdMeta, { force: true }); -await fsPromises.rm(chunkMetaColdParts, { recursive: true, force: true }); -const manifestPath = path.join(repoCacheRoot, 'incremental', 'code', 'manifest.json'); -await fsPromises.rm(manifestPath, { force: true }); - -let sqliteFailed = false; -try { - await runSqliteBuild(repoRoot, { mode: 'code' }); -} catch { - sqliteFailed = true; -} -if (!sqliteFailed) { - console.error('Expected sqlite build to fail with missing artifacts.'); - process.exit(1); -} - -const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); -if (!state?.sqlite) { - console.error('index_state.json missing sqlite section after failure.'); - process.exit(1); -} -if (state.sqlite.status !== 'failed') { - console.error(`Expected sqlite status=failed, got ${state.sqlite.status}`); - process.exit(1); -} - -run([ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--mode', - 'code', - '--repo', - repoRoot -], 'rebuild index'); -await runSqliteBuild(repoRoot, { mode: 'code' }); - -const refreshedIndexRoot = resolveIndexRoot(repoRoot, userConfig); -const refreshedCodeDir = getIndexDir(repoRoot, 'code', userConfig, { indexRoot: refreshedIndexRoot }); -const refreshedStatePath = path.join(refreshedCodeDir, 'index_state.json'); -const stateAfter = JSON.parse(fs.readFileSync(refreshedStatePath, 'utf8')); -if (stateAfter.sqlite?.status !== 'ready') { - console.error(`Expected sqlite status=ready after success, got ${stateAfter.sqlite?.status}`); - process.exit(1); -} - -console.log('sqlite index state fail-closed test passed'); - diff --git a/tests/storage/sqlite/sqlite-missing-dep.test.js b/tests/storage/sqlite/sqlite-missing-dep.test.js deleted file mode 100644 index 3cb81eaff..000000000 --- a/tests/storage/sqlite/sqlite-missing-dep.test.js +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-missing-dep'); -const cacheRoot = path.join(tempRoot, '.cache'); -const searchPath = path.join(root, 'search.js'); -const buildIndexPath = path.join(root, 'build_index.js'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const sampleCode = ` -export function greet(name) { - return "hello " + name; -} -`; -await fsPromises.writeFile(path.join(tempRoot, 'sample.js'), sampleCode); - -const envBase = { - ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -applyTestEnv(); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const run = (args, label, envOverride = {}) => { - const result = spawnSync(process.execPath, args, { - cwd: tempRoot, - env: { ...envBase, ...envOverride }, - encoding: 'utf8' - }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); - } - return result.stdout || ''; -}; - -run([buildIndexPath, '--stub-embeddings', '--repo', tempRoot], 'build index'); -await runSqliteBuild(tempRoot); - -const autoOutput = run( - [searchPath, 'greet', '--json', '--repo', tempRoot], - 'search auto with sqlite disabled', - { NODE_OPTIONS: '--no-addons' } -); -let autoBackend = null; -try { - autoBackend = JSON.parse(autoOutput).backend; -} catch { - console.error('Failed to parse JSON output for auto sqlite fallback.'); - process.exit(1); -} -if (autoBackend !== 'memory') { - console.error(`Expected memory backend with sqlite disabled, got ${autoBackend}`); - process.exit(1); -} - -const forcedResult = spawnSync( - process.execPath, - [searchPath, 'greet', '--json', '--backend', 'sqlite', '--repo', tempRoot], - { - cwd: tempRoot, - env: { ...envBase, NODE_OPTIONS: '--no-addons' }, - encoding: 'utf8' - } -); -if (forcedResult.status === 0) { - console.error('Expected forced sqlite search to fail when sqlite is disabled.'); - process.exit(1); -} -const forcedOutput = String(forcedResult.stdout || '').trim(); -const forcedStderr = String(forcedResult.stderr || '').trim(); -let forcedMessage = ''; -try { - forcedMessage = JSON.parse(forcedOutput)?.message || ''; -} catch { - forcedMessage = forcedStderr; -} -if (!forcedMessage.includes('better-sqlite3 is required')) { - console.error('Expected missing dependency message for forced sqlite backend.'); - if (forcedOutput) console.error(forcedOutput); - if (forcedStderr) console.error(forcedStderr); - process.exit(1); -} - -console.log('SQLite missing dependency test passed'); - diff --git a/tests/storage/sqlite/sqlite-replace-database-fallbacks.test.js b/tests/storage/sqlite/sqlite-replace-database-fallbacks.test.js deleted file mode 100644 index f9ab71971..000000000 --- a/tests/storage/sqlite/sqlite-replace-database-fallbacks.test.js +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { replaceSqliteDatabase } from '../../../src/storage/sqlite/utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'sqlite-replace-database-fallbacks'); -await fsPromises.rm(outDir, { recursive: true, force: true }); -await fsPromises.mkdir(outDir, { recursive: true }); - -const runCrossDeviceFallbackCase = async () => { - const finalPath = path.join(outDir, 'cross-device.sqlite'); - const tempPath = path.join(outDir, 'cross-device.sqlite.tmp'); - - await fsPromises.writeFile(finalPath, 'before', 'utf8'); - await fsPromises.writeFile(tempPath, 'after', 'utf8'); - - const originalRename = fsPromises.rename; - fsPromises.rename = async (from, to) => { - if (from === tempPath && to === finalPath) { - const err = new Error('EXDEV'); - err.code = 'EXDEV'; - throw err; - } - return originalRename(from, to); - }; - - try { - await replaceSqliteDatabase(tempPath, finalPath); - } finally { - fsPromises.rename = originalRename; - } - - const contents = await fsPromises.readFile(finalPath, 'utf8'); - assert.equal(contents, 'after'); - assert.ok(!fs.existsSync(tempPath), 'expected temp sqlite db removed after EXDEV fallback'); -}; - -const runRestoreBackupCase = async () => { - const finalPath = path.join(outDir, 'restore.sqlite'); - const tempPath = path.join(outDir, 'restore.sqlite.tmp'); - const backupPath = `${finalPath}.bak`; - - await fsPromises.writeFile(finalPath, 'before', 'utf8'); - await fsPromises.writeFile(tempPath, 'after', 'utf8'); - - const originalRename = fsPromises.rename; - fsPromises.rename = async (from, to) => { - if (from === finalPath && to === backupPath) { - return originalRename(from, to); - } - if (from === tempPath && to === finalPath) { - const err = new Error('ENOENT'); - err.code = 'ENOENT'; - throw err; - } - return originalRename(from, to); - }; - - let failed = null; - try { - await replaceSqliteDatabase(tempPath, finalPath); - } catch (err) { - failed = err; - } finally { - fsPromises.rename = originalRename; - } - - assert.ok(failed, 'expected sqlite replace to fail when temp promote cannot complete'); - assert.ok(fs.existsSync(finalPath), 'expected original sqlite db restored from backup'); - assert.ok(!fs.existsSync(backupPath), 'expected restore to consume backup when keepBackup=false'); - assert.equal(await fsPromises.readFile(finalPath, 'utf8'), 'before'); - assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); -}; - -const runStaleBackupNoRestoreCase = async () => { - const finalPath = path.join(outDir, 'stale-backup.sqlite'); - const tempPath = path.join(outDir, 'stale-backup.sqlite.tmp'); - const backupPath = `${finalPath}.bak`; - - await fsPromises.writeFile(backupPath, 'stale', 'utf8'); - await fsPromises.writeFile(tempPath, 'after', 'utf8'); - - const originalRename = fsPromises.rename; - fsPromises.rename = async (from, to) => { - if (from === tempPath && to === finalPath) { - const err = new Error('ENOENT'); - err.code = 'ENOENT'; - throw err; - } - return originalRename(from, to); - }; - - let failed = null; - try { - await replaceSqliteDatabase(tempPath, finalPath); - } catch (err) { - failed = err; - } finally { - fsPromises.rename = originalRename; - } - - assert.ok(failed, 'expected sqlite replace to fail when temp promote cannot complete'); - assert.ok(!fs.existsSync(finalPath), 'expected stale backup to remain un-restored when final db did not exist'); - assert.ok(fs.existsSync(backupPath), 'expected stale backup to remain untouched on failure'); - assert.equal(await fsPromises.readFile(backupPath, 'utf8'), 'stale'); - assert.equal(await fsPromises.readFile(tempPath, 'utf8'), 'after'); -}; - -await runCrossDeviceFallbackCase(); -await runRestoreBackupCase(); -await runStaleBackupNoRestoreCase(); - -console.log('sqlite replace database fallback tests passed'); diff --git a/tests/storage/sqlite/sqlite-sidecar-cleanup.test.js b/tests/storage/sqlite/sqlite-sidecar-cleanup.test.js deleted file mode 100644 index db294ba40..000000000 --- a/tests/storage/sqlite/sqlite-sidecar-cleanup.test.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { loadUserConfig, resolveSqlitePaths } from '../../../tools/shared/dict-utils.js'; -import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const tempRoot = resolveTestCachePath(root, 'sqlite-sidecar-cleanup'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); -await fsPromises.cp(fixtureRoot, repoRoot, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -process.env.PAIROFCLEATS_EMBEDDINGS = 'stub'; - -const run = (args, label) => { - const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`Failed: ${label}`); - process.exit(result.status ?? 1); - } -}; - -run([path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', repoRoot], 'build index'); -await runSqliteBuild(repoRoot, { mode: 'code' }); - -const userConfig = loadUserConfig(repoRoot); -let sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -let walPath = `${sqlitePaths.codePath}-wal`; -let shmPath = `${sqlitePaths.codePath}-shm`; -await fsPromises.writeFile(walPath, 'stale-wal'); -await fsPromises.writeFile(shmPath, 'stale-shm'); - -await runSqliteBuild(repoRoot, { mode: 'code' }); - -const staleWal = fs.existsSync(walPath) ? fs.readFileSync(walPath) : null; -const staleShm = fs.existsSync(shmPath) ? fs.readFileSync(shmPath) : null; -if (staleWal && staleWal.toString('utf8') === 'stale-wal') { - console.error('Stale WAL sidecar was not cleaned up.'); - process.exit(1); -} -if (staleShm && staleShm.toString('utf8') === 'stale-shm') { - console.error('Stale SHM sidecar was not cleaned up.'); - process.exit(1); -} - -run([ - path.join(root, 'build_index.js'), - '--incremental', - '--stub-embeddings', - '--repo', - repoRoot -], 'build index (incremental)'); -sqlitePaths = resolveSqlitePaths(repoRoot, userConfig); -walPath = `${sqlitePaths.codePath}-wal`; -shmPath = `${sqlitePaths.codePath}-shm`; -await fsPromises.writeFile(walPath, 'stale-wal'); -await fsPromises.writeFile(shmPath, 'stale-shm'); -await runSqliteBuild(repoRoot, { mode: 'code', incremental: true }); -const incrementalWal = fs.existsSync(walPath) ? fs.readFileSync(walPath) : null; -const incrementalShm = fs.existsSync(shmPath) ? fs.readFileSync(shmPath) : null; -if (incrementalWal && incrementalWal.toString('utf8') === 'stale-wal') { - console.error('Incremental WAL sidecar was not cleaned up.'); - process.exit(1); -} -if (incrementalShm && incrementalShm.toString('utf8') === 'stale-shm') { - console.error('Incremental SHM sidecar was not cleaned up.'); - process.exit(1); -} - -console.log('sqlite sidecar cleanup test passed'); - diff --git a/tests/storage/sqlite/sqlite-skip-empty-code-rebuild.test.js b/tests/storage/sqlite/sqlite-skip-empty-code-rebuild.test.js deleted file mode 100644 index c3c13b27e..000000000 --- a/tests/storage/sqlite/sqlite-skip-empty-code-rebuild.test.js +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildSqliteIndex } from '../../../tools/build/sqlite/runner.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; -import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); -requireOrSkip({ capability: 'sqlite', reason: 'sqlite empty code rebuild test requires better-sqlite3' }); - -let Database = null; -({ default: Database } = await import('better-sqlite3')); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-skip-empty-code-rebuild'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const buildRoot = path.join(tempRoot, 'build-root'); -const codeIndexDir = path.join(buildRoot, 'index-code'); -const sqliteDir = path.join(buildRoot, 'index-sqlite'); -const outputPath = path.join(sqliteDir, 'index-code.db'); -const zeroStateManifestPath = path.join(codeIndexDir, 'pieces', 'sqlite-zero-state.json'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fs.mkdir(codeIndexDir, { recursive: true }); -await fs.mkdir(sqliteDir, { recursive: true }); -await fs.writeFile(path.join(repoRoot, 'src', 'placeholder.js'), 'export const x = 1;\n', 'utf8'); - -applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - embeddings: { enabled: false } - } - } -}); - -await fs.writeFile(path.join(codeIndexDir, 'chunk_meta.json'), '[]\n', 'utf8'); -const logs = []; -await buildSqliteIndex({ - root: repoRoot, - mode: 'code', - indexRoot: buildRoot, - out: outputPath, - codeDir: codeIndexDir, - emitOutput: true, - logger: { - log: (message) => logs.push(String(message || '')), - warn: (message) => logs.push(String(message || '')), - error: (message) => logs.push(String(message || '')) - }, - exitOnError: false -}); - -assert.equal( - await fs.access(outputPath).then(() => true).catch(() => false), - false, - 'expected first-run empty code sqlite build to skip creating db' -); -assert.equal( - await fs.access(zeroStateManifestPath).then(() => true).catch(() => false), - true, - 'expected zero-state manifest for empty code mode' -); -assert.equal( - logs.some((line) => line.includes('skipping sqlite rebuild (artifacts empty; zero-state).')), - true, - 'expected zero-state skip log for empty code rebuild' -); - -const seedDb = new Database(outputPath); -seedDb.exec('CREATE TABLE chunks (id INTEGER PRIMARY KEY, mode TEXT NOT NULL);'); -seedDb.close(); -const before = await fs.stat(outputPath); -const secondRunLogs = []; -await buildSqliteIndex({ - root: repoRoot, - mode: 'code', - indexRoot: buildRoot, - out: outputPath, - codeDir: codeIndexDir, - emitOutput: true, - logger: { - log: (message) => secondRunLogs.push(String(message || '')), - warn: (message) => secondRunLogs.push(String(message || '')), - error: (message) => secondRunLogs.push(String(message || '')) - }, - exitOnError: false -}); -const after = await fs.stat(outputPath); - -assert.equal(after.mtimeMs, before.mtimeMs, 'expected empty code sqlite db to remain unchanged'); -assert.equal( - secondRunLogs.some((line) => line.includes('skipping sqlite rebuild (artifacts empty; zero-state).')), - true, - 'expected repeat zero-state skip log for empty code rebuild' -); - -console.log('sqlite skip empty code rebuild test passed'); diff --git a/tests/storage/sqlite/sqlite-skip-empty-records-rebuild.test.js b/tests/storage/sqlite/sqlite-skip-empty-records-rebuild.test.js deleted file mode 100644 index 62d534b6f..000000000 --- a/tests/storage/sqlite/sqlite-skip-empty-records-rebuild.test.js +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { buildSqliteIndex } from '../../../tools/build/sqlite/runner.js'; -import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; -import { applyTestEnv, ensureTestingEnv } from '../../helpers/test-env.js'; -import { setRecordsIncrementalCapability } from '../../../src/storage/sqlite/build/index.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); -requireOrSkip({ capability: 'sqlite', reason: 'sqlite empty records rebuild test requires better-sqlite3' }); - -let Database = null; -({ default: Database } = await import('better-sqlite3')); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-skip-empty-records-rebuild'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -const buildRoot = path.join(tempRoot, 'build-root'); -const recordsIndexDir = path.join(buildRoot, 'index-records'); -const sqliteDir = path.join(buildRoot, 'index-sqlite'); -const outputPath = path.join(sqliteDir, 'index-records.db'); -const zeroStateManifestPath = path.join(recordsIndexDir, 'pieces', 'sqlite-zero-state.json'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true }); -await fs.mkdir(recordsIndexDir, { recursive: true }); -await fs.mkdir(sqliteDir, { recursive: true }); -await fs.writeFile(path.join(repoRoot, 'src', 'placeholder.js'), 'export const x = 1;\n', 'utf8'); - -applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - embeddings: { enabled: false } - } - } -}); - -await fs.writeFile(path.join(recordsIndexDir, 'chunk_meta.json'), '[]\n', 'utf8'); -const logs = []; -await buildSqliteIndex({ - root: repoRoot, - mode: 'records', - indexRoot: buildRoot, - out: outputPath, - recordsDir: recordsIndexDir, - emitOutput: true, - logger: { - log: (message) => logs.push(String(message || '')), - warn: (message) => logs.push(String(message || '')), - error: (message) => logs.push(String(message || '')) - }, - exitOnError: false -}); - -assert.equal( - await fs.access(outputPath).then(() => true).catch(() => false), - false, - 'expected first-run empty records sqlite build to skip creating db' -); -assert.equal( - await fs.access(zeroStateManifestPath).then(() => true).catch(() => false), - true, - 'expected zero-state manifest for empty records mode' -); -assert.equal( - logs.some((line) => line.includes('skipping records sqlite rebuild (artifacts empty; zero-state).')), - true, - 'expected zero-state skip log for empty records rebuild' -); - -const seedDb = new Database(outputPath); -seedDb.exec('CREATE TABLE chunks (id INTEGER PRIMARY KEY, mode TEXT NOT NULL);'); -seedDb.close(); -const before = await fs.stat(outputPath); -const secondRunLogs = []; -await buildSqliteIndex({ - root: repoRoot, - mode: 'records', - indexRoot: buildRoot, - out: outputPath, - recordsDir: recordsIndexDir, - emitOutput: true, - logger: { - log: (message) => secondRunLogs.push(String(message || '')), - warn: (message) => secondRunLogs.push(String(message || '')), - error: (message) => secondRunLogs.push(String(message || '')) - }, - exitOnError: false -}); -const after = await fs.stat(outputPath); - -assert.equal(after.mtimeMs, before.mtimeMs, 'expected empty records sqlite db to remain unchanged'); -assert.equal( - secondRunLogs.some((line) => line.includes('skipping records sqlite rebuild (artifacts empty; zero-state).')), - true, - 'expected repeat zero-state records skip log' -); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const recordsIncrementalDir = path.join(repoCacheRoot, 'incremental', 'records'); -const seedUnsupportedDb = new Database(outputPath); -seedUnsupportedDb.exec('INSERT INTO chunks (id, mode) VALUES (1, \'records\');'); -seedUnsupportedDb.close(); -await fs.mkdir(path.join(recordsIncrementalDir, 'files'), { recursive: true }); -const unsupportedManifest = { - version: 5, - mode: 'records', - files: {} -}; -setRecordsIncrementalCapability(unsupportedManifest, false); -await fs.writeFile( - path.join(recordsIncrementalDir, 'manifest.json'), - `${JSON.stringify(unsupportedManifest, null, 2)}\n`, - 'utf8' -); - -const unsupportedLogs = []; -await buildSqliteIndex({ - root: repoRoot, - mode: 'records', - incremental: true, - indexRoot: buildRoot, - out: outputPath, - recordsDir: recordsIndexDir, - emitOutput: true, - logger: { - log: (message) => unsupportedLogs.push(String(message || '')), - warn: (message) => unsupportedLogs.push(String(message || '')), - error: (message) => unsupportedLogs.push(String(message || '')) - }, - exitOnError: false -}); -const unsupportedOutput = unsupportedLogs.join('\n').toLowerCase(); -assert.equal( - unsupportedOutput.includes('records incremental bundles unsupported') - || unsupportedOutput.includes('incremental bundles skipped for records'), - true, - 'expected unsupported records incremental capability warning' -); -assert.equal( - unsupportedOutput.includes('using artifacts'), - true, - 'expected unsupported records incremental manifest to fall back to artifacts' -); - -console.log('sqlite skip empty records rebuild test passed'); diff --git a/tests/storage/sqlite/sqlite-token-postings-duplicate-docids.test.js b/tests/storage/sqlite/sqlite-token-postings-duplicate-docids.test.js deleted file mode 100644 index 01fd4db70..000000000 --- a/tests/storage/sqlite/sqlite-token-postings-duplicate-docids.test.js +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-token-postings-duplicate-docids'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), [ - { - id: 0, - file: 'src/example.js', - start: 0, - end: 16, - startLine: 1, - endLine: 1, - kind: 'code', - name: 'example', - tokens: ['alpha', 'alpha', 'beta'] - } -], { atomic: true }); - -await writeJsonObjectFile(path.join(indexDir, 'token_postings.json'), { - fields: { - avgDocLen: 3, - totalDocs: 1 - }, - arrays: { - vocab: ['alpha'], - // Duplicate entries for (doc=0) must be merged by sqlite ingest. - postings: [ - [[0, 2], [0, 3], [0, 1]] - ], - docLengths: [3] - }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - { name: 'chunk_meta', path: 'chunk_meta.jsonl', format: 'jsonl' }, - { name: 'token_postings', path: 'token_postings.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta/token_postings artifacts'); - -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null } -}); -assert.equal(count, 1, 'expected sqlite build to ingest one chunk'); - -const db = new Database(outPath); -const tokenPosting = db.prepare(` - SELECT tf - FROM token_postings - WHERE mode = ? AND token_id = ? AND doc_id = ? -`).get('code', 0, 0); -assert.equal(tokenPosting?.tf, 6, 'expected duplicate doc postings to be merged (2+3+1)'); -db.close(); - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite token_postings duplicate doc ids test passed'); diff --git a/tests/storage/sqlite/sqlite-token-postings-packed-fastpath.test.js b/tests/storage/sqlite/sqlite-token-postings-packed-fastpath.test.js deleted file mode 100644 index 7605fb841..000000000 --- a/tests/storage/sqlite/sqlite-token-postings-packed-fastpath.test.js +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesFile, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { encodePackedOffsets, packTfPostings } from '../../../src/shared/packed-postings.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); -requireOrSkip({ capability: 'sqlite', reason: 'sqlite packed token_postings test requires better-sqlite3' }); - -let Database = null; -({ default: Database } = await import('better-sqlite3')); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-token-postings-packed-fastpath'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunks = [ - { - id: 0, - file: 'src/a.js', - start: 0, - end: 8, - startLine: 1, - endLine: 1, - kind: 'code', - name: 'a', - tokens: ['alpha', 'beta', 'alpha'] - }, - { - id: 1, - file: 'src/b.js', - start: 0, - end: 6, - startLine: 1, - endLine: 1, - kind: 'code', - name: 'b', - tokens: ['beta'] - } -]; -await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), chunks, { atomic: true }); - -const vocab = ['alpha', 'beta']; -const postings = [ - [[0, 2]], - [[0, 1], [1, 1]] -]; -const docLengths = [3, 1]; -const packed = packTfPostings(postings); -const offsets = encodePackedOffsets(packed.offsets); -await fs.writeFile(path.join(indexDir, 'token_postings.packed.bin'), packed.buffer); -await fs.writeFile(path.join(indexDir, 'token_postings.packed.offsets.bin'), offsets); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.packed.meta.json'), { - fields: { - totalDocs: 2, - avgDocLen: 2, - blockSize: packed.blockSize, - offsets: 'token_postings.packed.offsets.bin' - }, - arrays: { - vocab, - docLengths - }, - atomic: true -}); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect streamed chunk meta'); -assert.equal(indexPieces.chunkMeta, null, 'expected chunkMeta to remain streamed'); - -const warnings = []; -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: true, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - logger: { - warn: (message) => warnings.push(String(message || '')), - log: () => {}, - error: () => {} - } -}); -assert.equal(count, chunks.length, 'expected sqlite build to ingest all chunks'); -assert.equal( - warnings.some((message) => message.includes('token_postings missing; rebuilding tokens')), - false, - 'expected packed token_postings ingest to avoid chunk-based rebuild fallback' -); - -const db = new Database(outPath); -try { - const vocabTotal = db.prepare('SELECT COUNT(*) AS total FROM token_vocab WHERE mode = ?').get('code')?.total || 0; - const postingTotal = db.prepare('SELECT COUNT(*) AS total FROM token_postings WHERE mode = ?').get('code')?.total || 0; - const lengthsTotal = db.prepare('SELECT COUNT(*) AS total FROM doc_lengths WHERE mode = ?').get('code')?.total || 0; - assert.equal(vocabTotal, vocab.length, 'expected packed token vocab to ingest'); - assert.equal(postingTotal, 3, 'expected packed token postings row count'); - assert.equal(lengthsTotal, docLengths.length, 'expected packed doc lengths to ingest'); -} finally { - db.close(); -} - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite token_postings packed fastpath test passed'); diff --git a/tests/storage/sqlite/sqlite-token-postings-rebuild-from-streamed-chunk-meta.test.js b/tests/storage/sqlite/sqlite-token-postings-rebuild-from-streamed-chunk-meta.test.js deleted file mode 100644 index d3b9c1635..000000000 --- a/tests/storage/sqlite/sqlite-token-postings-rebuild-from-streamed-chunk-meta.test.js +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { requireOrSkip } from '../../helpers/require-or-skip.js'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -ensureTestingEnv(process.env); -requireOrSkip({ capability: 'sqlite', reason: 'sqlite streamed token_postings rebuild test requires better-sqlite3' }); - -let Database = null; -({ default: Database } = await import('better-sqlite3')); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-token-postings-rebuild-from-streamed-chunk-meta'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunks = [ - { - id: 0, - file: 'src/a.js', - start: 0, - end: 8, - startLine: 1, - endLine: 1, - kind: 'code', - name: 'a', - tokens: ['alpha', 'beta', 'alpha'] - }, - { - id: 1, - file: 'src/b.js', - start: 0, - end: 6, - startLine: 1, - endLine: 1, - kind: 'code', - name: 'b', - tokens: ['beta'] - } -]; - -await writeJsonLinesFile(path.join(indexDir, 'chunk_meta.jsonl'), chunks, { atomic: true }); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta jsonl stream'); -assert.equal(indexPieces.chunkMeta, null, 'expected streaming chunk_meta load to avoid materializing chunk array'); - -const warnings = []; -const count = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: true, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - logger: { - warn: (message) => warnings.push(String(message || '')), - log: () => {}, - error: () => {} - } -}); -assert.equal(count, chunks.length, 'expected sqlite build to ingest all streamed chunks'); -assert.equal( - warnings.some((message) => message.includes('chunk_meta unavailable for token rebuild')), - false, - 'expected token rebuild to use persisted chunk rows instead of reporting chunk_meta unavailable' -); -assert.equal( - warnings.some((message) => message.includes('token_postings missing; rebuilding tokens')), - true, - 'expected missing token_postings warning to remain visible' -); - -const db = new Database(outPath); -try { - const vocabTotal = db.prepare('SELECT COUNT(*) AS total FROM token_vocab WHERE mode = ?').get('code')?.total || 0; - const postingTotal = db.prepare('SELECT COUNT(*) AS total FROM token_postings WHERE mode = ?').get('code')?.total || 0; - const lengthsTotal = db.prepare('SELECT COUNT(*) AS total FROM doc_lengths WHERE mode = ?').get('code')?.total || 0; - assert.equal(vocabTotal, 2, 'expected rebuilt token vocab to include alpha and beta'); - assert.equal(postingTotal, 3, 'expected rebuilt token postings rows for both documents'); - assert.equal(lengthsTotal, chunks.length, 'expected rebuilt doc lengths for each chunk'); -} finally { - db.close(); -} - -if (!fsSync.existsSync(outPath)) { - console.error('Expected sqlite DB to be created.'); - process.exit(1); -} - -console.log('sqlite token_postings rebuild from streamed chunk_meta test passed'); diff --git a/tests/storage/sqlite/sqlite-token-text-materialization-skip.test.js b/tests/storage/sqlite/sqlite-token-text-materialization-skip.test.js deleted file mode 100644 index a6d8f0db4..000000000 --- a/tests/storage/sqlite/sqlite-token-text-materialization-skip.test.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-token-text-materialization-skip'); -const indexDir = path.join(tempRoot, 'index-code'); -const outPath = path.join(tempRoot, 'index-code.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(indexDir, { recursive: true }); - -const chunkCount = 12; -const chunkIterator = function* chunkIterator() { - for (let i = 0; i < chunkCount; i += 1) { - yield { - id: i, - file: `src/file-${i % 3}.js`, - start: 0, - end: 10, - startLine: 1, - endLine: 1, - kind: 'code', - name: `fn${i}`, - tokens: [] - }; - } -}; - -const shardResult = await writeJsonLinesSharded({ - dir: indexDir, - partsDirName: 'chunk_meta.parts', - partPrefix: 'chunk_meta.part-', - items: chunkIterator(), - maxBytes: 4096, - atomic: true -}); -await writeJsonObjectFile(path.join(indexDir, 'chunk_meta.meta.json'), { - fields: { - schemaVersion: '0.0.1', - artifact: 'chunk_meta', - format: 'jsonl-sharded', - generatedAt: new Date().toISOString(), - compression: 'none', - totalRecords: shardResult.total, - totalBytes: shardResult.totalBytes, - maxPartRecords: shardResult.maxPartRecords, - maxPartBytes: shardResult.maxPartBytes, - targetMaxBytes: shardResult.targetMaxBytes, - parts: shardResult.parts.map((part, index) => ({ - path: part, - records: shardResult.counts[index] || 0, - bytes: shardResult.bytes[index] || 0 - })) - }, - atomic: true -}); - -const postingsDir = path.join(indexDir, 'token_postings.shards'); -await fs.mkdir(postingsDir, { recursive: true }); -await writeJsonObjectFile(path.join(postingsDir, 'token_postings.part-00000.json'), { - arrays: { - vocab: [], - postings: [] - }, - atomic: true -}); -await writeJsonObjectFile(path.join(indexDir, 'token_postings.meta.json'), { - fields: { - avgDocLen: 0, - totalDocs: chunkCount, - format: 'sharded', - shardSize: 1, - vocabCount: 0, - parts: ['token_postings.shards/token_postings.part-00000.json'] - }, - arrays: { - docLengths: Array.from({ length: chunkCount }, () => 0) - }, - atomic: true -}); -await writePiecesManifest(indexDir, [ - ...shardResult.parts.map((part) => ({ - name: 'chunk_meta', - path: part, - format: 'jsonl' - })), - { name: 'chunk_meta_meta', path: 'chunk_meta.meta.json', format: 'json' }, - { - name: 'token_postings', - path: 'token_postings.shards/token_postings.part-00000.json', - format: 'sharded' - }, - { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } -]); - -const indexPieces = await loadIndexPieces(indexDir, null); -assert.ok(indexPieces, 'expected loadIndexPieces to detect sharded chunk_meta'); -const sqliteStats = {}; -const ingested = await buildDatabaseFromArtifacts({ - Database, - outPath, - index: indexPieces, - indexDir, - mode: 'code', - manifestFiles: null, - emitOutput: false, - validateMode: 'off', - vectorConfig: { enabled: false }, - modelConfig: { id: null }, - stats: sqliteStats -}); - -assert.equal(ingested, chunkCount, 'expected sqlite build to ingest all chunks'); -assert.equal(sqliteStats.chunkMeta?.tokenTextMaterialized || 0, 0, 'expected zero token text materializations'); -assert.equal(sqliteStats.chunkMeta?.tokenTextSkipped || 0, chunkCount, 'expected token text skips to match chunk count'); - -const db = new Database(outPath); -try { - const ftsNullTokens = db.prepare('SELECT COUNT(*) AS total FROM chunks_fts WHERE tokens IS NULL').get(); - assert.equal(ftsNullTokens?.total, chunkCount, 'expected FTS token column to remain NULL for empty token arrays'); -} finally { - db.close(); -} - -console.log('sqlite token-text materialization skip test passed'); diff --git a/tests/storage/sqlite/sqlite-utils-chunk-array.test.js b/tests/storage/sqlite/sqlite-utils-chunk-array.test.js deleted file mode 100644 index e2e478907..000000000 --- a/tests/storage/sqlite/sqlite-utils-chunk-array.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; - -import { chunkArray } from '../../../src/storage/sqlite/utils.js'; - -assert.deepEqual(chunkArray(null), [], 'expected null input to return empty chunks'); -assert.deepEqual(chunkArray(undefined), [], 'expected undefined input to return empty chunks'); -assert.deepEqual(chunkArray([]), [], 'expected empty input to return empty chunks'); - -assert.deepEqual( - chunkArray([1, 2, 3, 4], 2), - [[1, 2], [3, 4]], - 'expected chunkArray to split by requested chunk size' -); - -assert.deepEqual( - chunkArray([1, 2, 3], 0), - [[1, 2, 3]], - 'expected non-positive chunk size to fall back to default sizing' -); - -assert.deepEqual( - chunkArray([1, 2, 3], Number.NaN), - [[1, 2, 3]], - 'expected invalid chunk size to fall back to default sizing' -); - -console.log('sqlite utils chunk array test passed'); diff --git a/tests/storage/sqlite/sqlite-wal-size-limit.test.js b/tests/storage/sqlite/sqlite-wal-size-limit.test.js deleted file mode 100644 index 8b40dd4a1..000000000 --- a/tests/storage/sqlite/sqlite-wal-size-limit.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch (err) { - console.error(`better-sqlite3 missing: ${err?.message || err}`); - process.exit(1); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'sqlite-wal-size-limit'); -const dbPath = path.join(tempRoot, 'wal.db'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const db = new Database(dbPath); -const state = applyBuildPragmas(db, { inputBytes: 2 * 1024 * 1024 * 1024, stats: {} }); -const journalLimit = Number(state.applied.journal_size_limit || 0); -const walCheckpoint = Number(state.applied.wal_autocheckpoint || 0); -const lockingMode = state.applied.locking_mode; - -assert.ok(journalLimit > 0, 'expected journal_size_limit to be applied'); -assert.ok(walCheckpoint > 0, 'expected wal_autocheckpoint to be applied'); -assert.ok(lockingMode === 'EXCLUSIVE' || lockingMode === 'exclusive', 'expected locking_mode to be EXCLUSIVE'); - -restoreBuildPragmas(db, state); -db.close(); - -console.log('sqlite wal size limit test passed'); diff --git a/tests/storage/sqlite/token-ingest-stored-chunks-fallback.test.js b/tests/storage/sqlite/token-ingest-stored-chunks-fallback.test.js new file mode 100644 index 000000000..d4f5690bb --- /dev/null +++ b/tests/storage/sqlite/token-ingest-stored-chunks-fallback.test.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createTokenIngestor } from '../../../src/storage/sqlite/build/from-artifacts/token-ingest.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite stored-token ingest fallback test requires better-sqlite3' }); + +let Database = null; +({ default: Database } = await import('better-sqlite3')); + +const db = new Database(':memory:'); + +try { + db.exec(` + CREATE TABLE chunks ( + mode TEXT NOT NULL, + id INTEGER NOT NULL, + tokens TEXT + ); + CREATE TABLE doc_lengths ( + mode TEXT NOT NULL, + doc_id INTEGER NOT NULL, + length INTEGER NOT NULL, + PRIMARY KEY (mode, doc_id) + ); + CREATE TABLE token_vocab ( + mode TEXT NOT NULL, + token_id INTEGER NOT NULL, + token TEXT NOT NULL, + PRIMARY KEY (mode, token_id) + ); + CREATE TABLE token_postings ( + mode TEXT NOT NULL, + token_id INTEGER NOT NULL, + doc_id INTEGER NOT NULL, + tf INTEGER NOT NULL, + PRIMARY KEY (mode, token_id, doc_id) + ); + CREATE TABLE token_stats ( + mode TEXT PRIMARY KEY, + avg_doc_len REAL NOT NULL, + total_docs INTEGER NOT NULL + ); + `); + + const insertChunk = db.prepare('INSERT INTO chunks (mode, id, tokens) VALUES (?, ?, ?)'); + insertChunk.run('code', 0, JSON.stringify(['alpha', 'alpha'])); + insertChunk.run('code', 1, '{not valid json'); + insertChunk.run('code', 2, JSON.stringify(['beta', 'alpha'])); + + const batches = { + tokenPostingBatches: 0, + tokenVocabBatches: 0, + docLengthBatches: 0 + }; + const tables = new Map(); + const warnings = []; + + const ingestor = createTokenIngestor({ + db, + resolvedBatchSize: 2, + recordBatch: (name) => { + batches[name] = (batches[name] || 0) + 1; + }, + recordTable: (name, rows) => { + tables.set(name, rows); + }, + warn: (message) => warnings.push(String(message || '')), + insertTokenVocab: db.prepare( + 'INSERT OR REPLACE INTO token_vocab (mode, token_id, token) VALUES (?, ?, ?)' + ), + insertTokenPosting: db.prepare( + 'INSERT OR REPLACE INTO token_postings (mode, token_id, doc_id, tf) VALUES (?, ?, ?, ?)' + ), + insertDocLength: db.prepare( + 'INSERT OR REPLACE INTO doc_lengths (mode, doc_id, length) VALUES (?, ?, ?)' + ), + insertTokenStats: db.prepare( + 'INSERT OR REPLACE INTO token_stats (mode, avg_doc_len, total_docs) VALUES (?, ?, ?)' + ) + }); + + assert.equal(ingestor.ingestTokenIndexFromStoredChunks('code'), true); + + const docLengths = db.prepare( + 'SELECT doc_id, length FROM doc_lengths WHERE mode = ? ORDER BY doc_id' + ).all('code'); + assert.deepEqual(docLengths, [ + { doc_id: 0, length: 2 }, + { doc_id: 1, length: 0 }, + { doc_id: 2, length: 2 } + ]); + + const vocab = db.prepare( + 'SELECT token_id, token FROM token_vocab WHERE mode = ? ORDER BY token_id' + ).all('code'); + assert.deepEqual(vocab, [ + { token_id: 0, token: 'alpha' }, + { token_id: 1, token: 'beta' } + ]); + + const postings = db.prepare( + 'SELECT token_id, doc_id, tf FROM token_postings WHERE mode = ? ORDER BY token_id, doc_id' + ).all('code'); + assert.deepEqual(postings, [ + { token_id: 0, doc_id: 0, tf: 2 }, + { token_id: 0, doc_id: 2, tf: 1 }, + { token_id: 1, doc_id: 2, tf: 1 } + ]); + + const stats = db.prepare( + 'SELECT avg_doc_len, total_docs FROM token_stats WHERE mode = ?' + ).get('code'); + assert.equal(stats.total_docs, 3); + assert.equal(stats.avg_doc_len, 4 / 3); + + assert.equal(tables.get('doc_lengths'), 3); + assert.equal(tables.get('token_vocab'), 2); + assert.equal(tables.get('token_postings'), 3); + assert.equal(tables.get('token_stats'), 1); + assert.equal(batches.tokenPostingBatches, 2); + assert.equal(batches.tokenVocabBatches, 2); + assert.equal(batches.docLengthBatches, 2); + assert.deepEqual(warnings, []); +} finally { + db.close(); +} + +console.log('sqlite stored-token ingest fallback test passed'); diff --git a/tests/storage/sqlite/token-postings-cardinality-invariant.test.js b/tests/storage/sqlite/token-postings-cardinality-invariant.test.js new file mode 100644 index 000000000..ad59ec5ad --- /dev/null +++ b/tests/storage/sqlite/token-postings-cardinality-invariant.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { buildDatabaseFromArtifacts } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { setupTokenPostingsArtifactFixture } from './helpers/token-postings-streamed-fixture.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite token_postings cardinality invariant test requires better-sqlite3' }); + +let Database = null; +({ default: Database } = await import('better-sqlite3')); + +const { indexDir, outPath, indexPieces } = await setupTokenPostingsArtifactFixture({ + tempLabel: 'sqlite-token-postings-cardinality-invariant', + chunks: [{ + id: 0, + file: 'src/example.js', + start: 0, + end: 16, + startLine: 1, + endLine: 1, + kind: 'code', + name: 'example', + tokens: ['alpha', 'beta'] + }], + tokenPostings: { + sharded: { + part: { + arrays: { + vocab: ['alpha'], + postings: [ + [[0, 1]], + [[0, 1]] + ] + } + }, + meta: { + fields: { + avgDocLen: 2, + totalDocs: 1, + format: 'sharded', + shardSize: 1, + vocabCount: 1, + parts: ['token_postings.shards/token_postings.part-00000.json'] + }, + arrays: { + docLengths: [2] + } + } + } + }, + pieceEntries: [ + { name: 'chunk_meta', path: 'chunk_meta.jsonl', format: 'jsonl' }, + { name: 'token_postings', path: 'token_postings.shards/token_postings.part-00000.json', format: 'sharded' }, + { name: 'token_postings_meta', path: 'token_postings.meta.json', format: 'json' } + ] +}); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta/token_postings artifacts'); + +const warnings = []; +await assert.rejects( + () => buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: true, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + logger: { + warn: (message) => warnings.push(String(message || '')), + log: () => {}, + error: () => {} + } + }), + /cardinality invariant failed/i, + 'expected sqlite build to fail closed when token_postings shard cardinality is invalid' +); + +assert.equal( + warnings.some((message) => message.includes('cardinality invariant failed')), + true, + 'expected sqlite token_postings cardinality diagnostics to be emitted' +); + +console.log('sqlite token_postings cardinality invariant test passed'); diff --git a/tests/storage/sqlite/token-postings-duplicate-docids.test.js b/tests/storage/sqlite/token-postings-duplicate-docids.test.js new file mode 100644 index 000000000..c5fcbadcb --- /dev/null +++ b/tests/storage/sqlite/token-postings-duplicate-docids.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import { + buildTokenPostingsArtifactDatabase, + setupTokenPostingsArtifactFixture +} from './helpers/token-postings-streamed-fixture.js'; + +let Database = null; +try { + ({ default: Database } = await import('better-sqlite3')); +} catch (err) { + console.error(`better-sqlite3 missing: ${err?.message || err}`); + process.exit(1); +} + +const { indexDir, outPath, indexPieces } = await setupTokenPostingsArtifactFixture({ + tempLabel: 'sqlite-token-postings-duplicate-docids', + chunks: [{ + id: 0, + file: 'src/example.js', + start: 0, + end: 16, + startLine: 1, + endLine: 1, + kind: 'code', + name: 'example', + tokens: ['alpha', 'alpha', 'beta'] + }], + tokenPostings: { + json: { + fields: { + avgDocLen: 3, + totalDocs: 1 + }, + arrays: { + vocab: ['alpha'], + // Duplicate entries for (doc=0) must be merged by sqlite ingest. + postings: [ + [[0, 2], [0, 3], [0, 1]] + ], + docLengths: [3] + } + } + } +}); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta/token_postings artifacts'); + +const count = await buildTokenPostingsArtifactDatabase({ + Database, + indexPieces, + indexDir, + outPath +}); +assert.equal(count, 1, 'expected sqlite build to ingest one chunk'); + +const db = new Database(outPath); +const tokenPosting = db.prepare(` + SELECT tf + FROM token_postings + WHERE mode = ? AND token_id = ? AND doc_id = ? +`).get('code', 0, 0); +assert.equal(tokenPosting?.tf, 6, 'expected duplicate doc postings to be merged (2+3+1)'); +db.close(); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite token_postings duplicate doc ids test passed'); diff --git a/tests/storage/sqlite/token-postings-packed-fastpath.test.js b/tests/storage/sqlite/token-postings-packed-fastpath.test.js new file mode 100644 index 000000000..672026d20 --- /dev/null +++ b/tests/storage/sqlite/token-postings-packed-fastpath.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { encodePackedOffsets, packTfPostings } from '../../../src/shared/packed-postings.js'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + buildStreamedTokenPostingsDatabase, + loadSqliteDatabase, + loadStreamedTokenPostingsIndexPieces, + readTokenPostingTableTotals, + setupStreamedTokenPostingsFixture +} from './helpers/token-postings-streamed-fixture.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite packed token_postings test requires better-sqlite3' }); + +const Database = await loadSqliteDatabase(); +const { chunks, indexDir, outPath } = await setupStreamedTokenPostingsFixture({ + tempLabel: 'sqlite-token-postings-packed-fastpath' +}); + +const vocab = ['alpha', 'beta']; +const postings = [ + [[0, 2]], + [[0, 1], [1, 1]] +]; +const docLengths = [3, 1]; +const packed = packTfPostings(postings); +const offsets = encodePackedOffsets(packed.offsets); +await fs.writeFile(path.join(indexDir, 'token_postings.packed.bin'), packed.buffer); +await fs.writeFile(path.join(indexDir, 'token_postings.packed.offsets.bin'), offsets); +await writeJsonObjectFile(path.join(indexDir, 'token_postings.packed.meta.json'), { + fields: { + totalDocs: 2, + avgDocLen: 2, + blockSize: packed.blockSize, + offsets: 'token_postings.packed.offsets.bin' + }, + arrays: { + vocab, + docLengths + }, + atomic: true +}); + +const indexPieces = await loadStreamedTokenPostingsIndexPieces(indexDir); +assert.ok(indexPieces, 'expected loadIndexPieces to detect streamed chunk meta'); +assert.equal(indexPieces.chunkMeta, null, 'expected chunkMeta to remain streamed'); + +const { count, warnings } = await buildStreamedTokenPostingsDatabase({ + Database, + indexPieces, + indexDir, + outPath +}); +assert.equal(count, chunks.length, 'expected sqlite build to ingest all chunks'); +assert.equal( + warnings.some((message) => message.includes('token_postings missing; rebuilding tokens')), + false, + 'expected packed token_postings ingest to avoid chunk-based rebuild fallback' +); + +const { vocabTotal, postingTotal, lengthsTotal } = readTokenPostingTableTotals({ Database, outPath }); +assert.equal(vocabTotal, vocab.length, 'expected packed token vocab to ingest'); +assert.equal(postingTotal, 3, 'expected packed token postings row count'); +assert.equal(lengthsTotal, docLengths.length, 'expected packed doc lengths to ingest'); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite token_postings packed fastpath test passed'); diff --git a/tests/storage/sqlite/token-postings-rebuild-from-streamed-chunk-meta.test.js b/tests/storage/sqlite/token-postings-rebuild-from-streamed-chunk-meta.test.js new file mode 100644 index 000000000..75fe794b7 --- /dev/null +++ b/tests/storage/sqlite/token-postings-rebuild-from-streamed-chunk-meta.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import { requireOrSkip } from '../../helpers/require-or-skip.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + buildStreamedTokenPostingsDatabase, + loadSqliteDatabase, + loadStreamedTokenPostingsIndexPieces, + readTokenPostingTableTotals, + setupStreamedTokenPostingsFixture +} from './helpers/token-postings-streamed-fixture.js'; + +ensureTestingEnv(process.env); +requireOrSkip({ capability: 'sqlite', reason: 'sqlite streamed token_postings rebuild test requires better-sqlite3' }); + +const Database = await loadSqliteDatabase(); +const { chunks, indexDir, outPath } = await setupStreamedTokenPostingsFixture({ + tempLabel: 'sqlite-token-postings-rebuild-from-streamed-chunk-meta' +}); + +const indexPieces = await loadStreamedTokenPostingsIndexPieces(indexDir); +assert.ok(indexPieces, 'expected loadIndexPieces to detect chunk_meta jsonl stream'); +assert.equal(indexPieces.chunkMeta, null, 'expected streaming chunk_meta load to avoid materializing chunk array'); + +const { count, warnings } = await buildStreamedTokenPostingsDatabase({ + Database, + indexPieces, + indexDir, + outPath +}); +assert.equal(count, chunks.length, 'expected sqlite build to ingest all streamed chunks'); +assert.equal( + warnings.some((message) => message.includes('chunk_meta unavailable for token rebuild')), + false, + 'expected token rebuild to use persisted chunk rows instead of reporting chunk_meta unavailable' +); +assert.equal( + warnings.some((message) => message.includes('token_postings missing; rebuilding tokens')), + true, + 'expected missing token_postings warning to remain visible' +); + +const { vocabTotal, postingTotal, lengthsTotal } = readTokenPostingTableTotals({ Database, outPath }); +assert.equal(vocabTotal, 2, 'expected rebuilt token vocab to include alpha and beta'); +assert.equal(postingTotal, 3, 'expected rebuilt token postings rows for both documents'); +assert.equal(lengthsTotal, chunks.length, 'expected rebuilt doc lengths for each chunk'); + +if (!fsSync.existsSync(outPath)) { + console.error('Expected sqlite DB to be created.'); + process.exit(1); +} + +console.log('sqlite token_postings rebuild from streamed chunk_meta test passed'); diff --git a/tests/storage/sqlite/token-postings-strict-normalization.test.js b/tests/storage/sqlite/token-postings-strict-normalization.test.js new file mode 100644 index 000000000..8e4d94bdc --- /dev/null +++ b/tests/storage/sqlite/token-postings-strict-normalization.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { normalizeTfPostingRows } from '../../../src/storage/sqlite/build/from-artifacts/sources.js'; +import { INTEGER_COERCE_MODE_STRICT } from '../../../src/shared/number-coerce.js'; + +assert.throws( + () => normalizeTfPostingRows([[1]], { + mode: INTEGER_COERCE_MODE_STRICT, + rejectInvalid: true, + contextLabel: 'strict singleton malformed' + }), + /cardinality invariant failed/i, + 'expected malformed singleton posting to fail strict validation' +); + +assert.throws( + () => normalizeTfPostingRows([[7, 'not-an-int']], { + mode: INTEGER_COERCE_MODE_STRICT, + rejectInvalid: true, + contextLabel: 'strict singleton tf' + }), + /non-integer tf/i, + 'expected singleton posting with non-integer tf to fail strict validation' +); + +const valid = normalizeTfPostingRows([[7, 2]], { + mode: INTEGER_COERCE_MODE_STRICT, + rejectInvalid: true +}); +assert.deepEqual(valid, [[7, 2]], 'expected valid singleton posting to pass strict validation'); + +console.log('token postings strict normalization test passed'); diff --git a/tests/storage/sqlite/token-text-materialization-skip.test.js b/tests/storage/sqlite/token-text-materialization-skip.test.js new file mode 100644 index 000000000..bcc41c667 --- /dev/null +++ b/tests/storage/sqlite/token-text-materialization-skip.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { buildDatabaseFromArtifacts, loadIndexPieces } from '../../../src/storage/sqlite/build/from-artifacts.js'; +import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { + loadDatabaseCtor, + writeSqliteShardFixtureArtifacts +} from './helpers/build-fixture.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const Database = await loadDatabaseCtor(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'sqlite-token-text-materialization-skip'); +const indexDir = path.join(tempRoot, 'index-code'); +const outPath = path.join(tempRoot, 'index-code.db'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(indexDir, { recursive: true }); + +const chunkCount = 12; +const { pieceEntries } = await writeSqliteShardFixtureArtifacts({ + indexDir, + chunkCount, + fileCount: 3, + tokens: [], + tokenVocab: [], + tokenPostings: [], + docLengths: Array.from({ length: chunkCount }, () => 0), + avgDocLen: 0, + tokenShardSize: 1 +}); +await writePiecesManifest(indexDir, pieceEntries); + +const indexPieces = await loadIndexPieces(indexDir, null); +assert.ok(indexPieces, 'expected loadIndexPieces to detect sharded chunk_meta'); +const sqliteStats = {}; +const ingested = await buildDatabaseFromArtifacts({ + Database, + outPath, + index: indexPieces, + indexDir, + mode: 'code', + manifestFiles: null, + emitOutput: false, + validateMode: 'off', + vectorConfig: { enabled: false }, + modelConfig: { id: null }, + stats: sqliteStats +}); + +assert.equal(ingested, chunkCount, 'expected sqlite build to ingest all chunks'); +assert.equal(sqliteStats.chunkMeta?.tokenTextMaterialized || 0, 0, 'expected zero token text materializations'); +assert.equal(sqliteStats.chunkMeta?.tokenTextSkipped || 0, chunkCount, 'expected token text skips to match chunk count'); + +const db = new Database(outPath); +try { + const ftsNullTokens = db.prepare('SELECT COUNT(*) AS total FROM chunks_fts WHERE tokens IS NULL').get(); + assert.equal(ftsNullTokens?.total, chunkCount, 'expected FTS token column to remain NULL for empty token arrays'); +} finally { + db.close(); +} + +console.log('sqlite token-text materialization skip test passed'); diff --git a/tests/storage/sqlite/utils-chunk-array.test.js b/tests/storage/sqlite/utils-chunk-array.test.js new file mode 100644 index 000000000..6993565a0 --- /dev/null +++ b/tests/storage/sqlite/utils-chunk-array.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { chunkArray } from '../../../src/storage/sqlite/utils.js'; + +assert.deepEqual(chunkArray(null), [], 'expected null input to return empty chunks'); +assert.deepEqual(chunkArray(undefined), [], 'expected undefined input to return empty chunks'); +assert.deepEqual(chunkArray([]), [], 'expected empty input to return empty chunks'); + +assert.deepEqual( + chunkArray([1, 2, 3, 4], 2), + [[1, 2], [3, 4]], + 'expected chunkArray to split by requested chunk size' +); + +assert.deepEqual( + chunkArray([1, 2, 3], 0), + [[1, 2, 3]], + 'expected non-positive chunk size to fall back to default sizing' +); + +assert.deepEqual( + chunkArray([1, 2, 3], Number.NaN), + [[1, 2, 3]], + 'expected invalid chunk size to fall back to default sizing' +); + +assert.deepEqual( + chunkArray([1, 2, 3], 0.5), + [[1], [2], [3]], + 'expected fractional chunk sizes below one to clamp to one and avoid infinite loops' +); + +console.log('sqlite utils chunk array test passed'); diff --git a/tests/storage/sqlite/sqlite-utils-dense-binary-load.test.js b/tests/storage/sqlite/utils-dense-binary-load.test.js similarity index 100% rename from tests/storage/sqlite/sqlite-utils-dense-binary-load.test.js rename to tests/storage/sqlite/utils-dense-binary-load.test.js diff --git a/tests/storage/sqlite/vocab/vocab-fetch-parity.test.js b/tests/storage/sqlite/vocab/fetch-parity.test.js similarity index 100% rename from tests/storage/sqlite/vocab/vocab-fetch-parity.test.js rename to tests/storage/sqlite/vocab/fetch-parity.test.js diff --git a/tests/storage/sqlite/wal-pressure-telemetry-regimes.test.js b/tests/storage/sqlite/wal-pressure-telemetry-regimes.test.js new file mode 100644 index 000000000..a7b140596 --- /dev/null +++ b/tests/storage/sqlite/wal-pressure-telemetry-regimes.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveSqliteIngestPlan } from '../../../src/storage/sqlite/utils.js'; + +const MB = 1024 * 1024; + +const makePlan = (walBytes) => resolveSqliteIngestPlan({ + inputBytes: 700 * MB, + repoBytes: 700 * MB, + rowCount: 250_000, + fileCount: 1500, + pageSize: 4096, + journalMode: 'wal', + walEnabled: true, + walBytes +}); + +const nonePlan = makePlan(0); +const mediumPlan = makePlan(32 * MB); +const highPlan = makePlan(120 * MB); + +assert.equal(nonePlan.telemetry.walPressure, 'none', 'expected none regime'); +assert.equal(mediumPlan.telemetry.walPressure, 'medium', 'expected medium regime'); +assert.equal(highPlan.telemetry.walPressure, 'high', 'expected high regime'); +assert.ok( + mediumPlan.batchSize < nonePlan.batchSize, + 'expected medium WAL pressure to reduce batch size' +); +assert.ok( + highPlan.batchSize < mediumPlan.batchSize, + 'expected high WAL pressure to reduce batch size further' +); +assert.ok( + highPlan.transactionRows < mediumPlan.transactionRows, + 'expected high WAL pressure to reduce transaction rows' +); + +console.log('sqlite WAL-pressure telemetry regimes test passed'); diff --git a/tests/storage/sqlite/wal-size-limit.test.js b/tests/storage/sqlite/wal-size-limit.test.js new file mode 100644 index 000000000..d8d41de8f --- /dev/null +++ b/tests/storage/sqlite/wal-size-limit.test.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyBuildPragmas, restoreBuildPragmas } from '../../../src/storage/sqlite/build/pragmas.js'; + +import { loadSqlitePragmaDatabase, openPragmaTestDatabase } from './helpers/pragmas-fixture.js'; + +const Database = await loadSqlitePragmaDatabase(); +const { db } = await openPragmaTestDatabase({ + label: 'sqlite-wal-size-limit', + name: 'wal.db', + Database +}); +const state = applyBuildPragmas(db, { inputBytes: 2 * 1024 * 1024 * 1024, stats: {} }); +const journalLimit = Number(state.applied.journal_size_limit || 0); +const walCheckpoint = Number(state.applied.wal_autocheckpoint || 0); +const lockingMode = state.applied.locking_mode; + +assert.ok(journalLimit > 0, 'expected journal_size_limit to be applied'); +assert.ok(walCheckpoint > 0, 'expected wal_autocheckpoint to be applied'); +assert.ok(lockingMode === 'EXCLUSIVE' || lockingMode === 'exclusive', 'expected locking_mode to be EXCLUSIVE'); + +restoreBuildPragmas(db, state); +db.close(); + +console.log('sqlite wal size limit test passed'); diff --git a/tests/storage/vector-extension/contract-matrix.test.js b/tests/storage/vector-extension/contract-matrix.test.js new file mode 100644 index 000000000..e48b2ab9c --- /dev/null +++ b/tests/storage/vector-extension/contract-matrix.test.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getMetricsText } from '../../../src/shared/metrics/core.js'; +import { updateSqliteDense } from '../../../tools/build/embeddings/sqlite-dense.js'; +import { getExtensionsDir } from '../../../tools/shared/dict-utils.js'; +import { getVectorExtensionConfig, queryVectorAnn } from '../../../tools/sqlite/vector-extension.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'vector-extension-contract-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +{ + const config = getVectorExtensionConfig(tempRoot, null, { + enabled: true, + table: 'dense_vectors_ann; DROP TABLE chunks; --' + }); + assert.equal(config.enabled, false); + assert.ok(config.disabledReason); + + const traversal = getVectorExtensionConfig(tempRoot, null, { + dir: path.join('..', 'outside-extensions'), + path: path.join('..', 'outside-extensions', 'vec0.dll') + }); + assert.equal(traversal.path, null); + assert.equal(traversal.dir, getExtensionsDir(tempRoot, {})); + + const absolutePath = path.resolve(tempRoot, 'extensions', 'vec0.dll'); + const absoluteOverride = getVectorExtensionConfig(tempRoot, null, { path: absolutePath }); + assert.equal(absoluteOverride.path, absolutePath); +} + +{ + const captured = []; + const db = { + prepare(sql) { + assert.ok(sql.includes('MATCH ?')); + return { + all(...params) { + captured.push(params[0]); + return []; + } + }; + } + }; + const baseConfig = { + enabled: true, + table: 'dense_vectors_ann', + column: 'embedding', + encoding: 'float32', + dims: 4 + }; + queryVectorAnn(db, baseConfig, [1, 2], 5, null); + queryVectorAnn(db, baseConfig, [1, 2, 3, 4, 5], 5, null); + assert.equal(captured.length, 2); + assert.deepEqual(Array.from(new Float32Array(captured[0].buffer, captured[0].byteOffset, captured[0].byteLength / 4)), [1, 2, 0, 0]); + assert.deepEqual(Array.from(new Float32Array(captured[1].buffer, captured[1].byteOffset, captured[1].byteLength / 4)), [1, 2, 3, 4]); +} + +{ + const sqlStatements = []; + const inserted = []; + let queryParams = null; + const db = { + exec(sql) { + sqlStatements.push(sql); + }, + prepare(sql) { + sqlStatements.push(sql); + if (sql.startsWith('INSERT OR IGNORE')) { + return { + run(id) { + inserted.push(id); + } + }; + } + return { + all(...params) { + queryParams = params; + return []; + } + }; + }, + transaction(fn) { + return (...args) => fn(...args); + } + }; + + const candidateSet = new Set(Array.from({ length: 1200 }, (_, i) => i)); + const config = { + enabled: true, + table: 'dense_vectors_ann', + column: 'embedding', + encoding: 'float32', + dims: 4 + }; + queryVectorAnn(db, config, [1, 2, 3, 4], 7, candidateSet); + assert.ok(sqlStatements.some((sql) => sql.includes('CREATE TEMP TABLE IF NOT EXISTS __poc_ann_candidates_'))); + assert.ok(sqlStatements.some((sql) => sql.includes('IN (SELECT id FROM __poc_ann_candidates_'))); + assert.ok(sqlStatements.some((sql) => sql.includes('DROP TABLE IF EXISTS __poc_ann_candidates_'))); + assert.equal(inserted.length, candidateSet.size); + assert.ok(Array.isArray(queryParams) && queryParams.length === 2); + assert.equal(queryParams[1], 7); + const metrics = await getMetricsText(); + assert.match(metrics, /pairofcleats_ann_candidate_pushdown_total\{backend="sqlite-vector",strategy="temp-table",size_bucket="1025\+"\} 1/); +} + +{ + let Database = null; + try { + ({ default: Database } = await import('better-sqlite3')); + } catch { + Database = null; + } + + if (Database) { + const repoRoot = path.join(tempRoot, 'repo'); + const cacheRoot = path.join(tempRoot, 'cache'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.mkdir(cacheRoot, { recursive: true }); + applyTestEnv(); + process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; + const userConfig = { cache: { root: cacheRoot } }; + const dbPath = path.join(repoRoot, 'index.sqlite'); + const db = new Database(dbPath); + db.exec('CREATE TABLE dense_vectors (mode TEXT, doc_id INTEGER, vector BLOB)'); + db.exec('CREATE TABLE dense_meta (mode TEXT, dims INTEGER, scale REAL, model TEXT)'); + db.close(); + + const result = updateSqliteDense({ + Database, + root: repoRoot, + userConfig, + indexRoot: null, + mode: 'code', + vectors: [new Uint8Array([128, 128])], + dims: 2, + scale: 2 / 255, + modelId: 'test', + dbPath, + emitOutput: false, + logger: { log: () => {}, warn: () => {}, error: () => {} } + }); + assert.notEqual(result?.skipped, true); + + const verify = new Database(dbPath); + const count = verify.prepare('SELECT COUNT(*) AS total FROM dense_vectors').get().total; + verify.close(); + assert.equal(count, 1); + } +} + +console.log('vector extension contract matrix test passed'); diff --git a/tests/storage/vector-extension/vector-extension-large-candidate-pushdown.test.js b/tests/storage/vector-extension/vector-extension-large-candidate-pushdown.test.js deleted file mode 100644 index cb3cb5bed..000000000 --- a/tests/storage/vector-extension/vector-extension-large-candidate-pushdown.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { queryVectorAnn } from '../../../tools/sqlite/vector-extension.js'; -import { getMetricsText } from '../../../src/shared/metrics.js'; - -const sqlStatements = []; -const inserted = []; -let queryParams = null; - -const db = { - exec(sql) { - sqlStatements.push(sql); - }, - prepare(sql) { - sqlStatements.push(sql); - if (sql.startsWith('INSERT OR IGNORE')) { - return { - run(id) { - inserted.push(id); - } - }; - } - return { - all(...params) { - queryParams = params; - return []; - } - }; - }, - transaction(fn) { - return (...args) => fn(...args); - } -}; - -const candidateSet = new Set(Array.from({ length: 1200 }, (_, i) => i)); -const config = { - enabled: true, - table: 'dense_vectors_ann', - column: 'embedding', - encoding: 'float32', - dims: 4 -}; - -queryVectorAnn(db, config, [1, 2, 3, 4], 7, candidateSet); - -assert.ok( - sqlStatements.some((sql) => sql.includes('CREATE TEMP TABLE IF NOT EXISTS __poc_ann_candidates_')), - 'expected temp candidate table creation for large candidate sets' -); -assert.ok( - sqlStatements.some((sql) => sql.includes('IN (SELECT id FROM __poc_ann_candidates_')), - 'expected ANN query to use temp-table pushdown' -); -assert.ok( - sqlStatements.some((sql) => sql.includes('DROP TABLE IF EXISTS __poc_ann_candidates_')), - 'expected temp candidate table cleanup' -); -assert.equal(inserted.length, candidateSet.size, 'expected all candidate ids inserted into temp table'); -assert.ok(Array.isArray(queryParams) && queryParams.length === 2, 'expected encoded vector + limit params'); -assert.equal(queryParams[1], 7, 'expected exact topN limit when pushdown is active'); -const metrics = await getMetricsText(); -assert.match( - metrics, - /pairofcleats_ann_candidate_pushdown_total\{backend="sqlite-vector",strategy="temp-table",size_bucket="1025\+"\} 1/, - 'expected temp-table pushdown metric increment' -); - -console.log('vector extension large candidate pushdown test passed'); diff --git a/tests/storage/vector-extension/vector-extension-missing.test.js b/tests/storage/vector-extension/vector-extension-missing.test.js deleted file mode 100644 index 9113747f8..000000000 --- a/tests/storage/vector-extension/vector-extension-missing.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { updateSqliteDense } from '../../../tools/build/embeddings/sqlite-dense.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -let Database = null; -try { - ({ default: Database } = await import('better-sqlite3')); -} catch { - console.log('vector extension missing test skipped: better-sqlite3 not available'); - process.exit(0); -} - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'vector-extension-missing'); -const repoRoot = path.join(tempRoot, 'repo'); -const cacheRoot = path.join(tempRoot, 'cache'); -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(repoRoot, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -applyTestEnv(); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; -const userConfig = { cache: { root: cacheRoot } }; -const dbPath = path.join(repoRoot, 'index.sqlite'); -const db = new Database(dbPath); -db.exec('CREATE TABLE dense_vectors (mode TEXT, doc_id INTEGER, vector BLOB)'); -db.exec('CREATE TABLE dense_meta (mode TEXT, dims INTEGER, scale REAL, model TEXT)'); -db.close(); - -const result = updateSqliteDense({ - Database, - root: repoRoot, - userConfig, - indexRoot: null, - mode: 'code', - vectors: [new Uint8Array([128, 128])], - dims: 2, - scale: 2 / 255, - modelId: 'test', - dbPath, - emitOutput: false, - logger: { log: () => {}, warn: () => {}, error: () => {} } -}); - -if (result?.skipped) { - console.error('vector extension missing test failed: updateSqliteDense unexpectedly skipped'); - process.exit(1); -} - -const verify = new Database(dbPath); -const count = verify.prepare('SELECT COUNT(*) AS total FROM dense_vectors').get().total; -verify.close(); -if (count !== 1) { - console.error(`vector extension missing test failed: expected 1 vector row, got ${count}`); - process.exit(1); -} - -console.log('vector extension missing test passed'); - diff --git a/tests/storage/vector-extension/vector-extension-query-dims.test.js b/tests/storage/vector-extension/vector-extension-query-dims.test.js deleted file mode 100644 index d777a1a80..000000000 --- a/tests/storage/vector-extension/vector-extension-query-dims.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { queryVectorAnn } from '../../../tools/sqlite/vector-extension.js'; - -const captured = []; -const db = { - prepare(sql) { - assert.ok( - typeof sql === 'string' && sql.includes('MATCH ?'), - 'expected MATCH query for vector extension' - ); - return { - all(...params) { - captured.push(params[0]); - return []; - } - }; - } -}; - -const baseConfig = { - enabled: true, - table: 'dense_vectors_ann', - column: 'embedding', - encoding: 'float32', - dims: 4 -}; - -queryVectorAnn(db, baseConfig, [1, 2], 5, null); -queryVectorAnn(db, baseConfig, [1, 2, 3, 4, 5], 5, null); - -assert.equal(captured.length, 2, 'expected two ANN invocations'); - -const padded = new Float32Array(captured[0].buffer, captured[0].byteOffset, captured[0].byteLength / 4); -assert.deepEqual(Array.from(padded), [1, 2, 0, 0], 'expected zero padding for short query embedding'); - -const clipped = new Float32Array(captured[1].buffer, captured[1].byteOffset, captured[1].byteLength / 4); -assert.deepEqual(Array.from(clipped), [1, 2, 3, 4], 'expected clipping for oversized query embedding'); - -console.log('vector extension query dims test passed'); diff --git a/tests/storage/vector-extension/vector-extension-sanitize.test.js b/tests/storage/vector-extension/vector-extension-sanitize.test.js deleted file mode 100644 index 4e896a02b..000000000 --- a/tests/storage/vector-extension/vector-extension-sanitize.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { getVectorExtensionConfig } from '../../../tools/sqlite/vector-extension.js'; -import { getExtensionsDir } from '../../../tools/shared/dict-utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'vector-extension-sanitize'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const config = getVectorExtensionConfig(tempRoot, null, { - enabled: true, - table: 'dense_vectors_ann; DROP TABLE chunks; --' -}); -if (config.enabled) { - console.error('Expected vector extension to be disabled for invalid table name.'); - process.exit(1); -} -if (!config.disabledReason) { - console.error('Expected vector extension disabled reason to be set.'); - process.exit(1); -} - -const traversal = getVectorExtensionConfig(tempRoot, null, { - dir: path.join('..', 'outside-extensions'), - path: path.join('..', 'outside-extensions', 'vec0.dll') -}); -if (traversal.path !== null) { - console.error('Expected unsafe vectorExtension.path override to be ignored.'); - process.exit(1); -} -if (traversal.dir !== getExtensionsDir(tempRoot, {})) { - console.error('Expected unsafe vectorExtension.dir override to fall back to default extensions dir.'); - process.exit(1); -} - -const absolutePath = path.resolve(tempRoot, 'extensions', 'vec0.dll'); -const absoluteOverride = getVectorExtensionConfig(tempRoot, null, { - path: absolutePath -}); -if (absoluteOverride.path !== absolutePath) { - console.error('Expected absolute vectorExtension.path override to remain supported.'); - process.exit(1); -} - -console.log('vector extension sanitize test passed'); - diff --git a/tests/tooling/analysis-risk-request-contract.test.js b/tests/tooling/analysis-risk-request-contract.test.js new file mode 100644 index 000000000..54ec77acf --- /dev/null +++ b/tests/tooling/analysis-risk-request-contract.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + projectCliRiskDeltaRequest, + projectCliRiskExplainRequest, + projectRiskDeltaRequest, + projectRiskExplainRequest +} from '../../tools/analysis/risk-request.js'; + +const apiExplain = projectRiskExplainRequest({ + chunk: ' chunk:abc ', + max: 5, + includePartialFlows: 'true', + maxPartialFlows: 2, + filters: { + tags: 'http', + flow_id: 'flow-1', + 'source-rule': 'source.request', + severity: 'high' + } +}); + +assert.equal(apiExplain.chunkUid, 'chunk:abc'); +assert.equal(apiExplain.max, 5); +assert.equal(apiExplain.includePartialFlows, false, 'API/MCP projection must preserve strict boolean semantics'); +assert.equal(apiExplain.maxPartialFlows, 2); +assert.equal(apiExplain.filterValidation.ok, true); +assert.deepEqual(apiExplain.filters, { + rule: [], + category: [], + severity: ['high'], + tag: ['http'], + source: [], + sink: [], + sourceRule: ['source.request'], + sinkRule: [], + flowId: ['flow-1'] +}); + +const cliExplain = projectCliRiskExplainRequest({ + chunk: 'chunk:def', + includePartialFlows: true, + severity: 'critical', + 'flow-id': 'flow-2', + sourceRule: 'source.cli' +}); + +assert.equal(cliExplain.chunkUid, 'chunk:def'); +assert.equal(cliExplain.includePartialFlows, true); +assert.equal(cliExplain.filterValidation.ok, true); +assert.deepEqual(cliExplain.filters.severity, ['critical']); +assert.deepEqual(cliExplain.filters.flowId, ['flow-2']); +assert.deepEqual(cliExplain.filters.sourceRule, ['source.cli']); + +const apiDelta = projectRiskDeltaRequest({ + from: ' main ', + to: ' HEAD ', + seed: ' chunk:abc ', + includePartialFlows: true, + filters: { + severity: 'urgent' + } +}); + +assert.equal(apiDelta.fromRef, 'main'); +assert.equal(apiDelta.toRef, 'HEAD'); +assert.equal(apiDelta.seed, 'chunk:abc'); +assert.equal(apiDelta.includePartialFlows, true); +assert.equal(apiDelta.filterValidation.ok, false); +assert.match(apiDelta.filterValidation.errors.join('; '), /severity must be one of/i); + +const cliDelta = projectCliRiskDeltaRequest({ + from: 'a', + to: 'b', + seed: 'chunk:z', + includePartialFlows: 1, + source_rule: 'source.alias', + sinkRule: 'sink.alias' +}); + +assert.equal(cliDelta.includePartialFlows, false, 'CLI projection must also require a literal true boolean'); +assert.deepEqual(cliDelta.filters.sourceRule, ['source.alias']); +assert.deepEqual(cliDelta.filters.sinkRule, ['sink.alias']); + +console.log('analysis risk request contract test passed'); diff --git a/tests/tooling/analysis-wrapper-exit-propagation.test.js b/tests/tooling/analysis-wrapper-exit-propagation.test.js new file mode 100644 index 000000000..5e0287cc1 --- /dev/null +++ b/tests/tooling/analysis-wrapper-exit-propagation.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { applyTestEnv } from '../helpers/test-env.js'; +import { runNode } from '../helpers/run-node.js'; + +const root = process.cwd(); +const binPath = path.join(root, 'bin', 'pairofcleats.js'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'analysis-wrapper-exit-')); +const env = applyTestEnv({ syncProcess: false }); + +const runCli = (args) => runNode( + [binPath, ...args], + 'analysis wrapper exit propagation', + root, + env, + { stdio: 'pipe', allowFailure: true } +); + +const graphContext = runCli([ + 'graph-context', + '--json', + '--repo', tempRoot, + '--seed', 'file:src/app.js' +]); +assert.equal(graphContext.status, 1, 'expected graph-context wrapper to propagate emitCliError as exit 1'); +assert.match(graphContext.stdout, /"code": "ERR_GRAPH_CONTEXT"/, 'expected graph-context CLI error payload'); + +const impact = runCli([ + 'impact', + '--json', + '--repo', tempRoot, + '--seed', 'file:src/app.js' +]); +assert.equal(impact.status, 1, 'expected impact wrapper to propagate emitCliError as exit 1'); +assert.match(impact.stdout, /"code": "ERR_GRAPH_IMPACT"/, 'expected impact CLI error payload'); + +const suggestTests = runCli([ + 'suggest-tests', + '--json', + '--repo', tempRoot, + '--changed', 'src/app.js' +]); +assert.equal(suggestTests.status, 1, 'expected suggest-tests wrapper to propagate non-throwing failures as exit 1'); +assert.match(suggestTests.stderr, /Missing --max \./, 'expected suggest-tests validation error'); + +console.log('analysis wrapper exit propagation test passed'); diff --git a/tests/tooling/api-contracts/api-contracts-basic.test.js b/tests/tooling/api-contracts/basic.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-basic.test.js rename to tests/tooling/api-contracts/basic.test.js diff --git a/tests/tooling/api-contracts/api-contracts-caps.test.js b/tests/tooling/api-contracts/caps.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-caps.test.js rename to tests/tooling/api-contracts/caps.test.js diff --git a/tests/tooling/api-contracts/api-contracts-fail-on-warn.test.js b/tests/tooling/api-contracts/fail-on-warn.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-fail-on-warn.test.js rename to tests/tooling/api-contracts/fail-on-warn.test.js diff --git a/tests/tooling/api-contracts/api-contracts-large-call-sites.test.js b/tests/tooling/api-contracts/large-call-sites.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-large-call-sites.test.js rename to tests/tooling/api-contracts/large-call-sites.test.js diff --git a/tests/tooling/api-contracts/api-contracts-optional-params.test.js b/tests/tooling/api-contracts/optional-params.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-optional-params.test.js rename to tests/tooling/api-contracts/optional-params.test.js diff --git a/tests/tooling/api-contracts/api-contracts-schema-validate.test.js b/tests/tooling/api-contracts/schema-validate.test.js similarity index 100% rename from tests/tooling/api-contracts/api-contracts-schema-validate.test.js rename to tests/tooling/api-contracts/schema-validate.test.js diff --git a/tests/tooling/api-mcp/meta-filter-normalization.test.js b/tests/tooling/api-mcp/meta-filter-normalization.test.js index 0471ab9f9..225e33fd6 100644 --- a/tests/tooling/api-mcp/meta-filter-normalization.test.js +++ b/tests/tooling/api-mcp/meta-filter-normalization.test.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { normalizeMetaFilters } from '../../../tools/shared/search-request.js'; +import { normalizeMetaFilters } from '../../../src/shared/search-request.js'; import { buildSearchParams } from '../../../tools/api/router/search.js'; import { buildMcpSearchArgs } from '../../../tools/mcp/tools/search-args.js'; diff --git a/tests/tooling/api-mcp/repo-cache-active-generation-parity.test.js b/tests/tooling/api-mcp/repo-cache-active-generation-parity.test.js new file mode 100644 index 000000000..de72ff262 --- /dev/null +++ b/tests/tooling/api-mcp/repo-cache-active-generation-parity.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { loadUserConfig, getRepoCacheRoot, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { createRepoCacheManager } from '../../../src/shared/repo-cache-config.js'; +import { createRepoCacheManager as createApiRepoCacheManager } from '../../../tools/api/router/cache.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-repo-cache-generation-parity-')); +const fixedTick = new Date('2026-03-23T00:00:00.000Z'); + +const writeBuild = async (buildsRoot, buildId) => { + const buildRoot = path.join(buildsRoot, buildId); + await fs.mkdir(path.join(buildRoot, 'index-code'), { recursive: true }); + await fs.writeFile( + path.join(buildRoot, 'index-code', 'chunk_meta.json'), + JSON.stringify([{ chunkUid: `${buildId}-chunk`, file: `${buildId}.js` }], null, 2), + 'utf8' + ); + return buildRoot; +}; + +try { + const repoRoot = path.join(tempRoot, 'repo'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, 'package.json'), JSON.stringify({ name: 'repo-cache-generation-parity' }), 'utf8'); + + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const buildsRoot = path.join(repoCacheRoot, 'builds'); + await fs.mkdir(buildsRoot, { recursive: true }); + + const buildRootA = await writeBuild(buildsRoot, 'build-a'); + const buildRootB = await writeBuild(buildsRoot, 'build-b'); + + const writePointer = async (buildRoot) => { + const currentJsonPath = path.join(buildsRoot, 'current.json'); + await fs.writeFile(currentJsonPath, JSON.stringify({ + buildId: 'build-a', + buildRoot: path.relative(repoCacheRoot, buildRoot).replace(/\\/g, '/') + }, null, 2), 'utf8'); + await fs.utimes(currentJsonPath, fixedTick, fixedTick); + }; + + await writePointer(buildRootA); + + const sharedManager = createRepoCacheManager({ defaultRepo: repoRoot, namespace: 'parity-generation-shared' }); + const apiManager = createApiRepoCacheManager({ defaultRepo: repoRoot }); + const mcpManager = createRepoCacheManager({ defaultRepo: repoRoot, namespace: 'parity-generation-mcp' }); + + const sharedEntry = sharedManager.getRepoCaches(repoRoot); + const apiEntry = apiManager.getRepoCaches(repoRoot); + const mcpEntry = mcpManager.getRepoCaches(repoRoot); + + await sharedManager.refreshBuildPointer(sharedEntry); + await apiManager.refreshBuildPointer(apiEntry); + await mcpManager.refreshBuildPointer(mcpEntry); + + sharedEntry.indexCache.set('shared', { ok: true }); + sharedEntry.sqliteCache.set('shared', { ok: true }); + apiEntry.indexCache.set('api', { ok: true }); + apiEntry.sqliteCache.set('api', { ok: true }); + mcpEntry.indexCache.set('mcp', { ok: true }); + mcpEntry.sqliteCache.set('mcp', { ok: true }); + + await writePointer(buildRootB); + + await sharedManager.refreshBuildPointer(sharedEntry); + await apiManager.refreshBuildPointer(apiEntry); + await mcpManager.refreshBuildPointer(mcpEntry); + + assert.equal(sharedEntry.activeBuildRoot, toRealPathSync(buildRootB)); + assert.equal(apiEntry.activeBuildRoot, toRealPathSync(buildRootB)); + assert.equal(mcpEntry.activeBuildRoot, toRealPathSync(buildRootB)); + + assert.equal(sharedEntry.indexCache.size(), 0, 'shared retrieval cache should clear on active-generation change'); + assert.equal(sharedEntry.sqliteCache.size(), 0, 'shared sqlite cache should clear on active-generation change'); + assert.equal(apiEntry.indexCache.size(), 0, 'api cache should clear on active-generation change'); + assert.equal(apiEntry.sqliteCache.size(), 0, 'api sqlite cache should clear on active-generation change'); + assert.equal(mcpEntry.indexCache.size(), 0, 'mcp cache should clear on active-generation change'); + assert.equal(mcpEntry.sqliteCache.size(), 0, 'mcp sqlite cache should clear on active-generation change'); + + sharedManager.closeRepoCaches(); + apiManager.closeRepoCaches(); + mcpManager.closeRepoCaches(); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('repo cache active generation parity test passed'); diff --git a/tests/tooling/api-mcp/repo-cache-config-parity.test.js b/tests/tooling/api-mcp/repo-cache-config-parity.test.js index ad09ddea7..4ce1c4a30 100644 --- a/tests/tooling/api-mcp/repo-cache-config-parity.test.js +++ b/tests/tooling/api-mcp/repo-cache-config-parity.test.js @@ -5,7 +5,7 @@ import { INDEX_CACHE_POLICY_DEFAULTS, REPO_CACHE_POLICY_DEFAULTS, SQLITE_CACHE_POLICY_DEFAULTS -} from '../../../tools/shared/repo-cache-config.js'; +} from '../../../src/shared/repo-cache-config.js'; import { createRepoCacheManager as createApiRepoCacheManager } from '../../../tools/api/router/cache.js'; import { clearRepoCaches, getRepoCaches, refreshRepoCaches } from '../../../tools/mcp/repo.js'; diff --git a/tests/tooling/bench/bench-runtime-blocker-closure-evidence.test.js b/tests/tooling/bench/bench-runtime-blocker-closure-evidence.test.js new file mode 100644 index 000000000..8cf7df5ec --- /dev/null +++ b/tests/tooling/bench/bench-runtime-blocker-closure-evidence.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { + BENCH_RUNTIME_LIVE_CANARY_STATUS, +} from '../../../tools/bench/language/canaries.js'; +import { + createCleanSdkBenchmarkReport, + writeSdkTargetLiveSummary +} from './bench-runtime-fixture.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'bench', 'language-blocker-closure.js'); +const { liveSummaryPath, outDir, sdkEntry, targetResult } = await writeSdkTargetLiveSummary({ + cacheName: 'bench-runtime-blocker-closure-evidence', + root +}); +const benchmarkReportPath = path.join(outDir, 'benchmark-report.json'); +assert.ok(sdkEntry, 'expected sdk live canary entry'); +assert.equal(targetResult.status, BENCH_RUNTIME_LIVE_CANARY_STATUS.TARGET_ACHIEVED, 'expected target fixture to satisfy live canary target'); + +const missingBenchmarkRun = runNode( + [ + scriptPath, + '--live-summary', + liveSummaryPath, + '--require-closure' + ], + 'bench runtime blocker closure evidence without benchmark confirmation', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(missingBenchmarkRun.status, 0, 'expected closure evidence to fail without a benchmark confirmation'); +const missingBenchmarkEvidence = JSON.parse(missingBenchmarkRun.stdout); +assert.deepEqual(missingBenchmarkEvidence.blockedIssues, [379], 'expected sdk issue to remain blocked without benchmark confirmation'); + +const reportOutput = await createCleanSdkBenchmarkReport(); +fs.writeFileSync(benchmarkReportPath, `${JSON.stringify(reportOutput, null, 2)}\n`); + +const closureRun = runNode( + [ + scriptPath, + '--live-summary', + liveSummaryPath, + '--benchmark-report', + benchmarkReportPath, + '--require-closure' + ], + 'bench runtime blocker closure evidence with benchmark confirmation', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.equal(closureRun.status, 0, closureRun.stderr || closureRun.stdout || 'expected closure evidence to pass once live and benchmark evidence are both present'); +const closureEvidence = JSON.parse(closureRun.stdout); +assert.deepEqual(closureEvidence.blockedIssues, [], 'expected no blocked issues once closure evidence is complete'); +assert.equal(closureEvidence.ok, true, 'expected closure evidence ok=true after benchmark confirmation'); + +console.log('bench runtime blocker closure evidence test passed'); diff --git a/tests/tooling/bench/bench-runtime-canary-corpus.test.js b/tests/tooling/bench/bench-runtime-canary-corpus.test.js new file mode 100644 index 000000000..6b83a3938 --- /dev/null +++ b/tests/tooling/bench/bench-runtime-canary-corpus.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; + +import { + loadBenchRuntimeCanaryManifest, + replayBenchRuntimeCanary, + validateBenchRuntimeCanaryManifest +} from '../../../tools/bench/language/canaries.js'; + +const { canaryRoot, manifest } = await loadBenchRuntimeCanaryManifest(process.cwd()); + +assert.equal(manifest?.schemaVersion, 2, 'expected canary manifest schema version'); +assert.ok(Array.isArray(manifest?.entries), 'expected canary entries array'); +assert.equal(manifest.entries.length >= 7, true, 'expected critical canary coverage'); +assert.ok(Array.isArray(manifest?.liveCanaries), 'expected live canary entries array'); +assert.equal(manifest.liveCanaries.length >= 6, true, 'expected blocker live canary coverage'); +assert.deepEqual( + validateBenchRuntimeCanaryManifest(manifest), + [], + 'expected canary manifest to satisfy replay and live-canary contract requirements' +); + +for (const entry of manifest.entries) { + assert.ok(entry?.id, 'expected canary id'); + assert.ok(entry?.file, 'expected canary file'); + await fsPromises.access(new URL(`file:///${canaryRoot.replace(/\\/g, '/')}/${entry.file}`)); + const replay = await replayBenchRuntimeCanary(entry, process.cwd()); + for (const eventType of entry.requiredEventTypes || []) { + assert.equal( + replay.eventTypes.includes(eventType), + true, + `expected ${entry.id} to emit ${eventType}` + ); + } + for (const failureClass of entry.requiredFailureClasses || []) { + assert.equal( + replay.failureClasses.includes(failureClass), + true, + `expected ${entry.id} to emit failure class ${failureClass}` + ); + } + for (const pattern of entry.requiredPatterns || []) { + assert.equal( + replay.matchedPatterns.includes(pattern), + true, + `expected ${entry.id} to contain pattern ${pattern}` + ); + } +} + +console.log('bench runtime canary corpus test passed'); diff --git a/tests/tooling/bench/bench-runtime-canary-replay.test.js b/tests/tooling/bench/bench-runtime-canary-replay.test.js new file mode 100644 index 000000000..c5e24f095 --- /dev/null +++ b/tests/tooling/bench/bench-runtime-canary-replay.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + loadBenchRuntimeCanaryManifest, + replayBenchRuntimeCanary +} from '../../../tools/bench/language/canaries.js'; + +const { manifest } = await loadBenchRuntimeCanaryManifest(process.cwd()); +const byId = new Map((manifest.entries || []).map((entry) => [entry.id, entry])); + +const swift = await replayBenchRuntimeCanary(byId.get('swift-nio-timeout'), process.cwd()); +assert.deepEqual( + swift.eventTypes, + ['runtime_timeout', 'runtime_timeout_budget_extended'], + 'expected timeout canary structured events' +); + +const gopls = await replayBenchRuntimeCanary(byId.get('gopls-blocked-partitions'), process.cwd()); +assert.equal(gopls.eventTypes.includes('provider_preflight_blocked'), true); +assert.equal(gopls.eventTypes.includes('workspace_partition_decision'), true); + +const parser = await replayBenchRuntimeCanary(byId.get('parser-crash-quarantine'), process.cwd()); +assert.equal(parser.failureClasses.includes('grammar:json'), true); + +console.log('bench runtime canary replay test passed'); diff --git a/tests/tooling/bench/bench-runtime-fixture.js b/tests/tooling/bench/bench-runtime-fixture.js new file mode 100644 index 000000000..356c50b72 --- /dev/null +++ b/tests/tooling/bench/bench-runtime-fixture.js @@ -0,0 +1,87 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; +import { + buildBenchRuntimeLiveCanarySummary, + loadBenchRuntimeCanaryManifest, + runBenchRuntimeLiveCanary +} from '../../../tools/bench/language/canaries.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +export const createCleanSdkBenchmarkReport = () => buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: '/tmp/results', + runLabel: 'bench-language small', + config: { + python: { label: 'Python' } + }, + results: [ + { + language: 'python', + tier: 'medium', + repo: 'basedosdados/sdk', + summary: { + backends: ['memory'], + latencyMsAvg: { memory: 4 }, + hitRate: { memory: 1 }, + resultCountAvg: { memory: 3 }, + memoryRss: { memory: { mean: 1024 } }, + buildMs: { index: 50 } + } + } + ] +}); + +export const createBenchRuntimeCanaryTargetEntry = async (root = process.cwd()) => { + const { manifest } = await loadBenchRuntimeCanaryManifest(root); + const sdkEntry = manifest.liveCanaries.find((entry) => entry.id === 'sdk-artifact-tail-live'); + return { + sdkEntry, + targetEntry: sdkEntry + ? { + ...sdkEntry, + runner: { + ...sdkEntry.runner, + args: [ + '--fixture', + 'sdk-artifact-tail-live-target', + '--out', + '{outJson}' + ] + } + } + : null + }; +}; + +export const runSdkTargetLiveCanary = async (root = process.cwd()) => { + const { sdkEntry, targetEntry } = await createBenchRuntimeCanaryTargetEntry(root); + return { + sdkEntry, + targetEntry, + targetResult: targetEntry ? await runBenchRuntimeLiveCanary(targetEntry, root) : null + }; +}; + +export const writeSdkTargetLiveSummary = async ({ + cacheName, + root = process.cwd(), + requireTarget = true +}) => { + const { dir: outDir } = await prepareTestCacheDir(cacheName); + const liveSummaryPath = path.join(outDir, 'live-summary.json'); + const { sdkEntry, targetEntry, targetResult } = await runSdkTargetLiveCanary(root); + const liveSummary = buildBenchRuntimeLiveCanarySummary([targetResult], { requireTarget }); + fs.writeFileSync(liveSummaryPath, `${JSON.stringify(liveSummary, null, 2)}\n`); + + return { + liveSummary, + liveSummaryPath, + outDir, + sdkEntry, + targetEntry, + targetResult + }; +}; diff --git a/tests/tooling/bench/bench-runtime-live-canary-closure.test.js b/tests/tooling/bench/bench-runtime-live-canary-closure.test.js new file mode 100644 index 000000000..fbf1a74e4 --- /dev/null +++ b/tests/tooling/bench/bench-runtime-live-canary-closure.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; +import { + BENCH_RUNTIME_LIVE_CANARY_STATUS, + buildBenchRuntimeLiveCanarySummary, +} from '../../../tools/bench/language/canaries.js'; +import { runSdkTargetLiveCanary } from './bench-runtime-fixture.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'bench', 'language-canaries.js'); +const { dir: outDir } = await prepareTestCacheDir('bench-runtime-live-canary-closure'); +const outJsonPath = path.join(outDir, 'summary.json'); +const outMdPath = path.join(outDir, 'summary.md'); + +const baselineRun = runNode( + [ + scriptPath, + '--only', + 'sdk-artifact-tail-live', + '--out-json', + outJsonPath, + '--out-md', + outMdPath + ], + 'bench runtime live canary lane', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(baselineRun.status, 0, baselineRun.stderr || baselineRun.stdout || 'expected baseline canary lane to succeed'); +const baselineSummary = JSON.parse(fs.readFileSync(outJsonPath, 'utf8')); +assert.equal(baselineSummary.ok, true, 'expected baseline canary lane summary ok=true without target requirement'); +assert.deepEqual(baselineSummary.blockedIssues, [], 'expected no blocked issues without target requirement'); +assert.equal(baselineSummary.environmentFingerprints.length >= 1, true, 'expected baseline summary to expose environment fingerprints'); +assert.equal(fs.existsSync(outMdPath), true, 'expected markdown summary output'); + +const requireTargetRun = runNode( + [ + scriptPath, + '--only', + 'sdk-artifact-tail-live', + '--require-target' + ], + 'bench runtime live canary lane require-target', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(requireTargetRun.status, 0, 'expected require-target mode to fail while blocker baseline remains unfixed'); +const requireTargetSummary = JSON.parse(requireTargetRun.stdout); +assert.equal(requireTargetSummary.ok, false, 'expected require-target summary to fail'); +assert.deepEqual(requireTargetSummary.blockedIssues, [379], 'expected blocker issue 379 to remain open in require-target mode'); + +const { sdkEntry, targetResult } = await runSdkTargetLiveCanary(root); +assert.ok(sdkEntry, 'expected sdk live canary entry'); +assert.equal(targetResult.status, BENCH_RUNTIME_LIVE_CANARY_STATUS.TARGET_ACHIEVED); +assert.equal(targetResult.closureReady, true, 'expected target fixture to satisfy closure contract'); + +const targetSummary = buildBenchRuntimeLiveCanarySummary([targetResult], { requireTarget: true }); +assert.equal(targetSummary.ok, true, 'expected target-achieved summary to pass require-target mode'); +assert.deepEqual(targetSummary.blockedIssues, [], 'expected no blocked issues once the target contract is satisfied'); + +console.log('bench runtime live canary closure test passed'); diff --git a/tests/tooling/bench/bench-runtime-live-canary-runner.test.js b/tests/tooling/bench/bench-runtime-live-canary-runner.test.js new file mode 100644 index 000000000..406dbb8f0 --- /dev/null +++ b/tests/tooling/bench/bench-runtime-live-canary-runner.test.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + BENCH_RUNTIME_LIVE_CANARY_STATUS, + buildBenchRuntimeLiveCanarySummary, + loadBenchRuntimeCanaryManifest, + runBenchRuntimeLiveCanary +} from '../../../tools/bench/language/canaries.js'; + +const { manifest } = await loadBenchRuntimeCanaryManifest(process.cwd()); + +const results = []; +for (const entry of manifest.liveCanaries) { + const result = await runBenchRuntimeLiveCanary(entry, process.cwd()); + results.push(result); + assert.equal(result.id, entry.id, `expected result id for ${entry.id}`); + assert.equal(result.ok, true, `expected baseline canary ${entry.id} to match current contract`); + assert.ok(result.environment?.actual?.fingerprint, `expected environment fingerprint for ${entry.id}`); + assert.equal( + Array.isArray(result.environment?.expectedProbeIds), + true, + `expected environment probe list for ${entry.id}` + ); + assert.equal( + result.status, + BENCH_RUNTIME_LIVE_CANARY_STATUS.BASELINE_CONFIRMED, + `expected ${entry.id} to confirm the current blocker baseline` + ); + assert.equal(result.closureReady, false, `expected ${entry.id} target contract to remain unmet`); + assert.equal(result.current.ok, true, `expected current contract for ${entry.id}`); + assert.equal(result.target.ok, false, `expected target contract for ${entry.id} to remain open`); +} + +const summary = buildBenchRuntimeLiveCanarySummary(results, { requireTarget: false }); +assert.equal(summary.ok, true, 'expected baseline lane to pass without target requirement'); +assert.deepEqual(summary.blockedIssues, [], 'expected no blocked issues when only confirming baseline behavior'); +assert.equal(summary.environmentFingerprints.length >= 1, true, 'expected summary to expose environment fingerprint(s)'); +assert.equal( + summary.countsByStatus[ BENCH_RUNTIME_LIVE_CANARY_STATUS.BASELINE_CONFIRMED ], + manifest.liveCanaries.length, + 'expected every blocker canary to confirm the current baseline' +); + +console.log('bench runtime live canary runner test passed'); diff --git a/tests/tooling/bench/logger-durability.test.js b/tests/tooling/bench/logger-durability.test.js new file mode 100644 index 000000000..0e718e6ef --- /dev/null +++ b/tests/tooling/bench/logger-durability.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createBenchLogger } from '../../../tools/bench/language-repos/logging.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-bench-logger-durability-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const masterLogPath = path.join(tempRoot, 'bench.log'); +const display = { + log() {}, + warn() {}, + error() {}, + logLine() {} +}; +const logger = createBenchLogger({ + display, + configPath: path.join(tempRoot, 'repos.json'), + reposRoot: path.join(tempRoot, 'repos'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: path.join(tempRoot, 'results'), + masterLogPath, + runSuffix: 'bench-run', + repoLogsEnabled: true +}); + +logger.initMasterLog(); +const repoLogPath = await logger.initRepoLog({ + label: 'demo/repo', + tier: 'small', + repoPath: path.join(tempRoot, 'repos', 'demo-repo'), + slug: 'demo-repo' +}); +assert.ok(repoLogPath, 'expected per-repo log path to be created'); +logger.writeLog('[status] indexing started'); + +const waitForFileContent = async (filePath, substring, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const text = await fs.readFile(filePath, 'utf8'); + if (text.includes(substring)) return text; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${filePath} to contain ${substring}`); +}; + +const repoText = await waitForFileContent(repoLogPath, '[status] indexing started'); +assert.match(repoText, /Target: demo\/repo tier=small/, 'expected repo header to be flushed before close'); +const masterText = await waitForFileContent(masterLogPath, '[log] Repo log for demo/repo'); +assert.match(masterText, /\[status\] indexing started/, 'expected master log to receive active writes before close'); + +await logger.closeRepoLog(); +await logger.closeMasterLog(); + +console.log('bench logger durability test passed'); diff --git a/tests/tooling/bench/logger-sync-routing.test.js b/tests/tooling/bench/logger-sync-routing.test.js new file mode 100644 index 000000000..4be71e17a --- /dev/null +++ b/tests/tooling/bench/logger-sync-routing.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createBenchLogger } from '../../../tools/bench/language-repos/logging.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-bench-logger-sync-routing-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const masterLogPath = path.join(tempRoot, 'bench.log'); +const displayEvents = []; +const display = { + log(message, meta) { + displayEvents.push({ level: 'info', message, meta }); + }, + warn(message, meta) { + displayEvents.push({ level: 'warn', message, meta }); + }, + error(message, meta) { + displayEvents.push({ level: 'error', message, meta }); + }, + logLine(message, meta) { + displayEvents.push({ level: 'status', message, meta }); + } +}; +const logger = createBenchLogger({ + display, + configPath: path.join(tempRoot, 'repos.json'), + reposRoot: path.join(tempRoot, 'repos'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: path.join(tempRoot, 'results'), + masterLogPath, + runSuffix: 'bench-run', + repoLogsEnabled: false +}); + +logger.appendLogSync('[bench-language] Fatal: simulated', 'error', { forceOutput: true }); +logger.appendLogSync('[bench-language] Status: simulated', 'info', { kind: 'status', forceOutput: true }); + +const masterText = await fs.readFile(masterLogPath, 'utf8'); +assert.match(masterText, /\[bench-language\] Fatal: simulated/, 'expected sync append to write the master log'); +assert.match(masterText, /\[bench-language\] Status: simulated/, 'expected status append to write the master log'); +assert.equal(displayEvents.length, 2, 'expected two display events'); +assert.equal(displayEvents[0].level, 'error', 'expected sync append to route through display.error'); +assert.equal(displayEvents[0].message, '[bench-language] Fatal: simulated'); +assert.equal(displayEvents[1].level, 'status', 'expected status meta to route through display.logLine'); +assert.equal(displayEvents[1].message, '[bench-language] Status: simulated'); +assert.equal(displayEvents[1].meta?.kind, 'status'); + +console.log('bench logger sync routing test passed'); diff --git a/tests/tooling/bench/logging-routing-contract.test.js b/tests/tooling/bench/logging-routing-contract.test.js new file mode 100644 index 000000000..93baac0fe --- /dev/null +++ b/tests/tooling/bench/logging-routing-contract.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); + +const read = async (relativePath) => fs.readFile(path.join(root, relativePath), 'utf8'); + +const benchEntrypoint = await read('tools/bench/language-repos.js'); +const benchRunLoop = await read('tools/bench/language-repos/run-loop.js'); +const benchLifecycle = await read('tools/bench/language-repos/lifecycle.js'); +const buildIndexIndex = await read('src/integrations/core/build-index/index.js'); +const buildIndexStages = await read('src/integrations/core/build-index/stages.js'); +const runtimeRuntime = await read('src/index/build/runtime/runtime.js'); + +assert.doesNotMatch(benchEntrypoint, /display\.error\(/, 'bench-language entrypoint should route operator errors through the bench logger'); +assert.doesNotMatch(benchRunLoop, /display\.error\(/, 'bench-language run loop should not bypass the bench logger'); +assert.doesNotMatch(benchLifecycle, /display\.error\(/, 'bench-language lifecycle should not bypass the bench logger'); +assert.match(benchEntrypoint, /appendLogSync\(/, 'fatal bench-language output should use the sync bench logger path'); + +assert.doesNotMatch(buildIndexIndex, /log\(`\[warn\]/, 'build-index default warnings should not be emitted as prefixed info logs'); +assert.doesNotMatch(buildIndexStages, /\[warn\] Index validation warnings/, 'stage validation warnings should use warning metadata instead of embedded prefixes'); +assert.doesNotMatch(runtimeRuntime, /log\(`\[warn\]/, 'runtime envelope warnings should use warning metadata instead of embedded prefixes'); + +console.log('bench logging routing contract test passed'); diff --git a/tests/tooling/bench/repo-closeout-summary.test.js b/tests/tooling/bench/repo-closeout-summary.test.js new file mode 100644 index 000000000..ced23631b --- /dev/null +++ b/tests/tooling/bench/repo-closeout-summary.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildBenchRepoCloseoutSummaryLines } from '../../../tools/bench/language-repos/run-loop.js'; + +const lines = buildBenchRepoCloseoutSummaryLines({ + repoLabel: 'python pallets/jinja', + outcome: 'failed', + failureReason: 'bench', + diagnostics: { + countsByType: { + provider_request_timeout: 4, + provider_degraded_mode_entered: 1, + artifact_tail_stall: 2 + }, + countsBySeverity: { + warn: 7, + error: 1 + }, + topSignals: [ + { + summaryLabel: 'provider_request_timeout pyright textDocument/documentSymbol timeout', + count: 4 + }, + { + summaryLabel: 'artifact_tail_stall field_tokens', + count: 2 + } + ] + }, + progressConfidence: { + bucket: 'low', + score: 0.42 + }, + timeoutDecision: { + phase: 'artifact_write', + resourceClass: 'write-bound', + failureMode: 'budget_exhausted_with_progress' + }, + crashRetention: { + bundlePath: 'C:\\cache\\bundle.json' + } +}); + +assert.equal(lines.length, 2, 'expected headline and top-signal lines'); +assert.match(lines[0], /\[repo-summary\] python pallets\/jinja failed \(bench\)/); +assert.match(lines[0], /timeouts=4/); +assert.match(lines[0], /degraded=1/); +assert.match(lines[0], /artifact-stalls=2/); +assert.match(lines[0], /confidence=low:0\.42/); +assert.match(lines[0], /timeout=artifact_write\/write-bound\/budget_exhausted_with_progress/); +assert.match(lines[0], /crash-bundle=yes/); +assert.match(lines[0], /severity=error:1,warn:7/); +assert.match(lines[1], /pyright textDocument\/documentSymbol timeout x4/); +assert.match(lines[1], /artifact_tail_stall field_tokens x2/); + +console.log('bench repo closeout summary test passed'); diff --git a/tests/tooling/bench/run-closeout-summary.test.js b/tests/tooling/bench/run-closeout-summary.test.js new file mode 100644 index 000000000..363bc684e --- /dev/null +++ b/tests/tooling/bench/run-closeout-summary.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildBenchRunDiagnosticsSummaryLines } from '../../../tools/bench/language/report.js'; + +const lines = buildBenchRunDiagnosticsSummaryLines({ + tasks: [ + { + diagnostics: { + topSignals: [ + { + eventType: 'artifact_tail_stall', + failureClass: 'family:chunk-meta', + count: 2 + }, + { + eventType: 'artifact_tail_stall', + failureClass: 'family:field-postings', + count: 1 + } + ], + fidelity: { + providerId: 'gopls', + runtimeIssues: ['partial_workspace_coverage', 'blocked_workspace_partitions'] + } + } + }, + { + diagnostics: { + fidelity: { + providerId: 'pyright', + runtimeIssues: ['document_symbol_timeout', 'timeout_storm_truncated'] + } + } + } + ], + diagnostics: { + stream: { + countsByType: { + provider_request_timeout: 5, + provider_degraded_mode_entered: 2, + artifact_tail_stall: 3, + fallback_used: 8 + }, + countsByFailureClass: { + provider_unhealthy: 3, + cache_invalid: 2 + }, + countsBySeverity: { + warn: 18, + error: 1 + } + }, + reuse: { + countsBySurfaceAndSource: { + 'scm-derived:mixed-fallback': 2, + 'provider-result:cache': 4, + 'provider-result:live': 3 + }, + countsByQualityImpact: { + none: 2, + 'partial-provider-fidelity': 3 + }, + cost: { + timeCostMs: 3210, + fetchedCount: 12, + chunkCount: 9 + } + }, + progressConfidence: { + countsByBucket: { + high: 7, + medium: 3, + low: 1 + } + }, + crashRetention: { + retainedCount: 2 + } + } +}); + +assert.equal(lines.length, 9, 'expected highlights, artifact families, provider fidelity, fallback causes, reuse, cost, severity, confidence, and crash-retention lines'); +assert.match(lines[0], /^\[diagnostics\] run highlights: /); +assert.match(lines[0], /timeouts=5/); +assert.match(lines[0], /degraded=2/); +assert.match(lines[0], /artifact-stalls=3/); +assert.match(lines[0], /fallbacks=8/); +assert.equal(lines[1], '[diagnostics] artifact families: chunk-meta=2 | field-postings=1'); +assert.equal(lines[2], '[diagnostics] provider fidelity: gopls.blocked-workspace-partitions=1 | gopls.partial-workspace-coverage=1 | pyright.document-symbol-timeout=1 | pyright.timeout-storm-truncated=1'); +assert.equal(lines[3], '[diagnostics] fallback causes: provider-unhealthy=3 | cache-invalid=2'); +assert.equal(lines[4], '[diagnostics] reuse surfaces: scm-mixed-fallback=2 | provider-cache=4 | provider-live=3'); +assert.equal(lines[5], '[diagnostics] fallback cost: time=3.2s | fetched-files=12 | chunks=9 | quality none=2 | partial-provider-fidelity=3'); +assert.equal(lines[6], '[diagnostics] severity: error=1 warn=18'); +assert.equal(lines[7], '[diagnostics] progress confidence: low=1 medium=3'); +assert.equal(lines[8], '[diagnostics] retained crash bundles: 2'); + +console.log('bench run closeout summary test passed'); diff --git a/tests/tooling/cache/cache-gc-preserves-manifest-referenced.test.js b/tests/tooling/cache/cache-gc-preserves-manifest-referenced.test.js deleted file mode 100644 index 46cb86cd7..000000000 --- a/tests/tooling/cache/cache-gc-preserves-manifest-referenced.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getCasLeasesRoot, writeCasObject } from '../../../src/shared/cache-cas.js'; - -applyTestEnv(); - -const root = process.cwd(); -const toolPath = path.join(root, 'tools', 'index', 'cache-gc.js'); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-cache-gc-manifest-')); -const cacheRoot = path.join(tempRoot, 'cache'); -await fs.mkdir(cacheRoot, { recursive: true }); - -const keep = await writeCasObject({ - cacheRoot, - content: Buffer.from('keep-object', 'utf8'), - now: '2020-01-01T00:00:00.000Z' -}); -const leased = await writeCasObject({ - cacheRoot, - content: Buffer.from('leased-object', 'utf8'), - now: '2020-01-01T00:00:00.000Z' -}); -const prune = await writeCasObject({ - cacheRoot, - content: Buffer.from('prune-object', 'utf8'), - now: '2020-01-01T00:00:00.000Z' -}); - -const workspaceManifestPath = path.join(cacheRoot, 'federation', 'ws1-test', 'workspace_manifest.json'); -await fs.mkdir(path.dirname(workspaceManifestPath), { recursive: true }); -await fs.writeFile(workspaceManifestPath, JSON.stringify({ - schemaVersion: 1, - casObjects: [keep.hash] -}, null, 2), 'utf8'); - -const leaseRoot = getCasLeasesRoot(cacheRoot); -await fs.mkdir(leaseRoot, { recursive: true }); -await fs.writeFile(path.join(leaseRoot, `${leased.hash}.json`), JSON.stringify({ - holderId: 'test-worker', - startedAt: new Date(Date.now() - 1000).toISOString(), - ttlMs: 60_000 -}, null, 2), 'utf8'); - -const run = spawnSync( - process.execPath, - [ - toolPath, - '--dry-run', - '--json', - '--cache-root', - cacheRoot, - '--grace-days', - '0' - ], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); -assert.equal(payload.mode, 'cas'); -const candidateHashes = payload.candidates.map((entry) => entry.hash); -const leaseHashes = payload.skippedByLease.map((entry) => entry.hash); - -assert.ok(candidateHashes.includes(prune.hash), 'unreferenced object should be a delete candidate'); -assert.ok(!candidateHashes.includes(keep.hash), 'manifest-referenced object must not be deleted'); -assert.ok(!candidateHashes.includes(leased.hash), 'leased object must not be deleted'); -assert.ok(leaseHashes.includes(leased.hash), 'leased object should be reported as skipped by lease'); - -console.log('cache gc preserves manifest-referenced objects test passed'); diff --git a/tests/tooling/cache/cache-gc-preserves-recent-access.test.js b/tests/tooling/cache/cache-gc-preserves-recent-access.test.js deleted file mode 100644 index 22afc1fd9..000000000 --- a/tests/tooling/cache/cache-gc-preserves-recent-access.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { touchCasObject, writeCasObject } from '../../../src/shared/cache-cas.js'; - -applyTestEnv(); - -const root = process.cwd(); -const toolPath = path.join(root, 'tools', 'index', 'cache-gc.js'); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-cache-gc-access-')); -const cacheRoot = path.join(tempRoot, 'cache'); -await fs.mkdir(cacheRoot, { recursive: true }); - -const oldCreatedAt = '2020-01-01T00:00:00.000Z'; -const recentlyTouchedAt = Date.now(); - -const recentlyAccessed = await writeCasObject({ - cacheRoot, - content: Buffer.from('recently-accessed-object', 'utf8'), - now: oldCreatedAt -}); -await touchCasObject(cacheRoot, recentlyAccessed.hash, recentlyTouchedAt); - -const stale = await writeCasObject({ - cacheRoot, - content: Buffer.from('stale-object', 'utf8'), - now: oldCreatedAt -}); - -const run = spawnSync( - process.execPath, - [ - toolPath, - '--dry-run', - '--json', - '--cache-root', - cacheRoot, - '--grace-days', - '30' - ], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); -const candidateHashes = payload.candidates.map((entry) => entry.hash); - -assert.ok( - candidateHashes.includes(stale.hash), - 'stale object should be a delete candidate' -); -assert.ok( - !candidateHashes.includes(recentlyAccessed.hash), - 'recently accessed object should not be a delete candidate even when created long ago' -); - -console.log('cache gc preserves recent access test passed'); diff --git a/tests/tooling/cache/gc-contract-matrix.test.js b/tests/tooling/cache/gc-contract-matrix.test.js new file mode 100644 index 000000000..2a99d13d0 --- /dev/null +++ b/tests/tooling/cache/gc-contract-matrix.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getCasLeasesRoot } from '../../../src/shared/cache-cas/paths.js'; +import { touchCasObject, writeCasObject } from '../../../src/shared/cache-cas/objects.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +applyTestEnv(); + +const root = process.cwd(); +const toolPath = path.join(root, 'tools', 'index', 'cache-gc.js'); +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-cache-gc-contract-')); +const cacheRoot = path.join(tempRoot, 'cache'); +await fs.mkdir(cacheRoot, { recursive: true }); + +const runGc = (graceDays) => { + const run = runNode( + [toolPath, '--dry-run', '--json', '--cache-root', cacheRoot, '--grace-days', String(graceDays)], + `cache gc grace-days ${graceDays}`, + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', allowFailure: true } + ); + assert.equal(run.status, 0, run.stderr || run.stdout); + return JSON.parse(run.stdout); +}; + +try { + const oldCreatedAt = '2020-01-01T00:00:00.000Z'; + const recentlyTouchedAt = Date.now(); + + const recentlyAccessed = await writeCasObject({ + cacheRoot, + content: Buffer.from('recently-accessed-object', 'utf8'), + now: oldCreatedAt + }); + await touchCasObject(cacheRoot, recentlyAccessed.hash, recentlyTouchedAt); + const stale = await writeCasObject({ + cacheRoot, + content: Buffer.from('stale-object', 'utf8'), + now: oldCreatedAt + }); + + const accessPayload = runGc(30); + const accessCandidates = accessPayload.candidates.map((entry) => entry.hash); + assert.ok(accessCandidates.includes(stale.hash)); + assert.ok(!accessCandidates.includes(recentlyAccessed.hash)); + + const keep = await writeCasObject({ + cacheRoot, + content: Buffer.from('keep-object', 'utf8'), + now: oldCreatedAt + }); + const leased = await writeCasObject({ + cacheRoot, + content: Buffer.from('leased-object', 'utf8'), + now: oldCreatedAt + }); + const prune = await writeCasObject({ + cacheRoot, + content: Buffer.from('prune-object', 'utf8'), + now: oldCreatedAt + }); + + const workspaceManifestPath = path.join(cacheRoot, 'federation', 'ws1-test', 'workspace_manifest.json'); + await fs.mkdir(path.dirname(workspaceManifestPath), { recursive: true }); + await fs.writeFile(workspaceManifestPath, JSON.stringify({ + schemaVersion: 1, + casObjects: [keep.hash] + }, null, 2), 'utf8'); + + const leaseRoot = getCasLeasesRoot(cacheRoot); + await fs.mkdir(leaseRoot, { recursive: true }); + await fs.writeFile(path.join(leaseRoot, `${leased.hash}.json`), JSON.stringify({ + holderId: 'test-worker', + startedAt: new Date(Date.now() - 1000).toISOString(), + ttlMs: 60_000 + }, null, 2), 'utf8'); + + const manifestPayload = runGc(0); + assert.equal(manifestPayload.mode, 'cas'); + const manifestCandidates = manifestPayload.candidates.map((entry) => entry.hash); + const leaseHashes = manifestPayload.skippedByLease.map((entry) => entry.hash); + assert.ok(manifestCandidates.includes(prune.hash)); + assert.ok(!manifestCandidates.includes(keep.hash)); + assert.ok(!manifestCandidates.includes(leased.hash)); + assert.ok(leaseHashes.includes(leased.hash)); + + console.log('tooling cache gc contract matrix test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/ci/command-surface-audit.test.js b/tests/tooling/ci/command-surface-audit.test.js new file mode 100644 index 000000000..daffe1fae --- /dev/null +++ b/tests/tooling/ci/command-surface-audit.test.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getPackageScriptReplacement, listPackageScriptReplacements } from '../../../src/shared/command-aliases.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const scriptPath = path.join(ROOT, 'tools', 'ci', 'check-command-surface.js'); +const env = applyTestEnv({ syncProcess: false }); + +assert.equal(getPackageScriptReplacement('build-index'), null, 'legacy product aliases should not remain in the contributor npm surface'); +assert.equal(getPackageScriptReplacement('verify'), null, 'verify should remain a contributor workflow, not a deprecated CLI alias'); +assert.equal(listPackageScriptReplacements().length, 0, 'expected no deprecated package-script replacements after npm surface reduction'); + +const result = runNode([scriptPath], 'command surface audit', ROOT, env, { stdio: 'pipe' }); +assert.equal(result.status, 0, result.stderr || result.stdout || 'command surface audit failed'); +assert.match(result.stdout, /command surface audit passed/, 'expected success summary from command surface audit'); + +console.log('command surface audit test passed'); diff --git a/tests/tooling/ci/coverage-policy-report.test.js b/tests/tooling/ci/coverage-policy-report.test.js new file mode 100644 index 000000000..d3cbddfd2 --- /dev/null +++ b/tests/tooling/ci/coverage-policy-report.test.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execaSync } from 'execa'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'ci', 'coverage-policy-report.js'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pairofcleats-coverage-policy-')); +const coveragePath = path.join(tempRoot, 'coverage.json'); +const reportPath = path.join(tempRoot, 'coverage-policy.json'); +const markdownPath = path.join(tempRoot, 'coverage-policy.md'); + +const runGit = (args) => execaSync('git', args, { cwd: tempRoot }); + +try { + fs.mkdirSync(path.join(tempRoot, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, 'src', 'retrieval'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'bin', 'pairofcleats.js'), 'export const cli = 1;\n'); + fs.writeFileSync(path.join(tempRoot, 'src', 'retrieval', 'core.js'), 'export const retrieval = 1;\n'); + + runGit(['init']); + runGit(['config', 'user.name', 'PairOfCleats Tests']); + runGit(['config', 'user.email', 'tests@example.invalid']); + runGit(['add', '.']); + runGit(['commit', '-m', 'base']); + const baseSha = runGit(['rev-parse', 'HEAD']).stdout.trim(); + + fs.writeFileSync(path.join(tempRoot, 'bin', 'pairofcleats.js'), 'export const cli = 2;\n'); + runGit(['add', 'bin/pairofcleats.js']); + runGit(['commit', '-m', 'cli change']); + const headSha = runGit(['rev-parse', 'HEAD']).stdout.trim(); + + fs.writeFileSync(coveragePath, `${JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + runId: 'run-ci', + pathPolicy: 'repo-relative-posix', + kind: 'v8-range-summary', + summary: { + files: 2, + coveredRanges: 16, + totalRanges: 20 + }, + entries: [ + { + path: 'bin/pairofcleats.js', + coveredRanges: 6, + totalRanges: 10 + }, + { + path: 'src/retrieval/core.js', + coveredRanges: 10, + totalRanges: 10 + } + ] + }, null, 2)}\n`); + + const result = execaSync('node', [ + scriptPath, + '--root', + tempRoot, + '--coverage', + coveragePath, + '--out', + reportPath, + '--markdown', + markdownPath, + '--mode', + 'ci', + '--base', + baseSha, + '--head', + headSha + ], { cwd: root }); + + const stdout = JSON.parse(result.stdout); + if (stdout.outputPath !== reportPath) { + console.error('coverage policy report test failed: expected outputPath in stdout payload'); + process.exit(1); + } + + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + if (report.kind !== 'test-coverage-policy-report') { + console.error('coverage policy report test failed: expected report kind'); + process.exit(1); + } + if (report.changedFiles.strategy !== 'explicit-git-range') { + console.error('coverage policy report test failed: expected explicit-git-range strategy'); + process.exit(1); + } + if (report.changedFiles.summary.files !== 1) { + console.error('coverage policy report test failed: expected one changed covered file'); + process.exit(1); + } + if (report.changedFiles.files[0]?.path !== 'bin/pairofcleats.js') { + console.error('coverage policy report test failed: expected changed file path in report'); + process.exit(1); + } + const cliSurface = report.criticalSurfaces.find((entry) => entry.id === 'cli'); + const retrievalSurface = report.criticalSurfaces.find((entry) => entry.id === 'retrieval'); + if (!cliSurface || cliSurface.summary.files !== 1 || cliSurface.summary.coverageFraction !== 0.6) { + console.error('coverage policy report test failed: expected CLI critical surface summary'); + process.exit(1); + } + if (!retrievalSurface || retrievalSurface.summary.files !== 1 || retrievalSurface.summary.coverageFraction !== 1) { + console.error('coverage policy report test failed: expected retrieval critical surface summary'); + process.exit(1); + } + + const markdown = fs.readFileSync(markdownPath, 'utf8'); + if (!markdown.includes('## Changed files') || !markdown.includes('## Critical surfaces')) { + console.error('coverage policy report test failed: expected markdown sections'); + process.exit(1); + } +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +console.log('coverage policy report test passed'); diff --git a/tests/tooling/ci/get-last-failure-latest-pointer.test.js b/tests/tooling/ci/get-last-failure-latest-pointer.test.js index 5ba8a26b2..17b12e9bb 100644 --- a/tests/tooling/ci/get-last-failure-latest-pointer.test.js +++ b/tests/tooling/ci/get-last-failure-latest-pointer.test.js @@ -2,10 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; const projectRoot = process.cwd(); +const env = applyTestEnv({ syncProcess: false }); const tempRoot = resolveTestCachePath(projectRoot, 'get-last-failure-latest-pointer'); await fs.rm(tempRoot, { recursive: true, force: true }); @@ -20,10 +22,7 @@ const latestPointerValue = path.relative(tempRoot, latestRunDir).replace(/\\/g, await fs.writeFile(latestPointerPath, `${latestPointerValue}\n`, 'utf8'); const scriptPath = path.join(projectRoot, 'tools', 'ci', 'get-last-failure.js'); -const result = spawnSync(process.execPath, [scriptPath], { - cwd: tempRoot, - encoding: 'utf8' -}); +const result = runNode([scriptPath], 'get last failure latest pointer', tempRoot, env, { stdio: 'pipe' }); assert.equal(result.status, 0, `expected script to succeed, stderr=${result.stderr || ''}`); const stderrLines = String(result.stderr || '') diff --git a/tests/tooling/ci/lsp-embeddings-gates-testing-env.test.js b/tests/tooling/ci/lsp-embeddings-gates-testing-env.test.js new file mode 100644 index 000000000..a357684db --- /dev/null +++ b/tests/tooling/ci/lsp-embeddings-gates-testing-env.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', `lsp-embeddings-gates-testing-env-${process.pid}-${Date.now()}`); +const gatePath = path.join(root, 'tools', 'ci', 'run-lsp-embeddings-gates.js'); +const probePath = path.join(tempRoot, 'testing-env-probe.test.js'); +const testsJsonPath = path.join(tempRoot, 'tests.json'); +const junitPath = path.join(tempRoot, 'junit.xml'); +const diagnosticsPath = path.join(tempRoot, 'diagnostics.json'); +const GATE_TIMEOUT_MS = 20_000; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +await fs.writeFile( + probePath, + [ + "#!/usr/bin/env node", + "if (process.env.PAIROFCLEATS_TESTING !== '1') {", + " console.error(`expected PAIROFCLEATS_TESTING=1, received ${String(process.env.PAIROFCLEATS_TESTING)}`);", + ' process.exit(9);', + '}', + "console.log('testing env probe passed');" + ].join('\n'), + 'utf8' +); + +await fs.writeFile( + testsJsonPath, + `${JSON.stringify([{ label: 'testing-env-probe', file: probePath, timeoutMs: 5000 }], null, 2)}\n`, + 'utf8' +); + +const result = runNode( + [ + gatePath, + '--tests-json', + testsJsonPath, + '--junit', + junitPath, + '--diagnostics', + diagnosticsPath + ], + 'lsp embeddings gates testing env', + root, + applyTestEnv({ testing: '0', syncProcess: false }), + { stdio: 'pipe', timeoutMs: GATE_TIMEOUT_MS, allowFailure: true } +); + +if (result.status !== 0) { + console.error('lsp embeddings gates testing env test failed'); + console.error(result.stderr || result.stdout || ''); +} +assert.equal(result.status, 0, `expected gate exit code 0, received ${result.status}`); + +const diagnostics = JSON.parse(await fs.readFile(diagnosticsPath, 'utf8')); +assert.equal(diagnostics?.status, 'ok', `expected diagnostics status=ok, received ${String(diagnostics?.status)}`); +assert.equal(diagnostics?.metrics?.executed, 1, 'expected one executed gate test'); +assert.equal(diagnostics?.results?.[0]?.status, 'passed', 'expected probe test status=passed'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('lsp embeddings gates testing env test passed'); diff --git a/tests/tooling/ci/lsp-embeddings-gates-timeout-junit.test.js b/tests/tooling/ci/lsp-embeddings-gates-timeout-junit.test.js new file mode 100644 index 000000000..8663fde18 --- /dev/null +++ b/tests/tooling/ci/lsp-embeddings-gates-timeout-junit.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', `lsp-embeddings-gates-timeout-${process.pid}-${Date.now()}`); +const gatePath = path.join(root, 'tools', 'ci', 'run-lsp-embeddings-gates.js'); +const timeoutProbePath = path.join(tempRoot, 'timeout-probe.test.js'); +const testsJsonPath = path.join(tempRoot, 'tests.json'); +const junitPath = path.join(tempRoot, 'junit.xml'); +const diagnosticsPath = path.join(tempRoot, 'diagnostics.json'); +const GATE_TIMEOUT_MS = 20_000; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +await fs.writeFile( + timeoutProbePath, + [ + '#!/usr/bin/env node', + "setInterval(() => {}, 1000);" + ].join('\n'), + 'utf8' +); + +const timeoutMs = 200; +await fs.writeFile( + testsJsonPath, + `${JSON.stringify([{ label: 'timeout-probe', file: timeoutProbePath, timeoutMs }], null, 2)}\n`, + 'utf8' +); + +const result = runNode( + [ + gatePath, + '--tests-json', + testsJsonPath, + '--junit', + junitPath, + '--diagnostics', + diagnosticsPath + ], + 'lsp embeddings gates timeout junit', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', timeoutMs: GATE_TIMEOUT_MS, allowFailure: true } +); + +assert.equal(result.status, 124, `expected gate timeout exit code 124, received ${result.status}`); + +const diagnostics = JSON.parse(await fs.readFile(diagnosticsPath, 'utf8')); +assert.equal(diagnostics?.status, 'error', `expected diagnostics status=error, received ${String(diagnostics?.status)}`); +assert.equal(diagnostics?.failureReason, 'timeout', `expected failureReason=timeout, received ${String(diagnostics?.failureReason)}`); +assert.equal(diagnostics?.results?.[0]?.reason, 'timeout', 'expected timeout reason in gate result payload'); + +const junitRaw = await fs.readFile(junitPath, 'utf8'); +assert.ok(junitRaw.includes('type="timeout"'), 'expected timeout failure type in junit output'); +assert.ok(junitRaw.includes(`timed out after ${timeoutMs}ms`), 'expected timeout message in junit output'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('lsp embeddings gates timeout junit test passed'); diff --git a/tests/tooling/ci/run-suite-output-sanitization.test.js b/tests/tooling/ci/run-suite-output-sanitization.test.js new file mode 100644 index 000000000..228033667 --- /dev/null +++ b/tests/tooling/ci/run-suite-output-sanitization.test.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { __sanitizeHostedShellOutputForTests } from '../../../tools/ci/run-suite.js'; + +const titleWrapped = 'prefix\x1b]0;PairOfCleats CI\x07suffix'; +assert.equal( + __sanitizeHostedShellOutputForTests(titleWrapped), + 'prefixsuffix', + 'expected OSC title sequence to be stripped from forwarded output' +); + +const stWrapped = 'left\x1b]0;PairOfCleats CI\x1b\\right'; +assert.equal( + __sanitizeHostedShellOutputForTests(stWrapped), + 'leftright', + 'expected OSC title sequence terminated by ST to be stripped from forwarded output' +); + +const ansiOnly = '\x1b[36mstill-colored\x1b[0m'; +assert.equal( + __sanitizeHostedShellOutputForTests(ansiOnly), + ansiOnly, + 'expected non-OSC ANSI sequences to remain unchanged' +); + +console.log('run-suite output sanitization test passed'); diff --git a/tests/tooling/ci/run-suite-path-normalization.test.js b/tests/tooling/ci/run-suite-path-normalization.test.js new file mode 100644 index 000000000..8dabcf74f --- /dev/null +++ b/tests/tooling/ci/run-suite-path-normalization.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { splitPathEntries } from '../../../src/index/tooling/binary-utils.js'; +import { + __applySuiteToolingPathEntriesForTests, + __buildSuiteEnvForTests, + __resolveSuiteToolingPathEntriesForTests +} from '../../../tools/ci/run-suite.js'; + +const root = process.cwd(); +const firstPathEntry = path.join(root, '.testLogs', 'run-suite-path-a'); +const secondPathEntry = path.join(root, '.testLogs', 'run-suite-path-b'); +const thirdPathEntry = path.dirname(process.execPath); + +const env = __buildSuiteEnvForTests('ci', { + PATH: `${firstPathEntry}${path.delimiter}${thirdPathEntry}`, + Path: `${secondPathEntry}${path.delimiter}${thirdPathEntry}` +}); + +const pathKeys = Object.keys(env).filter((key) => key.toLowerCase() === 'path'); +assert.equal(pathKeys.length, 1, 'expected buildSuiteEnv to normalize PATH/Path to a single key'); + +const normalizedEntries = splitPathEntries(env[pathKeys[0]]); +const normalizeForCompare = (value) => process.platform === 'win32' + ? String(value || '').toLowerCase() + : String(value || ''); +const normalizedSet = new Set(normalizedEntries.map(normalizeForCompare)); +assert.ok(normalizedSet.has(normalizeForCompare(firstPathEntry)), 'expected PATH entry preserved after normalization'); +assert.ok(normalizedSet.has(normalizeForCompare(secondPathEntry)), 'expected Path entry preserved after normalization'); +assert.ok(normalizedSet.has(normalizeForCompare(thirdPathEntry)), 'expected executable PATH entry preserved'); +assert.equal(env.PAIROFCLEATS_TESTING, '1', 'expected suite env builder to force testing mode'); + +const userConfig = { + tooling: { + dir: path.join(root, '.ci-cache', 'pairofcleats', 'tooling') + } +}; +const suiteToolingEntries = __resolveSuiteToolingPathEntriesForTests(root, userConfig); +const normalizedToolingEntries = suiteToolingEntries.map(normalizeForCompare); +assert.ok( + normalizedToolingEntries.some((entry) => entry.endsWith(normalizeForCompare(path.join('.ci-cache', 'pairofcleats', 'tooling', 'bin')))), + 'expected suite tooling entries to include cache bin dir' +); +assert.ok( + normalizedToolingEntries.some((entry) => entry.endsWith(normalizeForCompare(path.join('.ci-cache', 'pairofcleats', 'tooling', 'dotnet')))), + 'expected suite tooling entries to include cache dotnet dir' +); +assert.ok( + normalizedToolingEntries.some((entry) => entry.endsWith(normalizeForCompare(path.join('.ci-cache', 'pairofcleats', 'tooling', 'composer', 'vendor', 'bin')))), + 'expected suite tooling entries to include cache composer bin dir' +); + +const runtimeEnv = __buildSuiteEnvForTests('ci', { + PATH: thirdPathEntry +}); +__applySuiteToolingPathEntriesForTests(runtimeEnv, root, userConfig); +const runtimePathKey = Object.keys(runtimeEnv).find((key) => key.toLowerCase() === 'path'); +const runtimeEntries = splitPathEntries(runtimeEnv[runtimePathKey]).map(normalizeForCompare); +for (const entry of normalizedToolingEntries) { + assert.ok(runtimeEntries.includes(entry), `expected suite runtime PATH to include ${entry}`); +} + +console.log('run-suite PATH normalization test passed'); diff --git a/tests/tooling/cli-utils-windows-wrapper-fallback.test.js b/tests/tooling/cli-utils-windows-wrapper-fallback.test.js new file mode 100644 index 000000000..3515d7ca8 --- /dev/null +++ b/tests/tooling/cli-utils-windows-wrapper-fallback.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { runCommand } from '../../tools/shared/cli-utils.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +applyTestEnv({ testing: '1' }); + +if (process.platform !== 'win32') { + console.log('cli-utils windows wrapper fallback test skipped on non-Windows platforms'); + process.exit(0); +} + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-cli-utils-windows-wrapper-')); +try { + const scriptPath = path.join(tempRoot, 'fake-npm.js'); + const wrapperPath = path.join(tempRoot, 'npm.cmd'); + const outputPath = path.join(tempRoot, 'npm-output.txt'); + await fs.writeFile( + scriptPath, + `#!/usr/bin/env node\nconst fs = require('node:fs');\nfs.writeFileSync(${JSON.stringify(outputPath)}, process.argv.slice(2).join(' '), 'utf8');\nprocess.exit(0);\n`, + 'utf8' + ); + await fs.writeFile( + wrapperPath, + '@echo off\r\nnode "%~dp0\\fake-npm.js" %*\r\n', + 'utf8' + ); + + const shimEnv = { + ...process.env, + PATH: tempRoot, + Path: tempRoot + }; + const result = runCommand('npm', ['install', '--version'], { + env: shimEnv, + stdio: 'pipe', + encoding: 'utf8' + }); + + assert.equal(result.status, 0, `expected bare npm wrapper invocation to succeed: ${result.stderr}`); + const captured = await fs.readFile(outputPath, 'utf8'); + assert.equal(captured, 'install --version', 'expected wrapper to receive original argv through bare npm path'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('cli-utils windows wrapper fallback test passed'); diff --git a/tests/tooling/config-inventory/inventory-idempotence.test.js b/tests/tooling/config-inventory/inventory-idempotence.test.js new file mode 100644 index 000000000..5a876b897 --- /dev/null +++ b/tests/tooling/config-inventory/inventory-idempotence.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildInventory } from '../../../tools/config/inventory.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'config-inventory-idempotence'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const outputJsonPath = path.join(tempRoot, 'inventory.json'); +const outputMdPath = path.join(tempRoot, 'inventory.md'); +const schemaPath = path.join(root, 'docs', 'config', 'schema.json'); + +await buildInventory({ + root, + schemaPath, + outputJsonPath, + outputMdPath, + check: false +}); + +const firstJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const firstMdText = await fs.readFile(outputMdPath, 'utf8'); +const firstJson = JSON.parse(firstJsonText); + +await new Promise((resolve) => setTimeout(resolve, 20)); + +await buildInventory({ + root, + schemaPath, + outputJsonPath, + outputMdPath, + check: false +}); + +const secondJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const secondMdText = await fs.readFile(outputMdPath, 'utf8'); +const secondJson = JSON.parse(secondJsonText); + +assert.equal(secondJson.generatedAt, firstJson.generatedAt, 'expected generatedAt to be preserved when inventory content is unchanged'); +assert.equal(secondJsonText, firstJsonText, 'expected inventory json output to be byte-stable across identical reruns'); +assert.equal(secondMdText, firstMdText, 'expected inventory markdown output to be byte-stable across identical reruns'); + +console.log('config inventory idempotence test passed'); diff --git a/tests/tooling/config/config-dump.test.js b/tests/tooling/config/config-dump.test.js deleted file mode 100644 index d6fa2c7cc..000000000 --- a/tests/tooling/config/config-dump.test.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); -const scriptPath = path.join(repoRoot, 'tools', 'config', 'dump.js'); -const result = spawnSync(process.execPath, [scriptPath, '--json'], { encoding: 'utf8', cwd: repoRoot }); -if (result.status !== 0) { - throw new Error(`config-dump failed: ${result.stderr || result.stdout}`); -} -const payload = JSON.parse(result.stdout || '{}'); -if (!payload.repoRoot) { - throw new Error('config-dump did not report repoRoot.'); -} -if (!payload.derived || !payload.derived.cacheRoot) { - throw new Error('config-dump did not include derived cacheRoot.'); -} -console.log('Config dump test passed'); diff --git a/tests/tooling/config/config-validate.test.js b/tests/tooling/config/config-validate.test.js deleted file mode 100644 index 324d0961f..000000000 --- a/tests/tooling/config/config-validate.test.js +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { repoRoot } from '../../helpers/root.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = repoRoot(); -const cacheRoot = resolveTestCachePath(root, 'config-validate'); -await fsPromises.rm(cacheRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const validPath = path.join(cacheRoot, 'valid.json'); -const validAnyOfPath = path.join(cacheRoot, 'valid-anyof.json'); -const invalidPath = path.join(cacheRoot, 'invalid.json'); - -await fsPromises.writeFile( - validPath, - JSON.stringify({ quality: 'balanced', cache: { root: 'C:/tmp/pairofcleats' } }, null, 2) -); -await fsPromises.writeFile( - validAnyOfPath, - JSON.stringify({ - quality: 'balanced', - threads: 4, - tooling: { enabledTools: ['pyright', 'clangd'] }, - indexing: { riskInterprocedural: { caps: { maxMs: null } } } - }, null, 2) -); -await fsPromises.writeFile( - invalidPath, - JSON.stringify({ unknownKey: true }, null, 2) -); - -const validatorPath = path.join(root, 'tools', 'config/validate.js'); -if (!fs.existsSync(validatorPath)) { - console.error(`Missing validator script: ${validatorPath}`); - process.exit(1); -} - -const okResult = spawnSync(process.execPath, [validatorPath, '--config', validPath, '--json'], { - encoding: 'utf8' -}); -if (okResult.status !== 0) { - console.error('config validate failed for valid config'); - if (okResult.stderr) console.error(okResult.stderr.trim()); - process.exit(okResult.status ?? 1); -} - -let okPayload; -try { - okPayload = JSON.parse(okResult.stdout || '{}'); -} catch (err) { - console.error('config validate output was not valid JSON'); - process.exit(1); -} -if (!okPayload.ok) { - console.error('config validate did not report ok for valid config'); - process.exit(1); -} - -const anyOfResult = spawnSync(process.execPath, [validatorPath, '--config', validAnyOfPath, '--json'], { - encoding: 'utf8' -}); -if (anyOfResult.status !== 0) { - console.error('config validate failed for anyOf/union config'); - if (anyOfResult.stderr) console.error(anyOfResult.stderr.trim()); - process.exit(anyOfResult.status ?? 1); -} -let anyOfPayload; -try { - anyOfPayload = JSON.parse(anyOfResult.stdout || '{}'); -} catch (err) { - console.error('config validate output was not valid JSON for anyOf/union config'); - process.exit(1); -} -if (!anyOfPayload.ok) { - console.error('config validate did not report ok for anyOf/union config'); - process.exit(1); -} - -const badResult = spawnSync(process.execPath, [validatorPath, '--config', invalidPath, '--json'], { - encoding: 'utf8' -}); -if (badResult.status === 0) { - console.error('config validate should have failed for invalid config'); - process.exit(1); -} - -let badPayload; -try { - badPayload = JSON.parse(badResult.stdout || '{}'); -} catch { - badPayload = null; -} -if (!badPayload || badPayload.ok || !Array.isArray(badPayload.errors) || badPayload.errors.length === 0) { - console.error('config validate did not report errors for invalid config'); - process.exit(1); -} - -console.log('config validate test passed'); - diff --git a/tests/tooling/config/contract-matrix.test.js b/tests/tooling/config/contract-matrix.test.js new file mode 100644 index 000000000..7b552422b --- /dev/null +++ b/tests/tooling/config/contract-matrix.test.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getToolingConfig, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { repoRoot } from '../../helpers/root.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const rootEnv = applyTestEnv({ syncProcess: false }); + +{ + const repo = process.cwd(); + const userConfig = { + tooling: { + timeoutMs: 42000, + lifecycle: { + lifecycleRestartWindowMs: 61000 + }, + lsp: { + enabled: true, + lifecycle: { + lifecycleMaxRestartsPerWindow: 9, + lifecycleSessionIdleTimeoutMs: 2500, + lifecycleSessionMaxLifetimeMs: 120000 + }, + servers: [{ id: 'gopls', cmd: 'gopls', args: [] }] + }, + clangd: { + maxRetries: 7, + disableHoverWithoutCompileCommands: false + }, + jdtls: { enabled: true }, + csharp: { + enabled: true, + lifecycle: { + fdPressureBackoffMs: 500 + } + } + } + }; + + const tooling = getToolingConfig(repo, userConfig); + assert.equal(tooling.timeoutMs, 42000); + assert.equal(tooling.lifecycle?.lifecycleRestartWindowMs, 61000); + assert.equal(tooling.lsp?.lifecycle?.lifecycleMaxRestartsPerWindow, 9); + assert.equal(tooling.lsp?.lifecycle?.lifecycleSessionIdleTimeoutMs, 2500); + assert.equal(tooling.lsp?.lifecycle?.lifecycleSessionMaxLifetimeMs, 120000); + assert.equal(tooling.lsp?.servers?.length, 1); + assert.equal(tooling.clangd?.maxRetries, 7); + assert.equal(tooling.clangd?.disableHoverWithoutCompileCommands, false); + assert.equal(tooling.jdtls?.enabled, true); + assert.equal(tooling.csharp?.enabled, true); + assert.equal(tooling.csharp?.lifecycle?.fdPressureBackoffMs, 500); +} + +{ + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-profile-')); + try { + await fsPromises.writeFile( + path.join(tempRoot, '.pairofcleats.json'), + JSON.stringify({ profile: 'lite' }, null, 2), + 'utf8' + ); + assert.throws(() => loadUserConfig(tempRoot), /profile/); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } +} + +{ + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tooling-config-load-')); + try { + await fsPromises.writeFile( + path.join(tempRoot, '.pairofcleats.json'), + JSON.stringify({ + tooling: { + lifecycle: { lifecycleRestartWindowMs: 61_000 }, + cache: { enabled: true, maxBytes: 123456, maxEntries: 987 }, + vfs: { + hashRouting: true, + coalesceSegments: true, + tokenMode: 'docHash+virtualPath', + coldStartCache: { enabled: true } + }, + lsp: { + lifecycle: { + lifecycleMaxRestartsPerWindow: 9, + lifecycleSessionIdleTimeoutMs: 2_500 + }, + servers: [{ id: 'gopls', cmd: 'gopls', args: [] }] + }, + jdtls: { enabled: true }, + csharp: { enabled: true }, + solargraph: { enabled: true }, + elixir: { enabled: true }, + haskell: { enabled: true }, + phpactor: { enabled: true }, + dart: { enabled: true } + } + }, null, 2), + 'utf8' + ); + + const loaded = loadUserConfig(tempRoot); + assert.equal(loaded.tooling?.lifecycle?.lifecycleRestartWindowMs, 61_000); + assert.equal(loaded.tooling?.cache?.maxBytes, 123456); + assert.equal(loaded.tooling?.cache?.maxEntries, 987); + assert.equal(loaded.tooling?.vfs?.hashRouting, true); + assert.equal(loaded.tooling?.vfs?.coalesceSegments, true); + assert.equal(loaded.tooling?.vfs?.tokenMode, 'docHash+virtualPath'); + assert.equal(loaded.tooling?.lsp?.lifecycle?.lifecycleMaxRestartsPerWindow, 9); + assert.equal(loaded.tooling?.lsp?.lifecycle?.lifecycleSessionIdleTimeoutMs, 2500); + for (const name of ['jdtls', 'csharp', 'solargraph', 'elixir', 'haskell', 'phpactor', 'dart']) { + assert.equal(loaded.tooling?.[name]?.enabled, true, `expected ${name} passthrough`); + } + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } +} + +{ + const root = repoRoot(); + const cacheRoot = resolveTestCachePath(root, 'config-validate'); + await fsPromises.rm(cacheRoot, { recursive: true, force: true }); + await fsPromises.mkdir(cacheRoot, { recursive: true }); + + const validPath = path.join(cacheRoot, 'valid.json'); + const validAnyOfPath = path.join(cacheRoot, 'valid-anyof.json'); + const invalidPath = path.join(cacheRoot, 'invalid.json'); + + await fsPromises.writeFile(validPath, JSON.stringify({ quality: 'balanced', cache: { root: 'C:/tmp/pairofcleats' } }, null, 2)); + await fsPromises.writeFile(validAnyOfPath, JSON.stringify({ + quality: 'balanced', + threads: 4, + tooling: { enabledTools: ['pyright', 'clangd'] }, + indexing: { + riskInterprocedural: { + caps: { maxMs: null }, + semantics: [ + { + kind: 'callback', + patterns: ['\\bregisterHandler\\b'], + languages: ['javascript'], + frameworks: ['express'], + fromArgs: [1], + taintHints: ['payload'] + } + ] + } + } + }, null, 2)); + await fsPromises.writeFile(invalidPath, JSON.stringify({ unknownKey: true }, null, 2)); + + const validatorPath = path.join(root, 'tools', 'config/validate.js'); + assert.ok(fs.existsSync(validatorPath), `Missing validator script: ${validatorPath}`); + + const okResult = runNode([validatorPath, '--config', validPath, '--json'], 'config validator valid fixture', root, rootEnv, { + stdio: 'pipe' + }); + assert.equal(okResult.status, 0, okResult.stderr || okResult.stdout); + assert.equal(JSON.parse(okResult.stdout || '{}').ok, true); + + const anyOfResult = runNode([validatorPath, '--config', validAnyOfPath, '--json'], 'config validator anyOf fixture', root, rootEnv, { + stdio: 'pipe' + }); + assert.equal(anyOfResult.status, 0, anyOfResult.stderr || anyOfResult.stdout); + assert.equal(JSON.parse(anyOfResult.stdout || '{}').ok, true); + + const badResult = runNode([validatorPath, '--config', invalidPath, '--json'], 'config validator invalid fixture', root, rootEnv, { + stdio: 'pipe', + allowFailure: true + }); + assert.notEqual(badResult.status, 0); + const badPayload = JSON.parse(badResult.stdout || '{}'); + assert.equal(badPayload.ok, false); + assert.ok(Array.isArray(badPayload.errors) && badPayload.errors.length > 0); +} + +{ + const repo = repoRoot(); + const scriptPath = path.join(repo, 'tools', 'config', 'dump.js'); + const result = runNode([scriptPath, '--json'], 'config dump json', repo, rootEnv, { stdio: 'pipe' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout || '{}'); + assert.ok(payload.repoRoot); + assert.ok(payload.derived?.cacheRoot); + assert.ok(payload.derived?.capabilityManifest?.surfaces?.api?.workflowCapabilities?.search); +} + +console.log('tooling config contract matrix test passed'); diff --git a/tests/tooling/config/generate-demo-config-output.test.js b/tests/tooling/config/generate-demo-config-output.test.js new file mode 100644 index 000000000..9cbf0b6c6 --- /dev/null +++ b/tests/tooling/config/generate-demo-config-output.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parseJsoncText } from '../../../src/shared/jsonc.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const { dir: tempRoot } = await prepareTestCacheDir('generate-demo-config-output', { root }); +const scriptPath = path.join(root, 'tools', 'config', 'generate-demo-config.js'); +const outPath = path.join(tempRoot, 'nested', 'demo.pairofcleats.json'); +const env = applyTestEnv({ syncProcess: false }); + +const result = runNode( + [scriptPath, '--out', outPath], + 'generate demo config output', + root, + env, + { stdio: 'pipe', timeoutMs: 30_000 } +); + +const output = await fs.readFile(outPath, 'utf8'); +const parsed = parseJsoncText(output, outPath); +assert.equal(typeof parsed, 'object', 'expected generated demo config to parse as an object'); +assert.ok(parsed.indexing, 'expected generated demo config to include indexing config'); +assert.ok(result.stderr.includes(`Wrote ${outPath}`), 'expected write summary on stderr'); + +console.log('generate demo config output test passed'); diff --git a/tests/tooling/config/indexing-profile-config.test.js b/tests/tooling/config/indexing-profile.test.js similarity index 100% rename from tests/tooling/config/indexing-profile-config.test.js rename to tests/tooling/config/indexing-profile.test.js diff --git a/tests/tooling/config/profile-config.test.js b/tests/tooling/config/profile-config.test.js deleted file mode 100644 index 2625b2417..000000000 --- a/tests/tooling/config/profile-config.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadUserConfig } from '../../../tools/shared/dict-utils.js'; - -const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-profile-')); -const configPath = path.join(tempRoot, '.pairofcleats.json'); - -try { - await fsPromises.writeFile( - configPath, - JSON.stringify({ profile: 'lite' }, null, 2), - 'utf8' - ); - try { - loadUserConfig(tempRoot); - console.error('Expected profile config to be rejected.'); - process.exit(1); - } catch (err) { - const message = String(err?.message || ''); - if (!message.includes('profile')) { - console.error('Expected profile config error to mention profile.'); - process.exit(1); - } - } -} finally { - await fsPromises.rm(tempRoot, { recursive: true, force: true }); -} - -console.log('profile-config rejection test passed'); diff --git a/tests/tooling/dict-utils/paths-builds-root.test.js b/tests/tooling/dict-utils/paths-builds-root.test.js index 4c65e07dd..b799d25c3 100644 --- a/tests/tooling/dict-utils/paths-builds-root.test.js +++ b/tests/tooling/dict-utils/paths-builds-root.test.js @@ -1,45 +1,28 @@ #!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { getBuildsRoot, getRepoId } from '../../../tools/dict-utils/paths.js'; import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'dict-utils-builds'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const savedEnv = { ...process.env }; -const restoreEnv = () => { - for (const key of Object.keys(process.env)) { - if (!(key in savedEnv)) delete process.env[key]; - } - for (const [key, value] of Object.entries(savedEnv)) { - process.env[key] = value; - } -}; - -applyTestEnv(); -try { - process.env.PAIROFCLEATS_CACHE_ROOT = path.join(tempRoot, 'cache'); +const { dir: tempRoot } = await prepareTestCacheDir('dict-utils-builds', { root }); +const cacheRoot = path.join(tempRoot, 'cache'); +await withTemporaryEnv(applyTestEnv({ cacheRoot, syncProcess: false }), async () => { const repoRoot = path.join(tempRoot, 'repo'); await fs.mkdir(repoRoot, { recursive: true }); const expected = path.join( - resolveVersionedCacheRoot(process.env.PAIROFCLEATS_CACHE_ROOT), + resolveVersionedCacheRoot(cacheRoot), 'repos', getRepoId(repoRoot), 'builds' ); assert.equal(getBuildsRoot(repoRoot), expected); +}); - console.log('dict-utils builds root test passed'); -} finally { - restoreEnv(); -} +console.log('dict-utils builds root test passed'); diff --git a/tests/tooling/docs/artifact-schema-index.test.js b/tests/tooling/docs/artifact-schema-index.test.js deleted file mode 100644 index f7c3773aa..000000000 --- a/tests/tooling/docs/artifact-schema-index.test.js +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildArtifactSchemaIndex } from '../../../src/shared/artifact-schema-index.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.resolve(__dirname, '../../..'); -const targetPath = path.join(root, 'docs', 'contracts', 'artifact-schema-index.json'); - -const expected = buildArtifactSchemaIndex(); -const raw = await fs.readFile(targetPath, 'utf8'); -const actual = JSON.parse(raw); - -assert.deepStrictEqual(actual, expected); -console.log('artifact schema index matches registry'); diff --git a/tests/tooling/docs/config-inventory-sync.test.js b/tests/tooling/docs/config-inventory-sync.test.js deleted file mode 100644 index 02c70f526..000000000 --- a/tests/tooling/docs/config-inventory-sync.test.js +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const contractPath = path.join(root, 'docs', 'config', 'contract.md'); - -const text = await fs.readFile(contractPath, 'utf8'); -assert.ok(text.includes('indexing.embeddings.cache.scope'), 'missing embeddings cache scope in contract'); -assert.ok(text.includes('tooling.vfs'), 'missing tooling.vfs entries in contract'); - -console.log('config inventory sync test passed'); diff --git a/tests/tooling/docs/contract-matrix.test.js b/tests/tooling/docs/contract-matrix.test.js new file mode 100644 index 000000000..4f221215c --- /dev/null +++ b/tests/tooling/docs/contract-matrix.test.js @@ -0,0 +1,1183 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildArtifactSchemaIndex } from '../../../src/contracts/artifact-schema-index.js'; +import { USR_REPORT_SCHEMA_DEFS, USR_SCHEMA_DEFS } from '../../../src/contracts/schemas/usr.js'; +import { loadRunRules } from '../../runner/run-config.js'; +import { + applyFilters, + assignLane, + buildTags, + compileMatchers, + discoverTests, + resolveLanes +} from '../../runner/run-discovery.js'; + +const root = process.cwd(); +const CURRENT_READINESS_GATE_LOG = 'temp/validation/readiness-gate-current-technical-validation-20260522.log'; +const LATEST_EVIDENCE_CITATION_LOG = 'temp/validation/readiness-evidence-citation-final-20260522.log'; +const CURRENT_ROADMAP_AUDIT_DATE = '2026-05-22'; + +const splitMarkdownRow = (line) => line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); + +const checkboxState = (text, label) => { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`^- \\[([ xX])\\] ${escaped}$`, 'm')); + assert.ok(match, `missing checklist row: ${label}`); + return match[1].toLowerCase(); +}; + +const assertChecked = (text, label) => { + assert.equal(checkboxState(text, label), 'x', `expected checked row: ${label}`); +}; + +const readMarkdownTableAfterHeading = (text, heading) => { + const lines = text.split(/\r?\n/); + const headingIndex = lines.findIndex((line) => line.trim() === heading); + assert.notEqual(headingIndex, -1, `missing markdown heading ${heading}`); + + const headerIndex = lines.findIndex((line, index) => index > headingIndex && line.trim().startsWith('|')); + assert.notEqual(headerIndex, -1, `missing markdown table after ${heading}`); + const header = splitMarkdownRow(lines[headerIndex]); + const separator = splitMarkdownRow(lines[headerIndex + 1] || ''); + assert.equal(separator.length, header.length, `markdown table after ${heading} has malformed separator`); + assert.ok(separator.every((cell) => /^:?-{3,}:?$/.test(cell)), `markdown table after ${heading} has invalid separator`); + + const rows = []; + for (let index = headerIndex + 2; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line.startsWith('|')) break; + const cells = splitMarkdownRow(line); + assert.equal(cells.length, header.length, `markdown table row after ${heading} has wrong cell count`); + rows.push(Object.fromEntries(header.map((name, cellIndex) => [name, cells[cellIndex]]))); + } + return { header, rows }; +}; + +const durationToMs = (duration) => { + const match = duration.trim().match(/^(\d+(?:\.\d+)?)(ms|s)$/); + if (!match) return null; + const value = Number(match[1]); + return match[2] === 's' ? value * 1000 : value; +}; + +const extractTimedTestDurations = (logText) => [...logText.matchAll(/\b(?:PASS|FAIL|TIME(?:OUT)?)\s+\[\s*([0-9.]+(?:ms|s))\]/g)] + .map((match) => durationToMs(match[1])) + .filter((value) => value !== null); + +const parseReleasePlanRunnerCommand = (line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith('node tests/run.js ')) return null; + if (trimmed.includes('')) return null; + const tokens = trimmed + .slice('node tests/run.js '.length) + .trim() + .split(/\s+/) + .map((token) => token.replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + const selectors = []; + const lanes = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === '--') break; + if (token === '--lane') { + if (tokens[index + 1]) lanes.push(tokens[index + 1]); + index += 1; + continue; + } + if (token.startsWith('--lane=')) { + lanes.push(token.slice('--lane='.length)); + continue; + } + if (token.startsWith('--')) { + if (!token.includes('=') && tokens[index + 1] && !tokens[index + 1].startsWith('--')) index += 1; + continue; + } + selectors.push(token); + } + return { line: trimmed, selectors, lanes }; +}; + +const selectTestsForCommand = ({ command, selector = null, tests, runRules }) => { + const requestedLanes = command.lanes.length ? command.lanes : ['ci']; + const resolvedLanes = resolveLanes(requestedLanes, runRules.knownLanes); + const includeMatchers = compileMatchers(selector ? [selector] : command.selectors, 'release-plan-selector'); + const { selected, skipped } = applyFilters({ + tests, + lanes: resolvedLanes, + includeMatchers, + excludeMatchers: [], + tagInclude: [], + tagExclude: [] + }); + return { selected, skipped }; +}; + +assert.deepEqual( + extractTimedTestDurations('PASS [ 12ms] a\nTIME [ 30.2s] b\nTIMEOUT [ 31s] c\nFAIL [ 1.5s] d'), + [12, 30200, 31000, 1500], + 'release evidence parser must count PASS, FAIL, TIME, and TIMEOUT duration rows' +); + +const walkMarkdownAndJsonDocs = (dir, out = []) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'archived') continue; + walkMarkdownAndJsonDocs(abs, out); + continue; + } + if (entry.isFile() && /\.(md|json)$/i.test(entry.name)) out.push(abs); + } + return out; +}; + +const backtickReferenceValuesByLine = (text) => { + const refs = []; + const lines = text.split(/\r?\n/); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + for (const match of line.matchAll(/`([^`]+)`/g)) { + refs.push({ + value: match[1].trim(), + line, + lineNumber: lineIndex + 1 + }); + } + } + return refs; +}; + +const expandSimpleBraceAlternates = (value) => { + const match = value.match(/^(.*?)\{([^{}]+)\}(.*)$/); + if (!match || !match[2].includes(',')) return [value]; + const [, prefix, body, suffix] = match; + return body + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .flatMap((part) => expandSimpleBraceAlternates(`${prefix}${part}${suffix}`)); +}; + +{ + const allowedHistoricalReferenceFiles = new Set([ + path.join(root, 'docs', 'roadmap.md'), + path.join(root, 'docs', 'roadmap-release-validation-plan.md'), + path.join(root, 'docs', 'tooling', 'repo-inventory.json') + ]); + const staleRoadmapPatterns = [ + /\bAINTKNOWMAP\.md\b/i, + /\bFUTUREROADMAP\b/i, + /\bGIGAMAP\b/i, + /\bGIGAROADMAP(?:_2)?(?:\.md)?\b/i, + /\bLEXI\.md\b/i, + /\bSTAGE1_ORDERED_THROUGHPUT_REDESIGN\b/i, + /\bTES_LAYN_(?:ROADMAP|EXECUTION_PACKS|GOVERNANCE)(?:\.md)?\b/i + ]; + const issues = []; + for (const abs of walkMarkdownAndJsonDocs(path.join(root, 'docs'))) { + if (allowedHistoricalReferenceFiles.has(abs)) continue; + const rel = path.relative(root, abs).replace(/\\/g, '/'); + const text = fs.readFileSync(abs, 'utf8'); + for (const pattern of staleRoadmapPatterns) { + if (pattern.test(text)) issues.push(`${rel} references stale roadmap name ${pattern}`); + } + } + assert.deepEqual(issues, [], 'active docs must use docs/roadmap.md instead of stale root roadmap names'); +} + +{ + const activeStatusDocs = [ + 'docs/roadmap.md', + 'docs/roadmap-release-validation-plan.md', + 'docs/roadmap-release-validation-evidence-20260521.md', + 'docs/archived/usr-rollout-approval-lock.md', + 'docs/tooling/duplication-reduction-status.md' + ]; + const filesystemPrefixes = [ + 'docs/', + 'src/', + 'tests/', + 'tools/', + 'bin/', + 'extensions/', + 'sublime/', + 'assets/', + 'benchmarks/', + 'rules/', + 'temp/', + '.testLogs/', + '.github/' + ]; + const historicalLineMarkers = [ + 'deleted', + 'removed', + 'archived', + 'deprecated', + 'historical', + 'former ', + 'old ', + 'superseded', + 'moved ', + 'moved from', + 'has been removed', + 'has been deleted' + ]; + const missing = []; + for (const rel of activeStatusDocs) { + const abs = path.join(root, rel.replace(/\//g, path.sep)); + const text = fs.readFileSync(abs, 'utf8'); + for (const ref of backtickReferenceValuesByLine(text)) { + const token = ref.value; + if (!/[\\/]/.test(token)) continue; + if (/\s/.test(token)) continue; + if (/[*?<>]/.test(token)) continue; + if (/\.\.\./.test(token)) continue; + if (token.startsWith('/') || token.startsWith('--')) continue; + const candidates = expandSimpleBraceAlternates(token) + .map((candidate) => candidate.replace(/[.,;:]$/u, '')); + for (const candidate of candidates) { + const normalized = candidate.replace(/\\/g, '/'); + if (!filesystemPrefixes.some((prefix) => normalized.startsWith(prefix))) continue; + const candidatePath = path.join(root, candidate.replace(/\//g, path.sep)); + if (fs.existsSync(candidatePath)) continue; + const lineLower = ref.line.toLowerCase(); + if (historicalLineMarkers.some((marker) => lineLower.includes(marker))) continue; + missing.push(`${rel}:${ref.lineNumber} references missing active path ${candidate}`); + } + } + } + assert.deepEqual(missing, [], 'active roadmap/status docs must not reference missing concrete file paths'); +} + +{ + const guidePath = path.join(root, 'docs', 'guides', 'perfplan-execution.md'); + const text = await fsPromises.readFile(guidePath, 'utf8'); + assert.ok(text.includes('# PERFPLAN Execution Guide')); + assert.ok(/ROADMAP\.md|roadmap/i.test(text)); + assert.ok(text.includes('docs/guides/roadmap-checklists.md')); + assert.ok(text.includes('docs/guides/jsdoc-standards.md')); +} + +{ + const docPath = path.join(root, 'docs', 'guides', 'jsdoc-standards.md'); + const text = await fsPromises.readFile(docPath, 'utf8'); + assert.ok(text.includes('# JSDoc Standards')); + assert.ok(text.includes('## Required sections')); + assert.ok(text.includes('Performance')); + assert.ok(text.includes('## Examples')); +} + +{ + const docPath = path.join(root, 'docs', 'specs', 'embeddings-cache.md'); + const text = await fsPromises.readFile(docPath, 'utf8'); + assert.ok(text.includes('# Embeddings Cache')); + assert.ok(text.includes('## Layout')); + assert.ok(/##\s+Cache entry format/i.test(text)); + assert.ok(text.includes('## Invalidation')); + assert.ok(text.includes('## Pruning')); + assert.ok(text.includes('## Configuration')); +} + +{ + const requiredDocs = [ + 'docs/specs/vfs-manifest-artifact.md', + 'docs/specs/vfs-index.md', + 'docs/specs/vfs-hash-routing.md', + 'docs/specs/vfs-token-uris.md', + 'docs/specs/vfs-io-batching.md', + 'docs/specs/vfs-segment-hash-cache.md', + 'docs/specs/vfs-cdc-segmentation.md', + 'docs/specs/vfs-cold-start-cache.md', + 'docs/specs/tooling-provider-registry.md', + 'docs/specs/tooling-vfs-and-segment-routing.md', + 'docs/specs/map-artifact.md', + 'docs/perf/map-pipeline.md' + ]; + for (const rel of requiredDocs) { + const abs = path.join(root, rel); + await fsPromises.access(abs); + } +} + +{ + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const targetPath = path.join(path.resolve(__dirname, '../../..'), 'docs', 'contracts', 'artifact-schema-index.json'); + const expected = buildArtifactSchemaIndex(); + const raw = await fsPromises.readFile(targetPath, 'utf8'); + const actual = JSON.parse(raw); + assert.deepStrictEqual(actual, expected); +} + +{ + const tablePath = path.join(root, 'docs', 'testing', 'truth-table.md'); + const raw = fs.readFileSync(tablePath, 'utf8'); + const lines = raw.split(/\r?\n/); + const claims = []; + let current = null; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const trimmed = line.trim(); + if (trimmed.startsWith('- Claim:')) { + if (current) claims.push(current); + current = { line: i + 1, lines: [line] }; + continue; + } + if (current) { + if (trimmed.startsWith('## ') || trimmed.startsWith('# ')) { + claims.push(current); + current = null; + continue; + } + current.lines.push(line); + } + } + if (current) claims.push(current); + assert.ok(claims.length > 0, 'Truth table validation failed: no claims found.'); + + const requiredLabels = ['Implementation:', 'Config:', 'Tests:', 'Limitations:']; + const issues = []; + const findLabelLine = (blockLines, label) => blockLines.find((line) => line.includes(label)) || null; + for (const claim of claims) { + const blockText = claim.lines.join('\n'); + for (const label of requiredLabels) { + const line = findLabelLine(claim.lines, label); + if (!line) { + issues.push(`Claim at line ${claim.line} missing ${label}`); + continue; + } + const content = line.split(label)[1]; + if (!content || !content.trim()) { + issues.push(`Claim at line ${claim.line} has empty ${label}`); + } + } + const testsLine = findLabelLine(claim.lines, 'Tests:'); + if (testsLine && !/tests\//.test(testsLine)) { + issues.push(`Claim at line ${claim.line} Tests line missing tests/ reference`); + } + if (!testsLine && /Tests:/.test(blockText)) { + issues.push(`Claim at line ${claim.line} has malformed Tests line`); + } + } + assert.deepEqual(issues, []); +} + +{ + const contractPath = path.join(root, 'docs', 'config', 'contract.md'); + const text = await fsPromises.readFile(contractPath, 'utf8'); + assert.ok(text.includes('indexing.embeddings.cache.scope')); + assert.ok(text.includes('tooling.vfs')); +} + +{ + const phase8Specs = [ + { + rel: path.join('docs', 'specs', 'lsp-provider-hardening.md'), + labels: [ + 'Provider can return hover/signature results for `.poc-vfs/...` virtual paths.', + 'Provider outputs are keyed by `chunkUid`.', + 'Restart races do not corrupt active sessions (generation token test).', + 'Failure counts reflect per-target failures, not per-attempt.' + ] + }, + { + rel: path.join('docs', 'specs', 'tooling-vfs-and-segment-routing.md'), + labels: [ + 'Embedded TS/JS segments inside `.md/.vue/.svelte/.astro` are routed to the correct provider.', + 'All tool outputs can be joined back to chunks by `chunkUid`.', + 'Offset mapping is validated and fails closed in strict mode.' + ] + } + ]; + for (const spec of phase8Specs) { + const text = await fsPromises.readFile(path.join(root, spec.rel), 'utf8'); + assert.match( + text, + /Status: Implemented for the required acceptance criteria.*temp\/validation\/lsp-vfs-focused-spec-acceptance-20260521\.log/, + `${spec.rel} must record current implemented status and focused evidence` + ); + for (const label of spec.labels) { + assertChecked(text, label); + } + } +} + +{ + const toolingApiSpec = await fsPromises.readFile( + path.join(root, 'docs', 'specs', 'tooling-and-api-contract.md'), + 'utf8' + ); + assert.match( + toolingApiSpec, + /\*\*Status:\*\* Active implemented public tooling\/API contract; current MCP schema version is `1\.4\.1`\./, + 'tooling/API contract spec must record active implemented status and current MCP schema version' + ); + assert.doesNotMatch( + toolingApiSpec, + /Proposed \(Codex-ready\)|Explicit required code fix|src\/shared\/schema-version\.js|MCP Streamable HTTP transport is not implemented here/, + 'tooling/API contract spec must not retain stale draft or pre-fix implementation language' + ); + + const httpApiSpec = await fsPromises.readFile(path.join(root, 'docs', 'specs', 'http-api.md'), 'utf8'); + assert.match( + httpApiSpec, + /\*\*Implementation status:\*\* active implemented contract.*workspacePath-based federated search/, + 'HTTP API spec must record current implemented snapshot/diff/as-of/federated surface status' + ); + assert.match( + httpApiSpec, + /`workspacePath` \(required, allowlisted\)[\s\S]*`workspaceId` \(optional cross-check against the resolved workspace\)/, + 'HTTP API spec must document workspacePath as required and workspaceId as a cross-check' + ); + assert.doesNotMatch( + httpApiSpec, + /federation pending|pending Phase 14\.6|\(new\)/, + 'HTTP API spec must not retain stale pending/new route language for implemented surfaces' + ); +} + +{ + const dispatcherSpec = await fsPromises.readFile( + path.join(root, 'docs', 'specs', 'dispatcher-rewrite-and-search-reconciliation.md'), + 'utf8' + ); + assert.match( + dispatcherSpec, + /## 3\. Completed reconciliation[\s\S]*tests\/cli\/general\/cli\.test\.js/, + 'dispatcher reconciliation spec must record search pass-through as completed with regression coverage' + ); + assert.match( + dispatcherSpec, + /### 3\.2 Strict dispatch mode[\s\S]*PAIROFCLEATS_DISPATCH_STRICT=1[\s\S]*--strict-dispatch/, + 'dispatcher strict-mode work must be recorded as active opt-in behavior' + ); + assert.doesNotMatch( + dispatcherSpec, + /Problem statement \(current repo state\)|Required changes \(immediate reconciliation\)|Correctness tests to add immediately/, + 'dispatcher reconciliation spec must not retain stale current/immediate pre-fix wording' + ); + + const spillMergeSpec = await fsPromises.readFile(path.join(root, 'docs', 'specs', 'spill-merge-framework.md'), 'utf8'); + assert.match( + spillMergeSpec, + /Status: Active implemented shared spill\/merge contract\./, + 'spill/merge spec must record active implemented status' + ); + assert.match( + spillMergeSpec, + /mergeRunsWithPlanner[\s\S]*createRowSpillCollector[\s\S]*createSpillSorter/, + 'spill/merge spec must name the live shared merge, row-spill, and map-sorter APIs' + ); + assert.match( + spillMergeSpec, + /tests\/shared\/merge\/contract-matrix\.test\.js/, + 'spill/merge spec must cite current shared merge contract coverage' + ); + assert.doesNotMatch( + spillMergeSpec, + /^- createSpillWriter\(config\)$/m, + 'spill/merge spec must not present old conceptual API names as live exports' + ); + + const architectureGuide = await fsPromises.readFile(path.join(root, 'docs', 'guides', 'architecture.md'), 'utf8'); + assert.match( + architectureGuide, + /docs\/specs\/json-stream-atomic-replace\.md[\s\S]*Historical context only: docs\/archived\/watch-atomicity\.md/, + 'architecture guide must pair active atomic replacement docs with archived watch-atomicity as historical context' + ); + + const usrUmbrella = await fsPromises.readFile(path.join(root, 'docs', 'specs', 'unified-syntax-representation.md'), 'utf8'); + assert.match( + usrUmbrella, + /Status: Active USR umbrella contract v1\.6; current branch technical rollout evidence is green\./, + 'USR umbrella must record active umbrella status with local technical evidence green' + ); + assert.match( + usrUmbrella, + /## 21\. Integration Checkpoint[\s\S]*Remaining movement is not another local implementation task list/, + 'USR umbrella must classify former immediate tasks as checkpointed local technical coverage' + ); + assert.doesNotMatch( + usrUmbrella, + /Status: Draft v1\.6|## 21\. Immediate Integration Tasks/, + 'USR umbrella must not retain stale draft/immediate-task status' + ); +} + +{ + const roadmapPath = path.join(root, 'docs', 'roadmap.md'); + const roadmapText = await fsPromises.readFile(roadmapPath, 'utf8'); + assert.ok( + roadmapText.includes(`Last audited: ${CURRENT_ROADMAP_AUDIT_DATE}`), + 'roadmap header must reflect the latest current evidence-citation audit date' + ); + assert.ok( + roadmapText.includes(`Current reconciliation, ${CURRENT_ROADMAP_AUDIT_DATE}:`), + 'roadmap USR reconciliation heading must reflect the latest current evidence-citation audit date' + ); + const initiativesTable = readMarkdownTableAfterHeading(roadmapText, '## Current Initiatives'); + assert.deepEqual( + initiativesTable.header, + ['Initiative', 'Status', 'Done now', 'Remaining / next'], + 'roadmap current initiatives table must keep the canonical status shape' + ); + const initiativeRows = initiativesTable.rows; + const initiativeByName = new Map(initiativeRows.map((row) => [row.Initiative, row])); + assert.deepEqual([...initiativeByName.keys()], [ + 'Stage1 ordered throughput cutover', + 'Phase 10 interprocedural risk flows', + 'Phase 14 IndexRefs, snapshots, diffs, and as-of retrieval', + 'Lexicon, relation boosts, chargram enrichment, and ANN candidate safety', + 'USR consolidated contract and rollout program', + 'Shared-module reduction', + 'Duplicate-code reduction', + 'Production readiness', + 'Phase 0.5 language/framework execution contract', + 'Worklogs and benchmark JSON under `docs/worklogs/**`' + ], 'roadmap current initiatives table must preserve the consolidated initiative set and order'); + assert.deepEqual( + initiativeRows + .filter((row) => /`remaining`|`blocked\/unverifiable`/.test(row.Status)) + .map((row) => row.Initiative), + [], + 'roadmap must not leave local top-level initiatives in remaining or blocked/unverifiable status' + ); + assert.deepEqual( + initiativeRows + .filter((row) => row.Status === '`in progress`') + .map((row) => row.Initiative), + [], + 'roadmap must not leave approval-only top-level initiatives in progress' + ); + assert.match( + initiativeByName.get('USR consolidated contract and rollout program')?.['Remaining / next'] || '', + /former approval-lock process is archived.*not a release blocker/, + 'USR top-level remaining status must archive approval paperwork instead of blocking on it' + ); + assert.match( + initiativeByName.get('Shared-module reduction')?.['Remaining / next'] || '', + /No known shared-module implementation batch remains open/, + 'shared-module top-level row must not imply a hidden local implementation batch' + ); + assert.match( + initiativeByName.get('Duplicate-code reduction')?.['Remaining / next'] || '', + /future intentional full audit refresh, not ad hoc rework of stale saved-report entries/, + 'duplicate-code top-level row must preserve the saved-report checkpoint policy' + ); + assert.match( + initiativeByName.get('Production readiness')?.['Remaining / next'] || '', + /Keep production verification and release-readiness evidence green/, + 'production readiness row must tie release status to technical validation' + ); + assert.ok( + (initiativeByName.get('Production readiness')?.['Done now'] || '').includes(LATEST_EVIDENCE_CITATION_LOG), + 'production readiness top-level row must cite the final current evidence-citation validation log' + ); + assert.match( + roadmapText, + new RegExp(`Keep release validation evidence current[\\s\\S]*${LATEST_EVIDENCE_CITATION_LOG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + 'roadmap current release-evidence checklist must cite the final current evidence-citation validation log' + ); + assert.match( + initiativeByName.get('Stage1 ordered throughput cutover')?.['Done now'] || '', + /Contiguous window planning.*commit cursor ordering.*no-gap-recovery assertions.*targeted Stage1 tests/, + 'Stage1 row must keep concrete implementation/test evidence anchors' + ); + assert.match( + initiativeByName.get('Stage1 ordered throughput cutover')?.['Remaining / next'] || '', + /perf and memory budget tests in the release gate/, + 'Stage1 row must keep its release-gate perf/memory proof anchor' + ); + assert.match( + initiativeByName.get('Phase 10 interprocedural risk flows')?.['Done now'] || '', + /risk summaries\/flows\/call-sites artifacts.*risk-interprocedural validator.*perf-quality proof/, + 'Phase 10 row must keep risk artifact implementation and release evidence anchors' + ); + assert.match( + initiativeByName.get('Phase 14 IndexRefs, snapshots, diffs, and as-of retrieval')?.['Done now'] || '', + /`src\/index\/index-ref\.js`.*`tools\/index-snapshot\.js`.*API routes.*api-search-asof-release-proof-fix/, + 'Phase 14 row must keep snapshot/diff/as-of implementation and proof anchors' + ); + assert.match( + initiativeByName.get('Lexicon, relation boosts, chargram enrichment, and ANN candidate safety')?.['Done now'] || '', + /lexicon loader\/wordlists.*relation boost scoring.*chargram field\/stopword config.*ANN candidate policy.*tests are present/, + 'lexicon/retrieval row must keep implementation/test anchors' + ); + assert.match( + roadmapText, + /those TUI build smoke files are explicit no-adopt\/local-wrapper owners.*not an open shared-module batch/, + 'roadmap must classify the abandoned TUI build-smoke wrapper migration as historical/no-adopt, not open work' + ); + assert.match( + roadmapText, + /tooling\/install\/detect-and-plan-contract-matrix.*historical evidence for an intentionally abandoned wrapper migration, not as an open roadmap task/, + 'roadmap must classify the abandoned detect-and-plan wrapper migration as historical, not open work' + ); + + const validationSection = roadmapText.match(/## Validation Commands\r?\n([\s\S]*?)$/); + assert.ok(validationSection, 'roadmap must define a Validation Commands section'); + const validationCommandBlock = validationSection[1].match(/```powershell\r?\n([\s\S]*?)\r?\n```/); + assert.ok(validationCommandBlock, 'roadmap validation section must include a PowerShell command block'); + assert.doesNotMatch( + validationCommandBlock[1], + /npm run audit:duplicates/, + 'generic roadmap validation must not rerun jscpd for ordinary doc/status changes' + ); + assert.match( + validationCommandBlock[1], + /ci\/markdown-link-check/, + 'generic roadmap validation must include markdown link checking' + ); + assert.match( + validationCommandBlock[1], + /tooling\/docs\/contract-matrix/, + 'generic roadmap validation must include the docs contract matrix guard' + ); + assert.match( + validationCommandBlock[1], + /tooling\/docs\/usr-contract-checklists/, + 'generic roadmap validation must include the USR technical checklist guard' + ); + assert.match( + validationCommandBlock[1], + /node tools\/docs\/generated-surfaces\.js --check-freshness/, + 'generic roadmap validation must check generated-surface freshness' + ); + assert.match( + validationCommandBlock[1], + /git diff --check/, + 'generic roadmap validation must include whitespace validation' + ); + const duplicateLane = roadmapText.match(/### Lane 2: Duplicate-Code Reduction\r?\n([\s\S]*?)(?:\r?\n### Lane 3:|$)/); + assert.ok(duplicateLane, 'roadmap must keep a duplicate-code reduction lane'); + assert.match( + duplicateLane[1], + /future intentional full (?:duplicate )?audit refresh|future intentional full duplicate baseline refresh/, + 'duplicate lane must describe audit reruns as future intentional baseline refreshes' + ); + assert.match( + duplicateLane[1], + /Do not rerun `jscpd` for this checkpoint/, + 'duplicate lane must preserve the no-repeat-jscpd checkpoint policy' + ); + + const duplicateStatusPath = path.join(root, 'docs', 'tooling', 'duplication-reduction-status.md'); + const duplicateStatusText = await fsPromises.readFile(duplicateStatusPath, 'utf8'); + assert.match( + duplicateStatusText, + /Older completed-slice notes below may preserve then-current instructions to rerun `npm run audit:duplicates`; those are historical records/, + 'duplication status must mark older rerun instructions as historical under the current checkpoint policy' + ); + assert.match( + duplicateStatusText, + /Completed-slice `Acceptance tests` and `Future constraints` sections are retained as implementation evidence and safety guidance only; do not treat them as reopened work unless a future intentional audit or live regression supplies current proof\./, + 'duplication status must prevent completed-slice notes from being read as active reopened work' + ); + assert.doesNotMatch( + duplicateStatusText, + /^\| P[23] \|/m, + 'duplication status must not present saved-baseline residual categories as open P2/P3 implementation work' + ); + assert.doesNotMatch( + duplicateStatusText, + /^### P[23]:/m, + 'duplication status must not present saved-baseline residual sections as active P2/P3 work' + ); + assert.doesNotMatch( + duplicateStatusText, + /^Current signal:/m, + 'duplication status must not describe historical saved-baseline details as current signals' + ); + assert.doesNotMatch( + duplicateStatusText, + /^- Rerun `npm run audit:duplicates`/m, + 'duplication status must not keep active per-family rerun instructions outside future baseline policy' + ); + assert.match( + duplicateStatusText, + /## Historical Saved-Baseline Candidate Details/, + 'duplication status must label stale detailed candidate sections as historical saved-baseline detail' + ); + assert.doesNotMatch( + duplicateStatusText, + /Current production hits include/, + 'duplication status must not describe stale saved-report language residuals as current production hits' + ); + assert.doesNotMatch( + duplicateStatusText, + /Remaining current hotspot counts/, + 'duplication status must not describe historical saved-baseline hotspot counts as current remaining work' + ); + assert.doesNotMatch( + duplicateStatusText, + /remain separate follow-up work|remaining duplicate hits|Remaining duplicate hits|remaining hits in those files|Remaining `[^`]+` duplicate hits|Remaining [A-Za-z].*duplicate signal/, + 'duplication status must not describe saved-baseline duplicate residuals as active remaining work' + ); + assert.match( + duplicateStatusText, + /saved-report exact-current refresh found 0 still-current fragments/, + 'duplication status must preserve the checkpoint-clean exact-current duplicate evidence' + ); + assert.match( + duplicateStatusText, + /historical\/intermediate.*not current checkpoint proof/i, + 'duplication status must mark preserved timeout/failure logs as historical rather than current checkpoint proof' + ); +} + +{ + const releasePlanPath = path.join(root, 'docs', 'roadmap-release-validation-plan.md'); + const text = await fsPromises.readFile(releasePlanPath, 'utf8'); + const runRules = loadRunRules({ root }); + const discoveredTests = await discoverTests({ + testsDir: path.join(root, 'tests'), + excludedDirs: runRules.excludedDirs, + excludedFiles: runRules.excludedFiles + }); + const testsWithMetadata = discoveredTests.map((test) => { + const lane = assignLane(test.id, runRules.laneRules); + return { + ...test, + lane, + tags: buildTags(test.id, lane, runRules.tagRules) + }; + }); + + const runnerCommands = text + .split(/\r?\n/) + .map(parseReleasePlanRunnerCommand) + .filter(Boolean); + assert.ok(runnerCommands.length > 0, 'release validation plan must include runnable tests/run.js commands'); + assert.match( + text, + /Runner-recorded timeouts are blocking timeout failures/, + 'release validation plan must align timeout classification with the current test runner' + ); + assert.match( + text, + /node tests\/run\.js ci\/markdown-link-check tooling\/docs\/contract-matrix tooling\/docs\/usr-contract-checklists --lane=all --timeout-ms 30000/, + 'docs-and-governance lane must include the USR technical checklist guard' + ); + assert.match( + text, + /node tools\/docs\/generated-surfaces\.js --check-freshness/, + 'docs-and-governance lane must check generated-surface freshness' + ); + assert.match( + text, + /node tools\/docs\/repo-inventory\.js --root \. --json docs\/tooling\/repo-inventory\.json/, + 'docs-and-governance lane must include the repo-inventory refresh command cited by evidence' + ); + assert.match( + text, + /git diff --check/, + 'docs-and-governance lane must include whitespace validation' + ); + assert.doesNotMatch( + text, + /\.testLogs\/bench-sweet16\.json/, + 'optional advisory benchmark examples must not cite absent .testLogs artifacts as current evidence' + ); + for (const command of runnerCommands) { + assert.match(command.line, /--timeout-ms\s+30000|--timeout-ms=30000/, `release plan command must enforce 30s timeout: ${command.line}`); + assert.ok(command.lanes.length > 0, `release plan command must specify an explicit lane: ${command.line}`); + for (const lane of command.lanes.flatMap((value) => value.split(',').map((item) => item.trim()).filter(Boolean))) { + assert.ok(lane === 'all' || runRules.knownLanes.has(lane), `release plan command uses unknown lane ${lane}: ${command.line}`); + } + const { selected, skipped } = selectTestsForCommand({ command, tests: testsWithMetadata, runRules }); + assert.ok( + selected.length + skipped.length > 0, + `release plan command does not match any current runner tests: ${command.line}` + ); + for (const selector of command.selectors) { + const { selected: selectorSelected } = selectTestsForCommand({ command, selector, tests: testsWithMetadata, runRules }); + assert.ok( + selectorSelected.length > 0, + `release plan selector does not select any current non-skipped runner tests: ${selector} in ${command.line}` + ); + } + } + + const line = text.split(/\r?\n/) + .find((candidate) => candidate.includes('Required release artifacts are named and schema-backed')); + assert.ok(line, 'release validation plan must list required schema-backed USR artifacts'); + + const knownReportIds = new Set(Object.keys(USR_REPORT_SCHEMA_DEFS)); + const artifactNames = [...line.matchAll(/`([^`]+\.json)`/g)] + .map((match) => match[1].replace(/\.json$/, '')); + assert.ok(artifactNames.length > 0, 'release validation plan schema-backed artifact list is empty'); + + const unknownArtifacts = artifactNames.filter((artifactId) => !knownReportIds.has(artifactId)); + const missingArtifacts = [...knownReportIds].filter((artifactId) => !artifactNames.includes(artifactId)); + assert.deepEqual( + unknownArtifacts, + [], + 'release validation plan lists schema-backed USR artifacts that are not in src/contracts/schemas/usr.js' + ); + assert.deepEqual( + missingArtifacts, + [], + 'release validation plan must list every schema-backed USR report artifact' + ); +} + +{ + const evidencePath = path.join(root, 'docs', 'roadmap-release-validation-evidence-20260521.md'); + const evidenceText = await fsPromises.readFile(evidencePath, 'utf8'); + for (const requiredField of [ + 'Branch:', + 'Commit:', + 'Worktree state:', + 'Date:', + 'Captured at:', + 'Node:', + 'npm:', + 'OS:', + 'Native optional deps:' + ]) { + assert.ok(evidenceText.includes(requiredField), `release evidence bundle missing ${requiredField}`); + } + assert.match( + evidenceText, + /Worktree state: .*(?:clean|uncommitted|dirty|branch-local)/, + 'release evidence bundle must classify clean versus uncommitted worktree state' + ); + assert.match( + evidenceText, + /Captured at: `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`/, + 'release evidence bundle must record an ISO UTC capture timestamp' + ); + assert.match( + evidenceText, + /temp\/validation\/roadmap-final-current-status-validation-20260521\.log/, + 'release evidence bundle must cite the current roadmap/docs status validation log' + ); + assert.match( + evidenceText, + /temp\/validation\/usr-framework-c4-policy-lane-guard-validation-20260521\.log/, + 'release evidence bundle must cite the framework C4 policy lane guard validation log' + ); + assert.match( + evidenceText, + /temp\/validation\/usr-current-evidence-handoff-validation-20260522\.log/, + 'release evidence bundle must cite the current USR evidence handoff validation log' + ); + assert.match( + evidenceText, + /temp\/validation\/roadmap-dup-ledger-brace-guard-final-20260522\.log/, + 'release evidence bundle must cite the duplicate ledger and brace-path guard validation log' + ); + assert.ok( + evidenceText.includes(CURRENT_READINESS_GATE_LOG), + 'release evidence bundle must cite the current readiness-gate validation log' + ); + assert.ok( + evidenceText.includes(LATEST_EVIDENCE_CITATION_LOG), + 'release evidence bundle must cite the final current evidence-citation validation log' + ); + + const releasePlanPathForTemplate = path.join(root, 'docs', 'roadmap-release-validation-plan.md'); + const releasePlanTemplateText = await fsPromises.readFile(releasePlanPathForTemplate, 'utf8'); + assert.ok( + releasePlanTemplateText.includes(`Latest branch evidence as of ${CURRENT_ROADMAP_AUDIT_DATE}:`), + 'release validation plan current evidence snapshot must reflect the latest current evidence-citation audit date' + ); + assert.ok( + releasePlanTemplateText.includes(CURRENT_READINESS_GATE_LOG), + 'release validation plan must cite the current readiness-gate validation log' + ); + assert.ok( + releasePlanTemplateText.includes(LATEST_EVIDENCE_CITATION_LOG), + 'release validation plan must cite the final current evidence-citation validation log' + ); + const reportingTemplate = releasePlanTemplateText.match(/## Reporting Template\r?\n[\s\S]*?```markdown\r?\n([\s\S]*?)\r?\n```/); + assert.ok(reportingTemplate, 'release validation plan must keep a markdown reporting template'); + for (const templateField of [ + 'Branch:', + 'Commit:', + 'Worktree state:', + 'Date:', + 'Captured at:', + 'Node:', + 'npm:', + 'OS:', + 'Native optional deps:' + ]) { + assert.ok( + reportingTemplate[1].includes(`- ${templateField}`), + `release validation reporting template missing ${templateField}` + ); + } + + const laneTable = readMarkdownTableAfterHeading(evidenceText, '## Lane Evidence'); + assert.deepEqual(laneTable.header, [ + 'Lane', + 'Command', + 'Result', + 'Exit', + 'Elapsed', + 'Evidence', + 'Checked artifacts', + 'Blocker?', + 'Blocker owner/severity/action', + 'Waiver' + ], 'release evidence bundle lane table must preserve the release-plan evidence shape plus owner/waiver detail'); + const laneRows = laneTable.rows; + assert.equal(laneRows.length, 8, 'release evidence bundle must have exactly 8 lane rows'); + const seenLanes = new Set(); + for (const row of laneRows) { + const lane = row.Lane; + for (const column of laneTable.header) { + assert.ok(row[column], `release evidence row has empty ${column} cell: ${lane}`); + } + assert.match(lane, /^`[^`]+`$/, `release evidence lane cell must use backtick lane id: ${lane}`); + seenLanes.add(lane); + assert.match(row.Command, /(?:node tests\/run\.js|npm run verify:production)/, `release evidence command must cite runner or production verify command: ${lane}`); + assert.doesNotMatch( + row.Command, + /node tests\/(?!run\.js\b)/, + `release evidence test commands must use tests/run.js rather than direct test entrypoints: ${lane}` + ); + assert.match(row.Result, /pass/, `release evidence row must classify passing local proof: ${lane}`); + assert.match(row.Exit, /^0$/, `release evidence row must record exit code 0 for local proof: ${lane}`); + assert.match(row.Elapsed, /\b\d+(?:\.\d+)?(?:ms|s)\b/, `release evidence row must record elapsed time: ${lane}`); + const elapsedDurations = [...row.Elapsed.matchAll(/\b([0-9.]+(?:ms|s))\b/g)] + .map((match) => durationToMs(match[1])) + .filter((value) => value !== null); + if (elapsedDurations.some((durationMs) => durationMs > 30000)) { + assert.match( + row.Elapsed, + /command (?:exits|total)|lane log|perf lane/i, + `release evidence elapsed values over 30s must be labeled as command/lane totals, not per-test proof: ${lane}` + ); + } + assert.match(row.Evidence, /temp\/validation\/[^`]+\.log/, `release evidence row must cite validation log evidence: ${lane}`); + const testLogRefs = [...row['Checked artifacts'].matchAll(/\.testLogs\/run-[A-Za-z0-9-]+/g)] + .map((match) => match[0]); + assert.ok(testLogRefs.length > 0, `release evidence row must cite .testLogs timing artifacts: ${lane}`); + for (const rel of testLogRefs) { + assert.ok( + fs.existsSync(path.join(root, rel)), + `release evidence row cites missing .testLogs timing artifact: ${lane} ${rel}` + ); + } + assert.match(row['Blocker?'], /^(?:yes|no)$/, `release evidence blocker cell must be yes or no: ${lane}`); + assert.equal(row.Waiver, 'none', `release evidence row must not cite unrecorded waivers: ${lane}`); + if (lane === '`docs-and-governance`') { + assert.match( + row.Command, + /tooling\/docs\/usr-contract-checklists/, + 'docs-and-governance evidence row must include the USR technical checklist guard' + ); + assert.match( + row.Command, + /node tools\/docs\/generated-surfaces\.js --check-freshness/, + 'docs-and-governance evidence row must include generated-surface freshness' + ); + assert.match( + row.Command, + /node tools\/docs\/repo-inventory\.js --root \. --json docs\/tooling\/repo-inventory\.json/, + 'docs-and-governance evidence row must include repo-inventory refresh evidence' + ); + assert.match( + row.Command, + /git diff --check/, + 'docs-and-governance evidence row must include whitespace validation' + ); + assert.match( + row.Evidence, + /temp\/validation\/roadmap-validation-recipe-guard-final-20260521\.log/, + 'docs-and-governance evidence row must cite the final validation recipe guard' + ); + assert.match( + row.Evidence, + /temp\/validation\/roadmap-dup-ledger-brace-guard-final-20260522\.log/, + 'docs-and-governance evidence row must cite the duplicate ledger and brace-path guard' + ); + assert.ok( + row.Evidence.includes(LATEST_EVIDENCE_CITATION_LOG), + 'docs-and-governance evidence row must cite the final current evidence-citation validation log' + ); + } + if (lane === '`production-readiness`') { + assert.match( + row.Command, + /tooling\/release\/readiness-gate/, + 'production-readiness evidence row must include the release readiness-gate selector' + ); + assert.ok( + row.Evidence.includes(CURRENT_READINESS_GATE_LOG), + 'production-readiness evidence row must cite the current readiness-gate validation log' + ); + assert.ok( + row.Evidence.includes(LATEST_EVIDENCE_CITATION_LOG), + 'production-readiness evidence row must cite the final current evidence-citation validation log' + ); + assert.match( + row['Checked artifacts'], + /\.testLogs\/run-1779415726577-0jr2cp/, + 'production-readiness evidence row must cite the current readiness-gate timing artifact' + ); + assert.match( + row['Checked artifacts'], + /\.testLogs\/run-1779415736438-zs5bxc/, + 'production-readiness evidence row must cite the current docs guard timing artifact' + ); + } + assert.equal(row['Blocker?'], 'no', `release evidence lane must not be blocked by archived approval paperwork: ${lane}`); + assert.equal(row['Blocker owner/severity/action'], 'none', `unblocked lane must not carry blocker action text: ${lane}`); + + const citedLogs = [...row.Evidence.matchAll(/temp\/validation\/[A-Za-z0-9._/-]+\.log/g)] + .map((match) => match[0]); + assert.ok(citedLogs.length > 0, `release evidence row must cite at least one validation log: ${lane}`); + + let hasPassingProof = false; + for (const rel of citedLogs) { + const abs = path.join(root, rel); + if (!fs.existsSync(abs)) continue; + const logText = fs.readFileSync(abs, 'utf8'); + if (/\b(?:FAIL|TIME(?:OUT)?)\s+\[|Summary\s*:\s*\d+ Passed \| [1-9]\d* Failed|Summary\s*:\s*\d+ Passed \| \d+ Failed \| [1-9]\d* Timeouts/.test(logText)) { + assert.match( + `${row.Evidence} ${row['Checked artifacts']}`, + /final passing block|final pass/i, + `evidence log with historical failures must identify final passing proof: ${lane}` + ); + } + if (/Summary\s*:\s*\d+ Passed \| 0 Failed \| 0 Timeouts \| 0 Skipped/.test(logText) + || /\[exit-code\]\s*0/.test(logText) + || /\bexit 0\b/.test(logText) + || /\bexit=0\b/.test(logText) + || /passed without command failures/.test(logText) + || /deterministic release validation passed/.test(logText)) { + hasPassingProof = true; + } + + const timedTestDurations = extractTimedTestDurations(logText); + const overBudgetDurations = timedTestDurations.filter((durationMs) => durationMs > 30000); + assert.deepEqual(overBudgetDurations, [], `required release evidence has per-test durations over 30s: ${lane} ${rel}`); + } + assert.equal(hasPassingProof, true, `release evidence row must cite a log with explicit passing proof: ${lane}`); + } + assert.deepEqual([...seenLanes].sort(), [ + '`docs-and-governance`', + '`lexicon-retrieval`', + '`perf-quality-final`', + '`production-readiness`', + '`snapshot-diff-asof`', + '`risk-artifacts`', + '`stage1-contract`', + '`usr-gates`' + ].sort(), 'release evidence bundle must contain exactly the eight release lanes'); + const blockedLaneIds = laneRows + .filter((row) => row['Blocker?'] === 'yes') + .map((row) => row.Lane) + .sort(); + assert.deepEqual(blockedLaneIds, [], 'release evidence bundle must leave no lanes blocked by archived approval paperwork'); + assert.doesNotMatch( + evidenceText, + /Approval state: pending|usrApproval\.pending|USR-GATE-C-APPROVAL/, + 'release evidence must not preserve active approval blocker wording' + ); + assert.match(evidenceText, /No roadmap status is advanced/, 'release evidence bundle must document roadmap status handling'); + + const tempValidationDir = path.join(root, 'temp', 'validation'); + if (fs.existsSync(tempValidationDir)) { + const missingEvidenceLogs = [...evidenceText.matchAll(/temp\/validation\/[A-Za-z0-9._/-]+\.log/g)] + .map((match) => match[0]) + .filter((rel) => !fs.existsSync(path.join(root, rel))); + assert.deepEqual(missingEvidenceLogs, [], 'release evidence bundle must not cite missing local validation logs'); + } + + const skipsTable = readMarkdownTableAfterHeading(evidenceText, '## Skips And Timeouts'); + assert.deepEqual(skipsTable.header, ['Command', 'Classification', 'Reason', 'Follow-up']); + assert.ok(skipsTable.rows.length > 0, 'release evidence bundle must include a skips/timeouts table'); + assert.ok( + skipsTable.rows.some((row) => row.Classification === 'none'), + 'release evidence bundle must classify final cited skips/timeouts' + ); + + const blockersTable = readMarkdownTableAfterHeading(evidenceText, '## Blockers'); + assert.deepEqual(blockersTable.header, ['ID', 'Severity', 'Contract/spec', 'Owner', 'Required action']); + assert.deepEqual(blockersTable.rows, [], 'release evidence bundle must leave no active approval blocker rows'); + + const waiversTable = readMarkdownTableAfterHeading(evidenceText, '## Waivers'); + assert.deepEqual(waiversTable.header, ['Waiver', 'Scope', 'Expiry', 'Approver', 'Residual risk']); + assert.ok( + waiversTable.rows.some((row) => row.Waiver === 'none' && row.Expiry === 'none'), + 'release evidence bundle must explicitly record that no waiver is active' + ); +} + +{ + const knownReportIds = new Set(Object.keys(USR_REPORT_SCHEMA_DEFS)); + const schemaDir = path.join(root, 'docs', 'schemas', 'usr'); + const docsSchemaIds = fs.readdirSync(schemaDir) + .filter((name) => name.endsWith('.schema.json') && name !== 'evidence-envelope.schema.json') + .map((name) => name.replace(/\.schema\.json$/, '')) + .filter((artifactId) => knownReportIds.has(artifactId)) + .sort((left, right) => left.localeCompare(right)); + const registryIds = [...knownReportIds].sort((left, right) => left.localeCompare(right)); + assert.deepEqual(docsSchemaIds, registryIds, 'docs/schemas/usr report schemas must match src/contracts/schemas/usr.js'); + for (const artifactId of docsSchemaIds) { + const schemaPath = path.join(schemaDir, `${artifactId}.schema.json`); + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + assert.equal(schema.$id, `usr/${artifactId}.schema.json`, `${artifactId} schema must use canonical $id`); + assert.equal(schema.properties?.artifactId?.const, artifactId, `${artifactId} schema artifactId const must match file name`); + assert.ok((schema.required || []).includes('summary'), `${artifactId} schema must require summary`); + assert.ok((schema.required || []).includes('rows'), `${artifactId} schema must require rows`); + } + + for (const schemaId of ['usr-evidence-envelope', 'usr-capability-transition']) { + assert.ok(USR_SCHEMA_DEFS[schemaId], `${schemaId} must exist in USR schema registry`); + const schemaPath = path.join(schemaDir, `${schemaId}.schema.json`); + assert.equal(fs.existsSync(schemaPath), true, `${schemaId} must have docs schema coverage`); + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + assert.equal(schema.$id, `usr/${schemaId}.schema.json`, `${schemaId} schema must use canonical $id`); + } + + const catalogPath = path.join(root, 'docs', 'specs', 'usr-core-artifact-schema-catalog.md'); + const catalogText = await fsPromises.readFile(catalogPath, 'utf8'); + const catalogArtifactIds = new Set( + [...catalogText.matchAll(/^\| `([^`]+)` \| `docs\/schemas\/usr\/[^`]+\.schema\.json` \|/gm)] + .map((match) => match[1]) + ); + const missingCatalogRows = [...knownReportIds].filter((artifactId) => !catalogArtifactIds.has(artifactId)); + assert.deepEqual(missingCatalogRows, [], 'USR artifact schema catalog must list every report schema'); +} + +{ + const requiredOutputDocs = [ + 'docs/specs/usr-core-evidence-gates-waivers.md', + 'docs/specs/usr-core-artifact-schema-catalog.md', + 'docs/specs/usr-core-governance-change.md' + ]; + const knownReportIds = new Set(Object.keys(USR_REPORT_SCHEMA_DEFS)); + const missingSchemas = []; + const missingSchemaFiles = []; + for (const rel of requiredOutputDocs) { + const text = await fsPromises.readFile(path.join(root, rel), 'utf8'); + const match = text.match(/## Required outputs\r?\n\r?\n([\s\S]*?)(?:\r?\n## |\r?\n### |$)/); + assert.ok(match, `${rel} must define a Required outputs section`); + const outputIds = [...match[1].matchAll(/`([^`]+\.json)`/g)] + .map((outputMatch) => outputMatch[1].replace(/\.json$/, '')); + assert.ok(outputIds.length > 0, `${rel} required outputs section must list JSON artifacts`); + for (const outputId of outputIds) { + if (!knownReportIds.has(outputId)) missingSchemas.push(`${rel}: ${outputId}`); + const schemaPath = path.join(root, 'docs', 'schemas', 'usr', `${outputId}.schema.json`); + if (!fs.existsSync(schemaPath)) missingSchemaFiles.push(`${rel}: ${outputId}`); + } + } + assert.deepEqual(missingSchemas, [], 'USR required outputs must be schema-backed in src/contracts/schemas/usr.js'); + assert.deepEqual(missingSchemaFiles, [], 'USR required outputs must have docs/schemas/usr schema files'); +} + +console.log('tooling docs contract matrix test passed'); diff --git a/tests/tooling/docs/contributor-script-surface.test.js b/tests/tooling/docs/contributor-script-surface.test.js new file mode 100644 index 000000000..2d62d8df0 --- /dev/null +++ b/tests/tooling/docs/contributor-script-surface.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const scripts = Object.keys(pkg.scripts || {}).sort(); + +const expectedScripts = [ + 'bootstrap', + 'bootstrap:ci', + 'config:budget', + 'env:check', + 'format', + 'lint', + 'postinstall', + 'release:verify', + 'test', + 'test:api', + 'test:ci', + 'test:ci-lite', + 'test:ci-long', + 'test:perf', + 'test:services', + 'test:storage', + 'verify' +].sort(); + +assert.deepEqual( + scripts, + expectedScripts, + 'package.json should expose only the curated contributor npm surface' +); + +console.log('contributor script surface test passed'); diff --git a/tests/tooling/docs/embeddings-cache-docs-sync.test.js b/tests/tooling/docs/embeddings-cache-docs-sync.test.js deleted file mode 100644 index 1ed1ba3e4..000000000 --- a/tests/tooling/docs/embeddings-cache-docs-sync.test.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const docPath = path.join(root, 'docs', 'specs', 'embeddings-cache.md'); - -const text = await fs.readFile(docPath, 'utf8'); -assert.ok(text.includes('# Embeddings Cache'), 'missing doc title'); -assert.ok(text.includes('## Layout'), 'missing layout section'); -assert.ok(/##\s+Cache entry format/i.test(text), 'missing cache entry format'); -assert.ok(text.includes('## Invalidation'), 'missing invalidation section'); -assert.ok(text.includes('## Pruning'), 'missing pruning section'); -assert.ok(text.includes('## Configuration'), 'missing configuration section'); - -console.log('embeddings cache docs sync test passed'); diff --git a/tests/tooling/docs/generated-surfaces-freshness-fixture.test.js b/tests/tooling/docs/generated-surfaces-freshness-fixture.test.js new file mode 100644 index 000000000..16d2c157a --- /dev/null +++ b/tests/tooling/docs/generated-surfaces-freshness-fixture.test.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execaSync } from 'execa'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const toolPath = path.join(root, 'tools', 'docs', 'generated-surfaces.js'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pairofcleats-generated-surfaces-')); + +const writeJson = (filePath, payload) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); +}; + +const writeText = (filePath, contents) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +}; + +try { + writeText( + path.join(tempRoot, 'tools', 'fixtures', 'generate-docs.js'), + `#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const outIndex = args.indexOf('--out'); +const outMdIndex = args.indexOf('--out-md'); +const valueIndex = args.indexOf('--value'); +const outputJson = outIndex >= 0 ? args[outIndex + 1] : ''; +const outputMd = outMdIndex >= 0 ? args[outMdIndex + 1] : ''; +const value = valueIndex >= 0 ? args[valueIndex + 1] : 'ok'; + +if (outputJson) { + fs.mkdirSync(path.dirname(outputJson), { recursive: true }); + fs.writeFileSync(outputJson, JSON.stringify({ generatedAt: 'fresh', value }, null, 2) + '\\n'); +} +if (outputMd) { + fs.mkdirSync(path.dirname(outputMd), { recursive: true }); + fs.writeFileSync(outputMd, '# Fixture\\n\\nvalue=' + value + '\\n'); +} +` + ); + + writeText( + path.join(tempRoot, 'tools', 'fixtures', 'audit-generated.js'), + `#!/usr/bin/env node +import fs from 'node:fs'; + +const args = process.argv.slice(2); +const targetIndex = args.indexOf('--target'); +const target = targetIndex >= 0 ? args[targetIndex + 1] : ''; +if (!target || !fs.existsSync(target)) { + console.error('missing target'); + process.exit(1); +} +console.log('audit ok'); +` + ); + + writeJson(path.join(tempRoot, 'docs', 'tooling', 'generated-surfaces.json'), { + schemaVersion: '1.0.0', + surfaces: [ + { + id: 'fixture-compare', + owner: 'tests', + committed: true, + validationMode: 'registry-audited', + freshnessExpectation: 'fixture compare', + freshness: { + mode: 'generated-compare', + command: 'node tools/fixtures/generate-docs.js --out {output:0} --out-md {output:1} --value ok', + outputs: [ + { format: 'json', omitKeys: ['generatedAt'] }, + { format: 'text' } + ] + }, + outputs: [ + 'docs/generated/fixture.json', + 'docs/generated/fixture.md' + ], + refresh: { + command: 'node tools/fixtures/generate-docs.js --out docs/generated/fixture.json --out-md docs/generated/fixture.md --value ok' + }, + audit: { + command: 'node tools/docs/generated-surfaces.js --check --surface fixture-compare' + } + }, + { + id: 'fixture-audit', + owner: 'tests', + committed: true, + validationMode: 'sync-check', + freshnessExpectation: 'fixture audit', + freshness: { + mode: 'audit-command', + command: 'node tools/fixtures/audit-generated.js --target docs/generated/audit.flag' + }, + outputs: [ + 'docs/generated/audit.flag' + ], + refresh: { + command: 'node tools/fixtures/generate-docs.js --out docs/generated/unused.json --value ok' + }, + audit: { + command: 'node tools/fixtures/audit-generated.js --target docs/generated/audit.flag' + } + } + ] + }); + + writeJson(path.join(tempRoot, 'docs', 'generated', 'fixture.json'), { + generatedAt: 'stale', + value: 'ok' + }); + writeText(path.join(tempRoot, 'docs', 'generated', 'fixture.md'), '# Fixture\n\nvalue=ok\n'); + writeText(path.join(tempRoot, 'docs', 'generated', 'audit.flag'), 'ok\n'); + + const freshPass = execaSync('node', [toolPath, '--root', tempRoot, '--check-freshness'], { cwd: root }); + if (!freshPass.stdout.includes('generated surfaces freshness check passed')) { + console.error('generated surfaces freshness fixture test failed: expected fresh pass'); + process.exit(1); + } + + writeJson(path.join(tempRoot, 'docs', 'generated', 'fixture.json'), { + generatedAt: 'stale', + value: 'drifted' + }); + let driftFailed = false; + try { + execaSync('node', [toolPath, '--root', tempRoot, '--check-freshness'], { cwd: root }); + } catch (error) { + const stderr = String(error.stderr || ''); + if (!stderr.includes('fixture-compare: stale output docs/generated/fixture.json')) { + console.error('generated surfaces freshness fixture test failed: missing stale-output summary'); + process.exit(1); + } + if (!stderr.includes('refresh: node tools/fixtures/generate-docs.js --out docs/generated/fixture.json --out-md docs/generated/fixture.md --value ok')) { + console.error('generated surfaces freshness fixture test failed: missing refresh hint'); + process.exit(1); + } + driftFailed = true; + } + if (!driftFailed) { + console.error('generated surfaces freshness fixture test failed: expected drift to fail'); + process.exit(1); + } + + const refreshResult = execaSync('node', [toolPath, '--root', tempRoot, '--refresh', '--surface', 'fixture-compare'], { cwd: root }); + if (!refreshResult.stdout.includes('refreshed fixture-compare')) { + console.error('generated surfaces freshness fixture test failed: missing refresh success output'); + process.exit(1); + } + const refreshedPass = execaSync('node', [toolPath, '--root', tempRoot, '--check-freshness'], { cwd: root }); + if (!refreshedPass.stdout.includes('generated surfaces freshness check passed')) { + console.error('generated surfaces freshness fixture test failed: expected refreshed pass'); + process.exit(1); + } + + fs.rmSync(path.join(tempRoot, 'docs', 'generated', 'audit.flag')); + let auditFailed = false; + try { + execaSync('node', [toolPath, '--root', tempRoot, '--check-freshness'], { cwd: root }); + } catch (error) { + const stderr = String(error.stderr || ''); + if (!stderr.includes('fixture-audit: audit failed')) { + console.error('generated surfaces freshness fixture test failed: missing audit failure summary'); + process.exit(1); + } + auditFailed = true; + } + if (!auditFailed) { + console.error('generated surfaces freshness fixture test failed: expected audit failure'); + process.exit(1); + } +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +console.log('generated surfaces freshness fixture test passed'); diff --git a/tests/tooling/docs/generated-surfaces-registry.test.js b/tests/tooling/docs/generated-surfaces-registry.test.js new file mode 100644 index 000000000..90681f26b --- /dev/null +++ b/tests/tooling/docs/generated-surfaces-registry.test.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const registryPath = path.join(root, 'docs', 'tooling', 'generated-surfaces.json'); + +if (!fs.existsSync(registryPath)) { + console.error(`generated surfaces registry test failed: missing ${registryPath}`); + process.exit(1); +} + +const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); +const surfaces = Array.isArray(registry?.surfaces) ? registry.surfaces : []; +const requiredIds = [ + 'script-inventory', + 'repo-inventory', + 'doc-contract-drift', + 'config-contract', + 'config-inventory', + 'artifact-schema-index', + 'shared-module-ledger' +]; + +for (const id of requiredIds) { + const surface = surfaces.find((entry) => entry?.id === id); + if (!surface) { + console.error(`generated surfaces registry test failed: missing ${id}`); + process.exit(1); + } + if (!surface.owner || !surface.validationMode || !surface.freshnessExpectation) { + console.error(`generated surfaces registry test failed: ${id} missing metadata`); + process.exit(1); + } + if (!surface.refresh?.command) { + console.error(`generated surfaces registry test failed: ${id} missing refresh command`); + process.exit(1); + } + if (!surface.freshness?.mode) { + console.error(`generated surfaces registry test failed: ${id} missing freshness metadata`); + process.exit(1); + } + const outputs = Array.isArray(surface.outputs) ? surface.outputs : []; + if (!outputs.length) { + console.error(`generated surfaces registry test failed: ${id} missing outputs`); + process.exit(1); + } +} + +console.log('generated surfaces registry test passed'); diff --git a/tests/tooling/docs/generated-surfaces-tool.test.js b/tests/tooling/docs/generated-surfaces-tool.test.js new file mode 100644 index 000000000..ba9ec5a6c --- /dev/null +++ b/tests/tooling/docs/generated-surfaces-tool.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import path from 'node:path'; +import { execaSync } from 'execa'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const toolPath = path.join(root, 'tools', 'docs', 'generated-surfaces.js'); + +const checkResult = execaSync('node', [toolPath, '--check'], { cwd: root }); +if (!checkResult.stdout.includes('generated surfaces registry check passed')) { + console.error('generated surfaces tool test failed: --check output missing success marker'); + process.exit(1); +} + +const freshnessResult = execaSync('node', [toolPath, '--check-freshness'], { cwd: root }); +if (!freshnessResult.stdout.includes('generated surfaces freshness check passed')) { + console.error('generated surfaces tool test failed: --check-freshness output missing success marker'); + process.exit(1); +} + +const jsonResult = execaSync('node', [toolPath, '--json'], { cwd: root }); +const payload = JSON.parse(jsonResult.stdout); +if (!Array.isArray(payload?.surfaces) || payload.surfaces.length < 6) { + console.error('generated surfaces tool test failed: expected populated surfaces array'); + process.exit(1); +} + +console.log('generated surfaces tool test passed'); diff --git a/tests/tooling/docs/jsdoc-standards-sync.test.js b/tests/tooling/docs/jsdoc-standards-sync.test.js deleted file mode 100644 index a589d1ed4..000000000 --- a/tests/tooling/docs/jsdoc-standards-sync.test.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const docPath = path.join(root, 'docs', 'guides', 'jsdoc-standards.md'); - -const text = await fs.readFile(docPath, 'utf8'); -assert.ok(text.includes('# JSDoc Standards'), 'missing title'); -assert.ok(text.includes('## Required sections'), 'missing required sections'); -assert.ok(text.includes('Performance'), 'missing performance guidance'); -assert.ok(text.includes('## Examples'), 'missing examples section'); - -console.log('jsdoc standards sync test passed'); diff --git a/tests/tooling/docs/perfplan-docs-sync.test.js b/tests/tooling/docs/perfplan-docs-sync.test.js deleted file mode 100644 index 5c0bfad24..000000000 --- a/tests/tooling/docs/perfplan-docs-sync.test.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const guidePath = path.join(root, 'docs', 'guides', 'perfplan-execution.md'); - -const text = await fs.readFile(guidePath, 'utf8'); -assert.ok(text.includes('# PERFPLAN Execution Guide'), 'missing perfplan guide title'); -assert.ok(/ROADMAP\.md|roadmap/i.test(text), 'missing roadmap reference'); -assert.ok(text.includes('docs/guides/roadmap-checklists.md'), 'missing roadmap checklist guide reference'); -assert.ok(text.includes('docs/guides/jsdoc-standards.md'), 'missing jsdoc standards doc reference'); - -console.log('perfplan docs sync test passed'); diff --git a/tests/tooling/docs/repo-inventory-idempotence.test.js b/tests/tooling/docs/repo-inventory-idempotence.test.js new file mode 100644 index 000000000..324a2a85d --- /dev/null +++ b/tests/tooling/docs/repo-inventory-idempotence.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildRepoInventory } from '../../../tools/docs/repo-inventory.js'; +import { writeStableGeneratedJsonReport } from '../../../tools/shared/generated-report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'repo-inventory-idempotence'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const outputJsonPath = path.join(tempRoot, 'repo-inventory.json'); + +await writeStableGeneratedJsonReport(outputJsonPath, await buildRepoInventory(root)); + +const firstJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const firstJson = JSON.parse(firstJsonText); + +await new Promise((resolve) => setTimeout(resolve, 20)); + +await writeStableGeneratedJsonReport(outputJsonPath, await buildRepoInventory(root)); + +const secondJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const secondJson = JSON.parse(secondJsonText); + +assert.equal(secondJson.generatedAt, firstJson.generatedAt, 'expected generatedAt to be preserved when repo inventory content is unchanged'); +assert.equal(secondJsonText, firstJsonText, 'expected repo inventory output to be byte-stable across identical reruns'); + +console.log('repo inventory idempotence test passed'); diff --git a/tests/tooling/docs/shared-module-ledger-idempotence.test.js b/tests/tooling/docs/shared-module-ledger-idempotence.test.js new file mode 100644 index 000000000..dcc4bec3e --- /dev/null +++ b/tests/tooling/docs/shared-module-ledger-idempotence.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildSharedModuleLedger } from '../../../tools/docs/shared-module-ledger.js'; +import { writeStableGeneratedJsonReport, writeTextIfChanged } from '../../../tools/shared/generated-report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'shared-module-ledger-idempotence'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const outputJsonPath = path.join(tempRoot, 'shared-module-ledger.json'); +const outputMdPath = path.join(tempRoot, 'shared-module-ledger.md'); + +const writeLedgerOutputs = async ({ report, markdown }) => { + await writeStableGeneratedJsonReport(outputJsonPath, report); + await writeTextIfChanged(outputMdPath, markdown); +}; + +await writeLedgerOutputs(await buildSharedModuleLedger(root)); + +const firstJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const firstMdText = await fs.readFile(outputMdPath, 'utf8'); +const firstJson = JSON.parse(firstJsonText); + +await new Promise((resolve) => setTimeout(resolve, 20)); + +await writeLedgerOutputs(await buildSharedModuleLedger(root)); + +const secondJsonText = await fs.readFile(outputJsonPath, 'utf8'); +const secondMdText = await fs.readFile(outputMdPath, 'utf8'); +const secondJson = JSON.parse(secondJsonText); + +assert.equal(secondJson.generatedAt, firstJson.generatedAt, 'expected generatedAt to be preserved when shared module ledger content is unchanged'); +assert.equal(secondJsonText, firstJsonText, 'expected shared module ledger json output to be byte-stable across identical reruns'); +assert.equal(secondMdText, firstMdText, 'expected shared module ledger markdown output to be byte-stable across identical reruns'); + +console.log('shared module ledger idempotence test passed'); diff --git a/tests/tooling/docs/spec-links-valid.test.js b/tests/tooling/docs/spec-links-valid.test.js deleted file mode 100644 index 9fc75a9df..000000000 --- a/tests/tooling/docs/spec-links-valid.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const requiredDocs = [ - 'docs/specs/vfs-manifest-artifact.md', - 'docs/specs/vfs-index.md', - 'docs/specs/vfs-hash-routing.md', - 'docs/specs/vfs-token-uris.md', - 'docs/specs/vfs-io-batching.md', - 'docs/specs/vfs-segment-hash-cache.md', - 'docs/specs/vfs-cdc-segmentation.md', - 'docs/specs/vfs-cold-start-cache.md', - 'docs/specs/tooling-provider-registry.md', - 'docs/specs/tooling-vfs-and-segment-routing.md', - 'docs/specs/map-artifact.md', - 'docs/perf/map-pipeline.md' -]; - -for (const rel of requiredDocs) { - const abs = path.join(root, rel); - try { - await fs.access(abs); - } catch { - assert.fail(`missing required doc: ${rel}`); - } -} - -console.log('spec link validity test passed'); diff --git a/tests/tooling/docs/truth-table.test.js b/tests/tooling/docs/truth-table.test.js deleted file mode 100644 index 6d66af6ff..000000000 --- a/tests/tooling/docs/truth-table.test.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; - -const root = process.cwd(); -const tablePath = path.join(root, 'docs', 'testing', 'truth-table.md'); -let raw = ''; -try { - raw = fs.readFileSync(tablePath, 'utf8'); -} catch (err) { - console.error(`Failed to read truth table at ${tablePath}: ${err?.message || err}`); - process.exit(1); -} - -const lines = raw.split(/\r?\n/); -const claims = []; -let current = null; - -for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - const trimmed = line.trim(); - if (trimmed.startsWith('- Claim:')) { - if (current) claims.push(current); - current = { line: i + 1, lines: [line] }; - continue; - } - if (current) { - if (trimmed.startsWith('## ') || trimmed.startsWith('# ')) { - claims.push(current); - current = null; - continue; - } - current.lines.push(line); - } -} -if (current) claims.push(current); - -if (!claims.length) { - console.error('Truth table validation failed: no claims found.'); - process.exit(1); -} - -const requiredLabels = ['Implementation:', 'Config:', 'Tests:', 'Limitations:']; -const issues = []; - -const findLabelLine = (blockLines, label) => { - for (const line of blockLines) { - if (line.includes(label)) return line; - } - return null; -}; - -for (const claim of claims) { - const blockText = claim.lines.join('\n'); - for (const label of requiredLabels) { - const line = findLabelLine(claim.lines, label); - if (!line) { - issues.push(`Claim at line ${claim.line} missing ${label}`); - continue; - } - const content = line.split(label)[1]; - if (!content || !content.trim()) { - issues.push(`Claim at line ${claim.line} has empty ${label}`); - } - } - const testsLine = findLabelLine(claim.lines, 'Tests:'); - if (testsLine && !/tests\//.test(testsLine)) { - issues.push(`Claim at line ${claim.line} Tests line missing tests/ reference`); - } - if (!testsLine && /Tests:/.test(blockText)) { - issues.push(`Claim at line ${claim.line} has malformed Tests line`); - } -} - -if (issues.length) { - console.error('Truth table validation failed:'); - issues.forEach((issue) => console.error(`- ${issue}`)); - process.exit(1); -} - -console.log(`Truth table validation passed (${claims.length} claims).`); diff --git a/tests/tooling/docs/usr-contract-checklists.test.js b/tests/tooling/docs/usr-contract-checklists.test.js new file mode 100644 index 000000000..b8636c155 --- /dev/null +++ b/tests/tooling/docs/usr-contract-checklists.test.js @@ -0,0 +1,337 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const CURRENT_READINESS_GATE_LOG = 'temp/validation/readiness-gate-current-technical-validation-20260522.log'; +const LATEST_EVIDENCE_CITATION_LOG = 'temp/validation/readiness-evidence-citation-final-20260522.log'; +const matrixDir = path.join(root, 'tests', 'lang', 'matrix'); + +const readJson = (fileName) => ( + JSON.parse(fs.readFileSync(path.join(matrixDir, fileName), 'utf8')) +); + +const toRowMap = (rows, key = 'id') => new Map(rows.map((row) => [row[key], row])); + +const languageProfiles = readJson('usr-language-profiles.json').rows; +const languageVersions = readJson('usr-language-version-policy.json').rows; +const languageEmbeddings = readJson('usr-language-embedding-policy.json').rows; +const languageShards = readJson('usr-language-batch-shards.json').rows; +const conformanceLevels = readJson('usr-conformance-levels.json').rows; +const fixtureGovernance = readJson('usr-fixture-governance.json').rows; +const frameworkProfiles = readJson('usr-framework-profiles.json').rows; +const frameworkEdgeCases = readJson('usr-framework-edge-cases.json').rows; +const sloBudgets = readJson('usr-slo-budgets.json').rows; +const benchmarkPolicies = readJson('usr-benchmark-policy.json').rows; + +const languageProfileById = toRowMap(languageProfiles); +const languageVersionById = toRowMap(languageVersions, 'languageId'); +const languageEmbeddingById = toRowMap(languageEmbeddings, 'languageId'); +const conformanceByProfile = new Map(conformanceLevels.map((row) => [`${row.profileType}:${row.profileId}`, row])); +const fixtureById = toRowMap(fixtureGovernance, 'fixtureId'); +const frameworkProfileById = toRowMap(frameworkProfiles); +const frameworkEdgeCaseById = toRowMap(frameworkEdgeCases); +const sloBudgetByLane = toRowMap(sloBudgets, 'laneId'); + +const shardByLanguage = new Map(); +for (const shard of languageShards) { + for (const languageId of shard.languageIds || []) { + shardByLanguage.set(languageId, shard); + } +} + +const readDocs = (subdir) => { + const dir = path.join(root, 'docs', 'specs', 'usr', subdir); + return fs.readdirSync(dir) + .filter((name) => name.endsWith('.md') && !['README.md', 'TEMPLATE.md'].includes(name)) + .sort((left, right) => left.localeCompare(right)) + .map((name) => ({ + id: name.replace(/\.md$/, ''), + path: path.join(dir, name), + text: fs.readFileSync(path.join(dir, name), 'utf8') + })); +}; + +const checkboxState = (text, label) => { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`^- \\[([ xX])\\] ${escaped}$`, 'm')); + assert.ok(match, `missing checklist row: ${label}`); + return match[1].toLowerCase(); +}; + +const assertChecked = (text, label) => { + assert.equal(checkboxState(text, label), 'x', `expected checked row: ${label}`); +}; + +const sectionText = (text, heading) => { + const headingLine = `## ${heading}`; + const start = text.indexOf(headingLine); + assert.notEqual(start, -1, `missing section: ${heading}`); + const bodyStart = text.indexOf('\n', start); + assert.notEqual(bodyStart, -1, `malformed section: ${heading}`); + const nextHeading = text.indexOf('\n## ', bodyStart + 1); + return text.slice(bodyStart + 1, nextHeading === -1 ? text.length : nextHeading); +}; + +const backtickValues = (text) => [...text.matchAll(/`([^`]+)`/g)].map((match) => match[1]); + +const backtickValuesForKey = (text, key) => { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`^- \`${escaped}\`: (.+)$`, 'm')); + assert.ok(match, `missing keyed value row: ${key}`); + return backtickValues(match[1]); +}; + +const assertFixtureFamiliesCoverConformance = ({ profileType, profileId, fixtureIds }) => { + const conformanceRow = conformanceByProfile.get(`${profileType}:${profileId}`); + assert.ok(conformanceRow, `missing conformance row for ${profileType}:${profileId}`); + + const fixtureFamilies = new Set(); + for (const fixtureId of fixtureIds) { + const fixture = fixtureById.get(fixtureId); + assert.ok(fixture, `${profileType} ${profileId} fixture missing governance row: ${fixtureId}`); + for (const family of fixture.families || []) { + fixtureFamilies.add(family); + } + } + + for (const requiredFamily of conformanceRow.requiredFixtureFamilies || []) { + assert.ok( + fixtureFamilies.has(requiredFamily), + `${profileType} ${profileId} fixture families must cover ${requiredFamily}` + ); + } +}; + +const assertOrderManifestExists = (shard) => { + const manifestPath = path.join(root, shard.orderManifest.replace(/\//g, path.sep)); + assert.equal(fs.existsSync(manifestPath), true, `missing order manifest for ${shard.id}`); + const validationPath = path.join(path.dirname(manifestPath), 'validation.test.js'); + assert.equal(fs.existsSync(validationPath), true, `missing executable validation test for ${shard.id}`); +}; + +const archivedApprovalLockPath = path.join(root, 'docs', 'archived', 'usr-rollout-approval-lock.md'); +assert.equal(fs.existsSync(archivedApprovalLockPath), true, 'former USR approval lock must be archived'); +const archivedApprovalLockText = fs.readFileSync(archivedApprovalLockPath, 'utf8'); +assert.match( + archivedApprovalLockText, + /^# DEPRECATED/m, + 'archived USR approval lock must carry a deprecation header' +); +assert.match( + archivedApprovalLockText, + /not an active release blocker|is not a release blocker/i, + 'archived USR approval lock must say it is not an active release blocker' +); +assert.equal( + fs.existsSync(path.join(root, 'docs', 'specs', 'usr-rollout-approval-lock.md')), + false, + 'USR approval lock must not remain an active spec' +); + +const unifiedSyntaxText = fs.readFileSync( + path.join(root, 'docs', 'specs', 'unified-syntax-representation.md'), + 'utf8' +); +for (const scorecardLabel of [ + '100% registry language profile coverage', + '100% required framework profile coverage', + '0 unresolved schema drift findings', + '0 ID grammar violations', + '0 edge endpoint constraint violations', + 'deterministic rerun diff is empty for required entities', + 'capability downgrade diagnostics within approved threshold budget', + 'no high-severity unresolved diagnostics in required conformance levels' +]) { + assertChecked(unifiedSyntaxText, scorecardLabel); +} +assert.match( + unifiedSyntaxText, + /Production-path enablement is now governed by standard release readiness validation rather than the archived Gate C role-signoff lock/, + 'USR scorecard must tie production-path enablement to technical readiness, not role signoff' +); + +const roadmapText = fs.readFileSync(path.join(root, 'docs', 'roadmap.md'), 'utf8'); +assert.match( + roadmapText, + /Gate B1-B7 technical, compatibility, matrix, conformance, observability, quality, and security-risk controls/, + 'roadmap must anchor Gate B1-B7 status named by rollout migration policy' +); +assert.match( + roadmapText, + /temp\/validation\/release-evidence-current-handoff-final-validation-20260521\.log/, + 'roadmap must cite the current release evidence handoff final validation log' +); + +assert.match( + roadmapText, + /Gate C technical rollout criteria are locally satisfied for the current branch/, + 'roadmap must record USR Gate C technical criteria as locally satisfied' +); +assert.doesNotMatch( + roadmapText, + /usrApproval\.pending|Approval state: pending|Readiness report approved\.|Test rollout authorized\.|conformance rollout authorized\./, + 'roadmap must not keep non-technical approval blockers as active status' +); + +const releasePlanText = fs.readFileSync(path.join(root, 'docs', 'roadmap-release-validation-plan.md'), 'utf8'); +assert.match( + releasePlanText, + /Local technical lanes have no known blocker/, + 'release validation plan must not classify archived role signoff as a technical blocker' +); +assert.match( + releasePlanText, + /tools\/release\/readiness-gate\.js.*technical release evidence/s, + 'release validation plan must document readiness-gate technical evidence enforcement' +); +assert.ok( + releasePlanText.includes(CURRENT_READINESS_GATE_LOG), + 'release validation plan must cite the current readiness-gate validation log' +); +assert.ok( + releasePlanText.includes(LATEST_EVIDENCE_CITATION_LOG), + 'release validation plan must cite the final current evidence-citation validation log' +); + +const rolloutMigrationText = fs.readFileSync( + path.join(root, 'docs', 'specs', 'usr-core-rollout-release-migration.md'), + 'utf8' +); +const rolloutPhaseRows = [ + ...rolloutMigrationText.matchAll(/^\| (Phase [A-H]) \| ([^|]+) \| ([^|]+) \|$/gm) +].map((match) => ({ + phase: match[1], + lifecycle: match[2].trim() +})); +assert.equal(rolloutPhaseRows.length, 8, 'rollout migration policy must define Phase A-H rows'); +for (const row of rolloutPhaseRows) { + const escapedLifecycle = row.lifecycle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + assert.match( + roadmapText, + new RegExp(`^\\| ${row.phase} \\| ${escapedLifecycle} \\|`, 'm'), + `roadmap must anchor ${row.phase} rollout lifecycle` + ); +} + +const assertTemplateIsPlaceholderOnly = (subdir) => { + const templatePath = path.join(root, 'docs', 'specs', 'usr', subdir, 'TEMPLATE.md'); + const templateText = fs.readFileSync(templatePath, 'utf8'); + assert.match( + templateText, + /Template placeholder rows are not active checklist state\./, + `${subdir} template must label checklist placeholders as non-active state` + ); + assert.equal( + /^- \[[ xX]\] /m.test(templateText), + false, + `${subdir} template must not contain active Markdown checkbox rows` + ); +}; + +assertTemplateIsPlaceholderOnly('languages'); +assertTemplateIsPlaceholderOnly('frameworks'); + +const languageDocs = readDocs('languages'); +assert.equal(languageDocs.length, languageProfiles.length, 'language docs must cover every language profile'); + +for (const doc of languageDocs) { + checkboxState(doc.text, 'Owner-role review completed.'); + checkboxState(doc.text, 'Backup-owner review completed.'); + assertChecked(doc.text, 'Matrix linkage verified against language/version/embedding registries.'); + assertChecked(doc.text, 'Required fixture families assigned with concrete fixture IDs.'); + assertChecked(doc.text, 'Required conformance levels mapped to executable lanes.'); + + const profile = languageProfileById.get(doc.id); + assert.ok(profile, `missing language profile row for ${doc.id}`); + assert.ok(languageVersionById.has(doc.id), `missing language version row for ${doc.id}`); + assert.ok(languageEmbeddingById.has(doc.id), `missing language embedding row for ${doc.id}`); + + const docConformance = backtickValues(sectionText(doc.text, 'Required conformance levels')); + assert.deepEqual(docConformance, profile.requiredConformance, `language conformance mismatch for ${doc.id}`); + + const shard = shardByLanguage.get(doc.id); + assert.ok(shard, `missing executable shard mapping for ${doc.id}`); + assertOrderManifestExists(shard); + for (const level of profile.requiredConformance || []) { + assert.ok( + (shard.requiredConformance || []).includes(level), + `language shard ${shard.id} for ${doc.id} missing ${level}` + ); + } + + const fixtureIds = backtickValues(sectionText(doc.text, 'Required fixture ID mappings')); + assert.ok(fixtureIds.length > 0, `language ${doc.id} must list concrete fixture IDs`); + assertFixtureFamiliesCoverConformance({ profileType: 'language', profileId: doc.id, fixtureIds }); + for (const fixtureId of fixtureIds) { + const fixture = fixtureById.get(fixtureId); + assert.ok(fixture, `language ${doc.id} fixture missing governance row: ${fixtureId}`); + assert.equal(fixture.profileType, 'language', `fixture ${fixtureId} must be language-scoped`); + assert.equal(fixture.profileId, doc.id, `fixture ${fixtureId} must point at ${doc.id}`); + assert.equal(fixture.blocking, true, `fixture ${fixtureId} must be blocking`); + } +} + +const frameworkDocs = readDocs('frameworks'); +assert.equal(frameworkDocs.length, frameworkProfiles.length, 'framework docs must cover every framework profile'); + +const frameworkPolicyLane = sloBudgetByLane.get('lang-framework-canonicalization'); +assert.ok(frameworkPolicyLane, 'framework C4 policy lane must have an SLO budget row'); +assert.equal(frameworkPolicyLane.profileScope, 'framework', 'framework C4 policy lane must be framework-scoped'); +assert.equal(frameworkPolicyLane.scopeId, 'C4', 'framework C4 policy lane must be scoped to C4'); +assert.equal(frameworkPolicyLane.blocking, true, 'framework C4 policy lane must be blocking'); +assert.ok( + benchmarkPolicies.some((row) => row.laneId === 'lang-framework-canonicalization' + && row.datasetClass === 'framework-overlay' + && row.blocking === true), + 'framework C4 policy lane must have a blocking framework-overlay benchmark policy row' +); + +for (const doc of frameworkDocs) { + checkboxState(doc.text, 'Owner-role review completed.'); + checkboxState(doc.text, 'Backup-owner review completed.'); + assertChecked(doc.text, 'Matrix linkage verified against framework profile and edge-case registries.'); + assertChecked(doc.text, 'Required framework fixture families assigned with concrete fixture IDs.'); + assertChecked(doc.text, 'Required C4 conformance checks mapped to executable lanes.'); + + const profile = frameworkProfileById.get(doc.id); + assert.ok(profile, `missing framework profile row for ${doc.id}`); + assert.ok((profile.requiredConformance || []).includes('C4'), `framework ${doc.id} must require C4`); + + const docEdgeCases = backtickValuesForKey(sectionText(doc.text, '8. Required fixtures and evidence'), 'edgeCaseCaseIds'); + assert.deepEqual(docEdgeCases, profile.edgeCaseCaseIds, `framework edge-case doc mismatch for ${doc.id}`); + + for (const edgeCaseId of profile.edgeCaseCaseIds || []) { + const edgeCase = frameworkEdgeCaseById.get(edgeCaseId); + assert.ok(edgeCase, `framework ${doc.id} edge case missing registry row: ${edgeCaseId}`); + assert.equal(edgeCase.frameworkProfile, doc.id, `edge case ${edgeCaseId} must point at ${doc.id}`); + assert.equal(edgeCase.blocking, true, `edge case ${edgeCaseId} must be blocking`); + } + + const fixtureIds = backtickValues(sectionText(doc.text, '8. Required fixtures and evidence')) + .filter((value) => value.includes('::')); + assert.ok(fixtureIds.length > 0, `framework ${doc.id} must list concrete fixture IDs`); + assertFixtureFamiliesCoverConformance({ profileType: 'framework', profileId: doc.id, fixtureIds }); + for (const fixtureId of fixtureIds) { + const fixture = fixtureById.get(fixtureId); + assert.ok(fixture, `framework ${doc.id} fixture missing governance row: ${fixtureId}`); + assert.equal(fixture.profileType, 'framework', `fixture ${fixtureId} must be framework-scoped`); + assert.equal(fixture.profileId, doc.id, `fixture ${fixtureId} must point at ${doc.id}`); + assert.equal(fixture.blocking, true, `fixture ${fixtureId} must be blocking`); + assert.ok((fixture.conformanceLevels || []).includes('C4'), `fixture ${fixtureId} must cover C4`); + } + + for (const languageId of profile.appliesToLanguages || []) { + const shard = shardByLanguage.get(languageId); + assert.ok(shard, `framework ${doc.id} language ${languageId} missing executable shard`); + assertOrderManifestExists(shard); + assert.ok( + (shard.requiredConformance || []).includes('C4'), + `framework ${doc.id} language ${languageId} shard must include C4` + ); + } +} + +console.log('usr contract checklists test passed'); + diff --git a/tests/tooling/doctor/command-profile-contract-matrix.test.js b/tests/tooling/doctor/command-profile-contract-matrix.test.js new file mode 100644 index 000000000..2e85cce82 --- /dev/null +++ b/tests/tooling/doctor/command-profile-contract-matrix.test.js @@ -0,0 +1,483 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsSync from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + __resetToolingCommandProbeCacheForTests, + __resolveToolingProbeTimeoutMsForTests, + isProbeCommandDefinitelyMissing, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const testRoot = resolveTestCachePath(root, `tooling-doctor-command-profile-contract-${process.pid}-${Date.now()}`); +const restorePath = prependLspTestPath({ repoRoot: root }); + +const makeExecutable = async (targetPath, body, helperBody = '#!/usr/bin/env node\nprocess.exit(0);\n') => { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, body, 'utf8'); + if (process.platform !== 'win32') { + await fs.chmod(targetPath, 0o755); + return; + } + await fs.writeFile(path.join(path.dirname(targetPath), 'ok.js'), helperBody, 'utf8'); +}; + +const csharpScript = process.platform === 'win32' + ? '@echo off\r\nnode "%~dp0\\ok.js" %*\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 0; fi\nif [ "$1" = "--help" ]; then exit 0; fi\nexit 0\n'; + +const phpactorScript = process.platform === 'win32' + ? '@echo off\r\nnode "%~dp0\\ok.js" %*\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 0; fi\nif [ "$1" = "--help" ]; then exit 0; fi\nif [ "$1" = "language-server" ]; then exit 0; fi\nexit 0\n'; + +const runTimeoutTierCases = () => { + const cases = [ + { + label: 'gopls fast tier', + input: { providerId: 'gopls', requestedCmd: 'gopls', resolvedCmd: 'gopls' }, + expected: 2000 + }, + { + label: 'jdtls heavy tier', + input: { providerId: 'jdtls', requestedCmd: 'jdtls', resolvedCmd: 'jdtls' }, + expected: 8000 + }, + { + label: 'sourcekit command token heavy tier', + input: { providerId: 'custom-provider', requestedCmd: 'sourcekit-lsp', resolvedCmd: 'sourcekit-lsp' }, + expected: 8000 + }, + { + label: 'default balanced tier', + input: { providerId: 'custom-unknown', requestedCmd: 'custom-tool', resolvedCmd: 'custom-tool' }, + expected: 4000 + }, + { + label: 'explicit override wins', + input: { + providerId: 'jdtls', + requestedCmd: 'jdtls', + resolvedCmd: 'jdtls', + explicitTimeoutMs: 1234 + }, + expected: 1234 + }, + { + label: 'explicit floor clamps', + input: { + providerId: 'jdtls', + requestedCmd: 'jdtls', + resolvedCmd: 'jdtls', + explicitTimeoutMs: 10 + }, + expected: 100 + } + ]; + + for (const testCase of cases) { + assert.equal( + __resolveToolingProbeTimeoutMsForTests(testCase.input), + testCase.expected, + `unexpected timeout for ${testCase.label}` + ); + } +}; + +const runMissingDetectionCases = () => { + const cases = [ + { + label: 'enoent means missing', + probe: { + attempted: [{ args: ['--help'], errorCode: 'ENOENT', stderr: '', stdout: '', exitCode: null }] + }, + expected: true + }, + { + label: 'shell text means missing', + probe: { + attempted: [{ + args: ['--version'], + exitCode: 127, + stderr: 'bash: sourcekit-lsp: command not found', + stdout: '', + errorCode: null + }] + }, + expected: true + }, + { + label: 'usage failure is inconclusive', + probe: { + attempted: [{ + args: ['--help'], + exitCode: 1, + stderr: 'usage: sourcekit-lsp [options]', + stdout: '', + errorCode: null + }] + }, + expected: false + }, + { + label: 'mixed attempts stay inconclusive', + probe: { + attempted: [ + { args: ['--version'], errorCode: 'ENOENT', stderr: '', stdout: '', exitCode: null }, + { + args: ['--help'], + exitCode: 1, + stderr: 'usage: sourcekit-lsp [options]', + stdout: '', + errorCode: null + } + ] + }, + expected: false + } + ]; + + for (const testCase of cases) { + assert.equal( + isProbeCommandDefinitelyMissing(testCase.probe), + testCase.expected, + `unexpected missing-command detection for ${testCase.label}` + ); + } +}; + +const runCommandResolutionCases = async () => { + const runtimeDir = path.join(testRoot, 'runtime-bin-dirs'); + const dotnetDir = path.join(runtimeDir, 'tooling', 'dotnet'); + const composerBinDir = path.join(runtimeDir, 'tooling', 'composer', 'vendor', 'bin'); + + await withTemporaryEnv({ + PATH: path.dirname(process.execPath), + Path: path.dirname(process.execPath), + PAIROFCLEATS_TESTING: '1' + }, async () => { + await fs.rm(runtimeDir, { recursive: true, force: true }); + await makeExecutable( + path.join(dotnetDir, process.platform === 'win32' ? 'csharp-ls.cmd' : 'csharp-ls'), + csharpScript + ); + await makeExecutable( + path.join(composerBinDir, process.platform === 'win32' ? 'phpactor.cmd' : 'phpactor'), + phpactorScript + ); + + const csharpProfile = resolveToolingCommandProfile({ + providerId: 'csharp-ls', + cmd: 'csharp-ls', + args: [], + repoRoot: root, + toolingConfig: { dir: path.join(runtimeDir, 'tooling') } + }); + assert.equal(csharpProfile.probe.ok, true); + assert.equal(path.dirname(csharpProfile.resolved.cmd), dotnetDir); + + const phpactorProfile = resolveToolingCommandProfile({ + providerId: 'phpactor', + cmd: 'phpactor', + args: ['language-server'], + repoRoot: root, + toolingConfig: { dir: path.join(runtimeDir, 'tooling') } + }); + assert.equal(phpactorProfile.probe.ok, true); + assert.equal(path.dirname(phpactorProfile.resolved.cmd), composerBinDir); + }); + + const globalDir = path.join(testRoot, 'global-bin-fallbacks'); + const homeDir = path.join(globalDir, 'home'); + const localAppDataDir = path.join(globalDir, 'localappdata'); + const dotnetGlobalBin = path.join(homeDir, '.dotnet', 'tools'); + const phpactorGlobalBin = path.join(localAppDataDir, 'Programs', 'phpactor'); + + await withTemporaryEnv({ + HOME: homeDir, + USERPROFILE: homeDir, + LOCALAPPDATA: localAppDataDir, + PATH: path.dirname(process.execPath), + Path: path.dirname(process.execPath), + PAIROFCLEATS_TESTING: '1' + }, async () => { + await fs.rm(globalDir, { recursive: true, force: true }); + await makeExecutable( + path.join(dotnetGlobalBin, process.platform === 'win32' ? 'csharp-ls.cmd' : 'csharp-ls'), + csharpScript + ); + await makeExecutable( + path.join(phpactorGlobalBin, process.platform === 'win32' ? 'phpactor.cmd' : 'phpactor'), + phpactorScript + ); + + const csharpProfile = resolveToolingCommandProfile({ + providerId: 'csharp-ls', + cmd: 'csharp-ls', + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(csharpProfile.probe.ok, true); + assert.equal(path.dirname(csharpProfile.resolved.cmd), dotnetGlobalBin); + + const phpactorProfile = resolveToolingCommandProfile({ + providerId: 'phpactor', + cmd: 'phpactor', + args: ['language-server'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(phpactorProfile.probe.ok, true); + assert.equal(path.dirname(phpactorProfile.resolved.cmd), phpactorGlobalBin); + }); +}; + +const runToolingDirPrecedenceCase = async () => { + const toolingDir = path.join(root, 'tests', 'fixtures', 'lsp'); + const expectedBinDir = path.join(toolingDir, 'bin'); + const fixtureCacheDir = path.join(toolingDir, 'cache', 'command-probes'); + const cacheRoot = path.join(testRoot, 'tooling-dir-precedence'); + const expectedPersistentCacheDir = path.join(cacheRoot, 'cache', 'tooling', 'command-probes'); + const nodeBin = path.dirname(process.execPath); + + await withTemporaryEnv({ + PATH: nodeBin, + Path: nodeBin, + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_TESTING: '1' + }, async () => { + await fs.rm(cacheRoot, { recursive: true, force: true }); + await fs.rm(fixtureCacheDir, { recursive: true, force: true }); + try { + const profile = resolveToolingCommandProfile({ + providerId: 'jdtls', + cmd: 'jdtls', + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir } + }); + assert.equal(profile.probe.ok, true, 'expected probe to succeed from tooling dir'); + assert.equal(path.dirname(profile.resolved.cmd), expectedBinDir, 'expected command to resolve from tooling dir bin'); + assert.equal(/^jdtls(\.cmd|\.exe|\.bat)?$/i.test(path.basename(profile.resolved.cmd)), true, 'expected jdtls binary'); + assert.equal(fsSync.existsSync(fixtureCacheDir), false, 'expected persistent probe cache to avoid fixture tooling dir'); + assert.equal(fsSync.existsSync(expectedPersistentCacheDir), true, 'expected persistent probe cache under test cache root'); + } finally { + await fs.rm(cacheRoot, { recursive: true, force: true }); + await fs.rm(fixtureCacheDir, { recursive: true, force: true }); + } + }); +}; + +const runDirectProbeCases = async () => { + const jdtlsProfile = resolveToolingCommandProfile({ + providerId: 'jdtls', + cmd: 'jdtls', + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(jdtlsProfile.probe.ok, true, 'expected jdtls probe to resolve command'); + assert.equal( + Array.isArray(jdtlsProfile.probe.attempted) && jdtlsProfile.probe.attempted.length > 0, + true, + 'expected jdtls probe to execute at least one probe argument' + ); + assert.equal(jdtlsProfile.resolved.mode, 'direct', 'expected direct launch mode for jdtls'); + + const zigProfile = resolveToolingCommandProfile({ + providerId: 'zig', + cmd: 'zig', + args: ['version'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(zigProfile.probe.attempted?.[0]?.args?.[0], 'version', 'expected zig probe to prefer `zig version`'); + + const erlProfile = resolveToolingCommandProfile({ + providerId: 'elixir-ls-erl', + cmd: 'erl', + args: ['-version'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(erlProfile.probe.attempted?.[0]?.args?.[0], '-version', 'expected erl probe to prefer `erl -version`'); +}; + +const runPyrightOverrideCases = async () => { + const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'pyright-langserver.cmd' : 'pyright-langserver' + ); + const nodeBin = path.dirname(process.execPath); + + await withTemporaryEnv({ PATH: nodeBin, Path: nodeBin }, async () => { + const explicitProfile = resolveToolingCommandProfile({ + providerId: 'pyright', + cmd: fixtureCmd, + args: ['--stdio'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(explicitProfile.probe.ok, true, 'expected explicit pyright command path probe to succeed'); + assert.equal(path.resolve(explicitProfile.resolved.cmd), path.resolve(fixtureCmd), 'expected explicit pyright command path to be preserved'); + }); + + await withTemporaryEnv({ PATH: nodeBin, Path: nodeBin }, async () => { + const defaultProfile = resolveToolingCommandProfile({ + providerId: 'pyright', + cmd: 'pyright-langserver', + args: ['--stdio'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(defaultProfile.probe.ok, true, 'expected default pyright command probe to tolerate stdio usage error output'); + }); +}; + +const runProbeTimeoutCase = async () => { + __resetToolingCommandProbeCacheForTests(); + const startedAt = Date.now(); + const profile = resolveToolingCommandProfile({ + providerId: 'timeout-probe', + cmd: 'hang-probe', + args: [], + repoRoot: root, + toolingConfig: {}, + probeTimeoutMs: 120 + }); + const elapsedMs = Date.now() - startedAt; + + assert.equal(profile.probe.ok, false, 'expected hanging probe command to fail'); + assert.equal(profile.probe.attempted?.[0]?.args?.[0], '--version', 'expected default probe to start with --version'); + assert.equal(profile.probe.attempted?.[0]?.errorCode, 'SUBPROCESS_TIMEOUT', 'expected hanging probe to be classified as a timeout'); + assert.equal(elapsedMs < 2_000, true, `expected probe attempts to be bounded by timeout (elapsed=${elapsedMs}ms)`); +}; + +const runLuaManagedLayoutValidationCase = async () => { + const caseRoot = path.join(testRoot, 'lua-managed-layout-validation'); + const toolingRoot = path.join(caseRoot, 'tooling-root'); + const binDir = path.join(toolingRoot, 'bin'); + + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(binDir, { recursive: true }); + + if (process.platform === 'win32') { + await fs.writeFile( + path.join(binDir, 'lua-language-server.cmd'), + '@echo off\r\nif "%1"=="-v" exit /b 0\r\nif "%1"=="--version" exit /b 0\r\nexit /b 0\r\n', + 'utf8' + ); + } else { + await makeExecutable(path.join(binDir, 'lua-language-server'), '#!/bin/sh\nexit 0\n'); + } + + await withTemporaryEnv({ PATH: path.dirname(process.execPath), Path: path.dirname(process.execPath) }, async () => { + const profile = resolveToolingCommandProfile({ + providerId: 'lua-language-server', + cmd: 'lua-language-server', + args: ['-v'], + repoRoot: root, + toolingConfig: { dir: toolingRoot } + }); + assert.equal(profile.probe.ok, false, 'expected broken managed Lua layout to fail validation'); + assert.equal(profile.probe.validationFailure?.reasonCode, 'broken-layout'); + assert.equal( + Array.isArray(profile.probe.failureReasons) && profile.probe.failureReasons.includes('broken-layout'), + true, + 'expected broken layout failure reason to be surfaced' + ); + }); +}; + +const runProviderOverrideCase = async () => { + registerDefaultToolingProviders(); + const requestedByProvider = new Map(); + const resolveCommandProfile = ({ providerId, cmd, args = [] }) => { + if (!providerId.includes('-runtime-') && providerId !== 'zig') { + requestedByProvider.set(providerId, { + cmd, + args: Array.isArray(args) ? args.slice() : [] + }); + } + return { + requested: { cmd, args }, + resolved: { cmd, args, mode: 'mock', reason: 'test' }, + probe: { ok: true, attempted: [{ args: ['--version'], exitCode: 0 }] } + }; + }; + + const tempRoot = path.join(testRoot, 'provider-overrides'); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + + const providerIds = [ + 'pyright', + 'csharp-ls', + 'dart', + 'elixir-ls', + 'haskell-language-server', + 'phpactor', + 'solargraph' + ]; + + await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: providerIds, + pyright: { command: 'pyright-custom', args: ['--stdio', '--watch'] }, + csharp: { cmd: 'csharp-custom' }, + dart: { cmd: 'dart-custom' }, + elixir: { cmd: 'elixir-custom' }, + haskell: { cmd: 'hls-custom' }, + phpactor: { cmd: 'phpactor-custom' }, + solargraph: { cmd: 'solargraph-custom' } + }, + strict: false + }, providerIds, { + log: () => {}, + probeHandshake: false, + resolveCommandProfile + }); + + assert.equal(requestedByProvider.get('pyright')?.cmd, 'pyright-custom'); + assert.deepEqual(requestedByProvider.get('pyright')?.args, ['--stdio', '--watch']); + assert.equal(requestedByProvider.get('csharp-ls')?.cmd, 'csharp-custom'); + assert.equal(requestedByProvider.get('dart')?.cmd, 'dart-custom'); + assert.equal(requestedByProvider.get('elixir-ls')?.cmd, 'elixir-custom'); + assert.equal(requestedByProvider.get('haskell-language-server')?.cmd, 'hls-custom'); + assert.equal(requestedByProvider.get('phpactor')?.cmd, 'phpactor-custom'); + assert.equal(requestedByProvider.get('solargraph')?.cmd, 'solargraph-custom'); +}; + +await fs.rm(testRoot, { recursive: true, force: true }); +await fs.mkdir(testRoot, { recursive: true }); + +try { + runTimeoutTierCases(); + runMissingDetectionCases(); + await runCommandResolutionCases(); + await runToolingDirPrecedenceCase(); + await runDirectProbeCases(); + await runPyrightOverrideCases(); + await runProbeTimeoutCase(); + await runLuaManagedLayoutValidationCase(); + await runProviderOverrideCase(); + console.log('tooling doctor command profile contract matrix test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + await restorePath(); + await fs.rm(testRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/doctor/command-profile-initialize-failure-cache-invalidation.test.js b/tests/tooling/doctor/command-profile-initialize-failure-cache-invalidation.test.js new file mode 100644 index 000000000..2d92a4a59 --- /dev/null +++ b/tests/tooling/doctor/command-profile-initialize-failure-cache-invalidation.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + __getToolingCommandProbeCacheStatsForTests, + __resetToolingCommandProbeCacheForTests, + invalidateProbeCacheOnInitializeFailure, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; + +const root = process.cwd(); +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'gopls.cmd' : 'gopls' +); + +try { + __resetToolingCommandProbeCacheForTests(); + + const first = resolveToolingCommandProfile({ + providerId: 'clangd', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(first.probe.ok, true, 'expected fixture command probe success'); + assert.equal(first.probe.cached, false, 'expected cold probe on first resolve'); + + const second = resolveToolingCommandProfile({ + providerId: 'clangd', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(second.probe.cached, true, 'expected warm probe cache before invalidation'); + assert.equal( + __getToolingCommandProbeCacheStatsForTests().commandProbeEntries > 0, + true, + 'expected probe cache entries before invalidation' + ); + + const ignored = invalidateProbeCacheOnInitializeFailure({ + checks: [{ name: 'tooling_capability_missing_document_symbol' }], + providerId: 'clangd', + command: fixtureCmd + }); + assert.equal(ignored, false, 'expected no invalidation when initialize failure check is absent'); + + const invalidated = invalidateProbeCacheOnInitializeFailure({ + checks: [{ name: 'tooling_initialize_failed' }], + providerId: 'clangd', + command: fixtureCmd + }); + assert.equal(invalidated, true, 'expected invalidation on initialize failure check'); + + const third = resolveToolingCommandProfile({ + providerId: 'clangd', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(third.probe.cached, false, 'expected cache miss after initialize-failure invalidation'); + + console.log('command-profile initialize-failure cache invalidation test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); +} diff --git a/tests/tooling/doctor/command-profile-probe-cache-disabled.test.js b/tests/tooling/doctor/command-profile-probe-cache-disabled.test.js new file mode 100644 index 000000000..afe094c78 --- /dev/null +++ b/tests/tooling/doctor/command-profile-probe-cache-disabled.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + __getToolingCommandProbeCacheStatsForTests, + __resetToolingCommandProbeCacheForTests, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const cacheRoot = resolveTestCachePath(root, 'command-profile-probe-cache-disabled'); +const persistentCacheDir = path.join(cacheRoot, 'cache', 'tooling', 'command-probes'); +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'gopls.cmd' : 'gopls' +); + +try { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + __resetToolingCommandProbeCacheForTests(); + await withTemporaryEnv({ + PAIROFCLEATS_CACHE_ROOT: cacheRoot, + PAIROFCLEATS_TESTING: '1' + }, async () => { + const first = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { + cache: { + enabled: false + } + } + }); + assert.equal(first.probe.ok, true, 'expected probe success'); + assert.equal(first.probe.cached, false, 'expected cold probe'); + + __resetToolingCommandProbeCacheForTests(); + + const second = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { + cache: { + enabled: false + } + } + }); + assert.equal(second.probe.ok, true, 'expected second probe success'); + assert.equal(second.probe.cached, false, 'expected no persistent probe cache hit when tooling cache is disabled'); + assert.equal(fs.existsSync(persistentCacheDir), false, 'expected no persistent probe cache directory when tooling cache is disabled'); + + const stats = __getToolingCommandProbeCacheStatsForTests(); + assert.equal(stats.persistentWrites, 0, 'expected no persistent cache writes when tooling cache is disabled'); + assert.equal(stats.persistentHits, 0, 'expected no persistent cache hits when tooling cache is disabled'); + }); + + console.log('tooling doctor command profile persistent probe cache disabled test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + fs.rmSync(cacheRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/doctor/command-profile-probe-cache-fingerprint-invalidation.test.js b/tests/tooling/doctor/command-profile-probe-cache-fingerprint-invalidation.test.js new file mode 100644 index 000000000..43333e2e5 --- /dev/null +++ b/tests/tooling/doctor/command-profile-probe-cache-fingerprint-invalidation.test.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sleep } from '../../../src/shared/sleep.js'; +import { + __getToolingCommandProbeCacheStatsForTests, + __resetToolingCommandProbeCacheForTests, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testCache', 'command-profile-probe-cache-fingerprint-invalidation'); +const toolingDir = path.join(tempRoot, 'tooling'); +const binDir = path.join(tempRoot, 'bin'); +const fixtureCmd = path.join( + binDir, + process.platform === 'win32' ? 'fingerprint-tool.cmd' : 'fingerprint-tool' +); + +const writeFixtureCommand = (version) => { + fs.mkdirSync(binDir, { recursive: true }); + if (process.platform === 'win32') { + fs.writeFileSync(fixtureCmd, `@echo off\r\necho fingerprint tool ${version}\r\n`, 'utf8'); + return; + } + fs.writeFileSync(fixtureCmd, `#!/usr/bin/env sh\nprintf 'fingerprint tool ${version}\\n'\n`, 'utf8'); + fs.chmodSync(fixtureCmd, 0o755); +}; + +try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + writeFixtureCommand('1.0'); + + __resetToolingCommandProbeCacheForTests(); + const first = resolveToolingCommandProfile({ + providerId: 'fingerprint-probe', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(first.probe.ok, true, 'expected initial probe success'); + assert.equal(first.probe.cached, false, 'expected initial probe cache miss'); + + await sleep(30); + writeFixtureCommand('2.0'); + + __resetToolingCommandProbeCacheForTests(); + const second = resolveToolingCommandProfile({ + providerId: 'fingerprint-probe', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(second.probe.ok, true, 'expected probe success after command rewrite'); + assert.equal(second.probe.cached, false, 'expected fingerprint change to bypass persistent cache'); + + const stats = __getToolingCommandProbeCacheStatsForTests(); + assert.equal(stats.persistentHits, 0, 'expected no persistent hit after fingerprint change'); + assert.equal(stats.persistentWrites >= 1, true, 'expected persistent cache refresh after fingerprint change'); + + console.log('tooling doctor command profile probe cache fingerprint invalidation test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/doctor/command-profile-probe-cache-invalidate-launch-failure.test.js b/tests/tooling/doctor/command-profile-probe-cache-invalidate-launch-failure.test.js new file mode 100644 index 000000000..1d0436246 --- /dev/null +++ b/tests/tooling/doctor/command-profile-probe-cache-invalidate-launch-failure.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + __resetToolingCommandProbeCacheForTests, + probeLspInitializeHandshake, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `command-profile-probe-cache-invalidate-launch-failure-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const launchFailScriptPath = path.join(tempRoot, 'launch-fail-probe-ok.js'); +await fs.writeFile(launchFailScriptPath, [ + '#!/usr/bin/env node', + "const first = String(process.argv[2] || '').toLowerCase();", + "if (first === '--version' || first === 'version' || first === '--help' || first === 'help' || first === '-h') {", + " process.stdout.write('probe-ok\\n');", + ' process.exit(0);', + '}', + "process.stderr.write('launch failed intentionally\\n');", + 'process.exit(1);' +].join('\n'), 'utf8'); + +const providerId = 'cache-launch-failure'; + +try { + __resetToolingCommandProbeCacheForTests(); + + const first = resolveToolingCommandProfile({ + providerId, + cmd: process.execPath, + args: [launchFailScriptPath], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(first.probe.ok, true, 'expected initial probe success'); + assert.equal(first.probe.cached, false, 'expected initial probe cache miss'); + + const second = resolveToolingCommandProfile({ + providerId, + cmd: process.execPath, + args: [launchFailScriptPath], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(second.probe.ok, true, 'expected second probe success'); + assert.equal(second.probe.cached, true, 'expected second probe cache hit before launch failure'); + + const handshake = await probeLspInitializeHandshake({ + providerId, + cmd: process.execPath, + args: [launchFailScriptPath], + cwd: root, + timeoutMs: 1200 + }); + assert.equal(handshake.ok, false, 'expected initialize handshake failure for launch-fail command'); + + const third = resolveToolingCommandProfile({ + providerId, + cmd: process.execPath, + args: [launchFailScriptPath], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(third.probe.ok, true, 'expected probe success after invalidation-triggered reprobe'); + assert.equal(third.probe.cached, false, 'expected launch failure to invalidate cached probe success'); + + console.log('tooling doctor command profile launch-failure cache invalidation test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/doctor/command-profile-probe-cache-matrix.test.js b/tests/tooling/doctor/command-profile-probe-cache-matrix.test.js new file mode 100644 index 000000000..c9fa7ea4e --- /dev/null +++ b/tests/tooling/doctor/command-profile-probe-cache-matrix.test.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + __getToolingCommandProbeCacheStatsForTests, + __resetToolingCommandProbeCacheForTests, + __setToolingCommandProbeSuccessTtlMsForTests, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const restorePath = prependLspTestPath({ repoRoot: root }); +const tempRoot = resolveTestCachePath(root, `tooling-doctor-command-profile-matrix-${process.pid}-${Date.now()}`); +const toolingDir = path.join(tempRoot, 'tooling'); +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'gopls.cmd' : 'gopls' +); + +try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + + __resetToolingCommandProbeCacheForTests(); + const profile = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(profile.probe.ok, true); + assert.equal(profile.resolved.mode, 'gopls-direct'); + assert.deepEqual(profile.resolved.args, []); + + const explicitProfile = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: ['-rpc.trace'], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(explicitProfile.resolved.mode, 'gopls-explicit-args'); + assert.deepEqual(explicitProfile.resolved.args, ['-rpc.trace']); + + const nodeBin = path.dirname(process.execPath); + await withTemporaryEnv({ PATH: nodeBin, Path: nodeBin }, async () => { + const overrideProfile = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(overrideProfile.probe.ok, true); + assert.equal(path.resolve(overrideProfile.resolved.cmd), path.resolve(fixtureCmd)); + }); + + __resetToolingCommandProbeCacheForTests(); + const initialStats = __getToolingCommandProbeCacheStatsForTests(); + assert.equal(initialStats.commandProbeEntries, 0); + + const first = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(first.probe.ok, true); + assert.equal(first.probe.cached, false); + + const second = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(second.probe.ok, true); + assert.equal(second.probe.cached, true); + assert.equal(__getToolingCommandProbeCacheStatsForTests().commandProbeEntries >= 1, true); + + __resetToolingCommandProbeCacheForTests(); + const missingCmd = `poc-missing-cmd-${Date.now()}-${process.pid}`; + const failedFirst = resolveToolingCommandProfile({ + providerId: 'custom-missing', + cmd: missingCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(failedFirst.probe.ok, false); + assert.equal(failedFirst.probe.cached, false); + + const failedSecond = resolveToolingCommandProfile({ + providerId: 'custom-missing', + cmd: missingCmd, + args: [], + repoRoot: root, + toolingConfig: {} + }); + assert.equal(failedSecond.probe.ok, false); + assert.equal(failedSecond.probe.cached, true); + + __resetToolingCommandProbeCacheForTests(); + __setToolingCommandProbeSuccessTtlMsForTests(25); + + const ttlFirst = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(ttlFirst.probe.cached, false); + + const ttlSecond = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(ttlSecond.probe.cached, true); + + await sleep(60); + + const ttlThird = resolveToolingCommandProfile({ + providerId: 'gopls', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(ttlThird.probe.cached, false); + + console.log('tooling doctor command profile probe cache matrix test passed'); +} finally { + __setToolingCommandProbeSuccessTtlMsForTests(null); + __resetToolingCommandProbeCacheForTests(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + await restorePath(); +} diff --git a/tests/tooling/doctor/command-profile-probe-cache-persistent.test.js b/tests/tooling/doctor/command-profile-probe-cache-persistent.test.js new file mode 100644 index 000000000..6f26cdc46 --- /dev/null +++ b/tests/tooling/doctor/command-profile-probe-cache-persistent.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + __getToolingCommandProbeCacheStatsForTests, + __resetToolingCommandProbeCacheForTests, + resolveToolingCommandProfile +} from '../../../src/index/tooling/command-resolver.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testCache', 'command-profile-probe-cache-persistent'); +const toolingDir = path.join(tempRoot, 'tooling'); +const binDir = path.join(tempRoot, 'bin'); +const fixtureCmd = path.join( + binDir, + process.platform === 'win32' ? 'persistent-tool.cmd' : 'persistent-tool' +); + +const writeFixtureCommand = () => { + fs.mkdirSync(binDir, { recursive: true }); + if (process.platform === 'win32') { + fs.writeFileSync(fixtureCmd, '@echo off\r\necho persistent tool 1.0\r\n', 'utf8'); + return; + } + fs.writeFileSync(fixtureCmd, '#!/usr/bin/env sh\nprintf \'persistent tool 1.0\\n\'\n', 'utf8'); + fs.chmodSync(fixtureCmd, 0o755); +}; + +try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + writeFixtureCommand(); + + __resetToolingCommandProbeCacheForTests(); + const first = resolveToolingCommandProfile({ + providerId: 'persistent-probe', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(first.probe.ok, true, 'expected initial probe success'); + assert.equal(first.probe.cached, false, 'expected initial probe cache miss'); + + const firstStats = __getToolingCommandProbeCacheStatsForTests(); + assert.equal(firstStats.persistentWrites >= 1, true, 'expected persistent probe cache write'); + + __resetToolingCommandProbeCacheForTests(); + const second = resolveToolingCommandProfile({ + providerId: 'persistent-probe', + cmd: fixtureCmd, + args: [], + repoRoot: root, + toolingConfig: { dir: toolingDir, cache: { dir: toolingDir } } + }); + assert.equal(second.probe.ok, true, 'expected persistent cache probe success'); + assert.equal(second.probe.cached, true, 'expected persistent cache hit'); + assert.equal(second.probe.cacheSource, 'persistent', 'expected persistent cache source'); + + const secondStats = __getToolingCommandProbeCacheStatsForTests(); + assert.equal(secondStats.persistentHits >= 1, true, 'expected persistent cache hit stats'); + + console.log('tooling doctor command profile persistent probe cache test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/doctor/command-resolution-report.test.js b/tests/tooling/doctor/command-resolution-report.test.js new file mode 100644 index 000000000..b5f82a1ce --- /dev/null +++ b/tests/tooling/doctor/command-resolution-report.test.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; + + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-command-resolution'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + registerDefaultToolingProviders(); + const report = await runToolingDoctor({ + repoRoot: root, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['clangd', 'pyright', 'sourcekit'] + }, + strict: false + }, null, { log: () => {}, handshakeTimeoutMs: 1500 }); + + assert.equal(report.schemaVersion, 2, 'expected doctor report schema version 2'); + assert.equal(report.reportFile, 'tooling_doctor_report.json', 'expected canonical doctor report filename'); + assert.equal( + path.basename(report.reportPath || ''), + 'tooling_doctor_report.json', + 'expected doctor report path to use tooling_doctor_report.json' + ); + assert.ok(report.summary?.preflight && typeof report.summary.preflight === 'object', 'expected doctor preflight summary envelope'); + assert.equal( + Number.isFinite(Number(report.summary?.preflight?.supported)), + true, + 'expected numeric preflight supported count' + ); + assert.equal( + Number.isFinite(Number(report.summary?.preflight?.enabled)), + true, + 'expected numeric preflight enabled count' + ); + assert.equal( + Array.isArray(report.summary?.preflight?.ids), + true, + 'expected doctor preflight summary id list' + ); + assert.equal( + report.summary?.preflight?.byClass && typeof report.summary.preflight.byClass === 'object', + true, + 'expected doctor preflight summary byClass map' + ); + assert.equal( + report.summary?.preflight?.byPolicy && typeof report.summary.preflight.byPolicy === 'object', + true, + 'expected doctor preflight summary byPolicy map' + ); + assert.equal( + Number.isFinite(Number(report.summary?.preflight?.withRuntimeRequirements)), + true, + 'expected numeric preflight withRuntimeRequirements count' + ); + + const providers = Array.isArray(report.providers) ? report.providers : []; + const clangd = providers.find((entry) => entry.id === 'clangd'); + assert.ok(clangd, 'expected clangd provider report'); + assert.ok(clangd.command && typeof clangd.command === 'object', 'expected command profile in provider report'); + assert.equal( + Array.isArray(clangd.command?.probe?.attempted), + true, + 'expected probe attempts in provider command profile' + ); + assert.ok(clangd.handshake && typeof clangd.handshake === 'object', 'expected handshake probe details'); +} finally { + await restorePath(); +} + +console.log('tooling doctor command resolution report test passed'); + diff --git a/tests/tooling/doctor/dedicated-provider-handshake-fixtures.test.js b/tests/tooling/doctor/dedicated-provider-handshake-fixtures.test.js new file mode 100644 index 000000000..c43fc46af --- /dev/null +++ b/tests/tooling/doctor/dedicated-provider-handshake-fixtures.test.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; +import { skip } from '../../helpers/skip.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-doctor-dedicated-handshake-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +const cacheRoot = path.join(tempRoot, 'cache'); +await fs.mkdir(cacheRoot, { recursive: true }); +applyTestEnv({ cacheRoot }); + +const restorePath = prependLspTestPath({ repoRoot: root }); +let skipReason = null; + +try { + registerDefaultToolingProviders(); + const providerIds = [ + 'jdtls', + 'csharp-ls', + 'solargraph' + ]; + const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: providerIds + }, + strict: false + }, providerIds, { + log: () => {}, + probeTimeoutMs: 750, + handshakeTimeoutMs: 750 + }); + + let probeSuccessCount = 0; + let handshakeSuccessCount = 0; + for (const providerId of providerIds) { + const provider = (report.providers || []).find((entry) => entry.id === providerId); + assert.ok(provider, `expected provider report for ${providerId}`); + assert.ok(provider.command, `expected command profile for ${providerId}`); + if (provider.command?.probe?.ok === true) { + probeSuccessCount += 1; + assert.ok(provider.handshake, `expected handshake probe result for ${providerId}`); + assert.equal( + typeof provider.handshake?.ok, + 'boolean', + `expected boolean handshake status for ${providerId}` + ); + if (provider.handshake?.ok === true) { + handshakeSuccessCount += 1; + } else { + assert.equal( + typeof provider.handshake?.errorCode === 'string' && provider.handshake.errorCode.length > 0, + true, + `expected handshake error code for ${providerId} when handshake is not ok` + ); + } + } else { + assert.equal( + provider.handshake == null, + true, + `expected no handshake probe result for ${providerId} when command probe fails` + ); + } + } + if (probeSuccessCount === 0) { + skipReason = 'Skipping dedicated provider handshake test; no provider command probes succeeded.'; + } else if (handshakeSuccessCount === 0) { + skipReason = 'Skipping dedicated provider handshake test; no provider completed initialize handshake.'; + } +} finally { + await restorePath(); +} + +if (skipReason) { + skip(skipReason); +} + +console.log('tooling doctor dedicated provider handshake fixtures test passed'); + diff --git a/tests/tooling/doctor/doctor-detects-missing-typescript.test.js b/tests/tooling/doctor/detects-missing-typescript.test.js similarity index 100% rename from tests/tooling/doctor/doctor-detects-missing-typescript.test.js rename to tests/tooling/doctor/detects-missing-typescript.test.js diff --git a/tests/tooling/doctor/doctor-emits-report.test.js b/tests/tooling/doctor/doctor-emits-report.test.js deleted file mode 100644 index 1bc72524f..000000000 --- a/tests/tooling/doctor/doctor-emits-report.test.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tooling-doctor'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -registerDefaultToolingProviders(); -const report = await runToolingDoctor({ - repoRoot: root, - buildRoot: tempRoot, - toolingConfig: {}, - strict: false -}, null, { log: () => {} }); - -const reportPath = path.join(tempRoot, 'tooling_report.json'); -const raw = await fs.readFile(reportPath, 'utf8'); -const parsed = JSON.parse(raw); - -assert.ok(report, 'expected report object'); -assert.ok(parsed.identity?.chunkUid, 'expected identity chunkUid section'); -assert.ok(parsed.providers, 'expected providers section'); - -console.log('tooling doctor report emission test passed'); diff --git a/tests/tooling/doctor/doctor-gating-reasons.test.js b/tests/tooling/doctor/doctor-gating-reasons.test.js deleted file mode 100644 index 0007f8c9c..000000000 --- a/tests/tooling/doctor/doctor-gating-reasons.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tooling-doctor-gating'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -registerDefaultToolingProviders(); -const report = await runToolingDoctor({ - repoRoot: root, - buildRoot: tempRoot, - toolingConfig: { - enabledTools: ['clangd'], - disabledTools: ['typescript'] - }, - strict: false -}, null, { log: () => {} }); - -const providers = Array.isArray(report.providers) ? report.providers : []; -const typescript = providers.find((entry) => entry.id === 'typescript'); -assert.ok(typescript, 'expected typescript provider entry'); -assert.equal(typescript.enabled, false, 'expected typescript provider disabled'); -assert.ok(typescript.reasonsDisabled.includes('disabled-by-config') || typescript.reasonsDisabled.includes('not-in-enabled-tools')); - -console.log('tooling doctor gating reasons test passed'); diff --git a/tests/tooling/doctor/doctor-json-stable.test.js b/tests/tooling/doctor/doctor-json-stable.test.js deleted file mode 100644 index cceadb619..000000000 --- a/tests/tooling/doctor/doctor-json-stable.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tooling-doctor-json'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -registerDefaultToolingProviders(); -const report = await runToolingDoctor({ - repoRoot: root, - buildRoot: tempRoot, - toolingConfig: {}, - strict: false -}, null, { log: () => {} }); - -assert.ok(report.repoRoot, 'expected repoRoot'); -assert.ok(report.config, 'expected config section'); -assert.ok(report.xxhash, 'expected xxhash section'); -assert.ok(Array.isArray(report.providers), 'expected providers array'); -assert.ok(report.summary, 'expected summary section'); - -const typescript = report.providers.find((entry) => entry.id === 'typescript'); -assert.ok(typescript, 'expected typescript provider entry'); -assert.ok(Object.prototype.hasOwnProperty.call(typescript, 'enabled'), 'expected enabled field'); - -console.log('tooling doctor JSON schema test passed'); diff --git a/tests/tooling/doctor/doctor-reports-xxhash-backend.test.js b/tests/tooling/doctor/doctor-reports-xxhash-backend.test.js deleted file mode 100644 index 6877ce22c..000000000 --- a/tests/tooling/doctor/doctor-reports-xxhash-backend.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tooling-doctor-xxhash'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -registerDefaultToolingProviders(); -const report = await runToolingDoctor({ - repoRoot: root, - buildRoot: tempRoot, - toolingConfig: {}, - strict: false -}, null, { log: () => {} }); - -assert.ok(report.identity?.chunkUid?.available, 'expected xxhash backend to be available'); -assert.notEqual(report.identity?.chunkUid?.backend, 'none'); - -console.log('tooling doctor xxhash backend test passed'); diff --git a/tests/tooling/doctor/doctor-scm-none-annotate-disabled.test.js b/tests/tooling/doctor/doctor-scm-none-annotate-disabled.test.js deleted file mode 100644 index 72b59e468..000000000 --- a/tests/tooling/doctor/doctor-scm-none-annotate-disabled.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'tooling-doctor-scm-none'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -registerDefaultToolingProviders(); -const report = await runToolingDoctor({ - repoRoot: root, - buildRoot: tempRoot, - toolingConfig: {}, - scmConfig: { provider: 'none', annotate: { enabled: true } }, - strict: false -}, null, { log: () => {} }); - -assert.equal(report?.scm?.provider, 'none', 'expected provider none'); -assert.equal(report?.scm?.annotateEnabled, false, 'annotate should be disabled when provider none'); - -console.log('tooling doctor scm none test passed'); diff --git a/tests/tooling/doctor/gating-reasons.test.js b/tests/tooling/doctor/gating-reasons.test.js new file mode 100644 index 000000000..76014bc18 --- /dev/null +++ b/tests/tooling/doctor/gating-reasons.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { createDoctorCommandResolver } from '../../helpers/tooling-doctor-fixture.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-gating'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); +await fs.mkdir(cacheRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'index.js'), 'export const value = 1;\n', 'utf8'); + +applyTestEnv({ cacheRoot }); + +registerDefaultToolingProviders(); +const resolveCommandProfile = createDoctorCommandResolver({ + available: ['clangd', 'typescript-language-server'] +}); +const report = await runToolingDoctor({ + repoRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['clangd'], + disabledTools: ['typescript'] + }, + strict: false +}, null, { + log: () => {}, + resolveCommandProfile, + probeTimeoutMs: 750, + handshakeTimeoutMs: 750 +}); + +const providers = Array.isArray(report.providers) ? report.providers : []; +const typescript = providers.find((entry) => entry.id === 'typescript'); +assert.ok(typescript, 'expected typescript provider entry'); +assert.equal(typescript.enabled, false, 'expected typescript provider disabled'); +assert.ok(typescript.reasonsDisabled.includes('disabled-by-config') || typescript.reasonsDisabled.includes('not-in-enabled-tools')); + +console.log('tooling doctor gating reasons test passed'); diff --git a/tests/tooling/doctor/generic-preset-matrix.test.js b/tests/tooling/doctor/generic-preset-matrix.test.js new file mode 100644 index 000000000..b09aa1d24 --- /dev/null +++ b/tests/tooling/doctor/generic-preset-matrix.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-generic-preset-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +const expectedProviders = [ + { id: 'lsp-gopls', commandCheck: 'lsp-gopls-command' }, + { id: 'lsp-rust-analyzer', commandCheck: 'lsp-rust-analyzer-command' }, + { id: 'lsp-yaml-language-server', commandCheck: 'lsp-yaml-language-server-command' }, + { id: 'lsp-lua-language-server', commandCheck: 'lsp-lua-language-server-command' }, + { id: 'lsp-zls', commandCheck: 'lsp-zls-command' } +]; + +try { + const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + lsp: { + enabled: true, + servers: [ + { preset: 'gopls' }, + { preset: 'rust-analyzer' }, + { preset: 'yaml-language-server' }, + { preset: 'lua-language-server' }, + { preset: 'zls' } + ] + } + }, + strict: false + }, expectedProviders.map((entry) => entry.id), { + log: () => {}, + probeHandshake: false + }); + + for (const expected of expectedProviders) { + const provider = (report.providers || []).find((entry) => entry.id === expected.id); + assert.ok(provider, `expected provider report for ${expected.id}`); + const commandCheck = (provider.checks || []).find((check) => check.name === expected.commandCheck); + assert.ok(commandCheck, `expected command check for ${expected.id}`); + assert.equal( + commandCheck.status === 'ok' || commandCheck.status === 'warn', + true, + `expected command check status ok/warn for ${expected.id}` + ); + } + + const zlsProvider = (report.providers || []).find((entry) => entry.id === 'lsp-zls'); + const zlsCompatibility = (zlsProvider?.checks || []).find((check) => check.name === 'zls-zig-compatibility'); + assert.ok(zlsCompatibility, 'expected zls-zig compatibility check'); + assert.equal( + zlsCompatibility.status === 'ok' || zlsCompatibility.status === 'warn', + true, + 'expected zls-zig compatibility check status ok/warn' + ); + + console.log('tooling doctor generic preset matrix test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/doctor/preflight-capabilities-configured-gopls.test.js b/tests/tooling/doctor/preflight-capabilities-configured-gopls.test.js new file mode 100644 index 000000000..0fa25889f --- /dev/null +++ b/tests/tooling/doctor/preflight-capabilities-configured-gopls.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-preflight-capabilities-configured-gopls'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-gopls'], + lsp: { + enabled: true, + servers: [{ + id: 'gopls', + preset: 'gopls', + cmd: 'gopls', + languages: ['go'], + uriScheme: 'poc-vfs' + }] + } + }, + strict: false + }, ['lsp-gopls'], { + log: () => {}, + probeHandshake: false + }); + + const provider = (report.providers || []).find((entry) => entry.id === 'lsp-gopls'); + assert.ok(provider, 'expected configured gopls provider in doctor report'); + assert.equal(provider.preflight?.supported, true, 'expected configured gopls preflight capability'); + assert.equal( + provider.preflight?.id, + 'lsp-gopls.workspace-model', + 'expected configured gopls preflight id' + ); + assert.equal( + provider.preflight?.class, + 'workspace', + 'expected configured gopls preflight class' + ); + assert.equal( + provider.preflight?.policy, + 'required', + 'expected configured gopls preflight policy' + ); + assert.equal( + Array.isArray(provider.preflight?.runtimeRequirements) + && provider.preflight.runtimeRequirements.some((entry) => entry?.id === 'go'), + true, + 'expected configured gopls runtime requirement metadata' + ); + assert.equal(report.summary?.preflight?.supported, 1, 'expected one supported preflight provider'); + assert.equal(report.summary?.preflight?.enabled, 1, 'expected one enabled preflight provider'); + assert.ok( + Array.isArray(report.summary?.preflight?.ids) + && report.summary.preflight.ids.includes('lsp-gopls.workspace-model'), + 'expected summary preflight ids to include configured gopls preflight' + ); +} finally { + await restorePath(); +} + +console.log('tooling doctor preflight capabilities configured gopls test passed'); diff --git a/tests/tooling/doctor/preflight-capabilities-configured-rust.test.js b/tests/tooling/doctor/preflight-capabilities-configured-rust.test.js new file mode 100644 index 000000000..eee81f7db --- /dev/null +++ b/tests/tooling/doctor/preflight-capabilities-configured-rust.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-preflight-capabilities-configured-rust'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-analyzer'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-analyzer', + preset: 'rust-analyzer', + cmd: 'rust-analyzer', + languages: ['rust'], + uriScheme: 'poc-vfs' + }] + } + }, + strict: false + }, ['lsp-rust-analyzer'], { + log: () => {}, + probeHandshake: false + }); + + const provider = (report.providers || []).find((entry) => entry.id === 'lsp-rust-analyzer'); + assert.ok(provider, 'expected configured rust-analyzer provider in doctor report'); + assert.equal(provider.preflight?.supported, true, 'expected configured rust preflight capability'); + assert.equal( + provider.preflight?.id, + 'lsp-rust-analyzer.workspace-model', + 'expected configured rust preflight id' + ); + assert.equal( + provider.preflight?.class, + 'workspace', + 'expected configured rust preflight class' + ); + assert.equal( + provider.preflight?.policy, + 'required', + 'expected configured rust preflight policy' + ); + assert.equal( + Array.isArray(provider.preflight?.runtimeRequirements) + && provider.preflight.runtimeRequirements.some((entry) => entry?.id === 'cargo'), + true, + 'expected configured rust runtime requirement metadata' + ); + assert.equal(report.summary?.preflight?.supported, 1, 'expected one supported preflight provider'); + assert.equal(report.summary?.preflight?.enabled, 1, 'expected one enabled preflight provider'); + assert.ok( + Array.isArray(report.summary?.preflight?.ids) + && report.summary.preflight.ids.includes('lsp-rust-analyzer.workspace-model'), + 'expected summary preflight ids to include configured rust preflight' + ); +} finally { + await restorePath(); +} + +console.log('tooling doctor preflight capabilities configured rust test passed'); diff --git a/tests/tooling/doctor/preflight-capabilities-report.test.js b/tests/tooling/doctor/preflight-capabilities-report.test.js new file mode 100644 index 000000000..a2d64eed1 --- /dev/null +++ b/tests/tooling/doctor/preflight-capabilities-report.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-preflight-capabilities-report'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +registerDefaultToolingProviders(); + +const report = await runToolingDoctor({ + repoRoot: root, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['sourcekit', 'clangd', 'typescript'] + }, + strict: false +}, ['sourcekit', 'clangd', 'typescript'], { + log: () => {}, + probeHandshake: false +}); + +const sourcekit = (report.providers || []).find((entry) => entry.id === 'sourcekit'); +assert.ok(sourcekit, 'expected sourcekit provider in doctor report'); +assert.equal(sourcekit.preflight?.supported, true, 'expected sourcekit preflight support metadata'); +assert.equal( + sourcekit.preflight?.id, + 'sourcekit.package-resolution', + 'expected sourcekit preflight id in doctor report' +); +assert.equal( + sourcekit.preflight?.class, + 'dependency', + 'expected sourcekit preflight class metadata' +); +assert.equal( + sourcekit.preflight?.policy, + 'required', + 'expected sourcekit preflight policy metadata' +); +assert.equal( + Array.isArray(sourcekit.preflight?.runtimeRequirements) + && sourcekit.preflight.runtimeRequirements.some((entry) => entry?.id === 'swift'), + true, + 'expected sourcekit preflight runtime requirement metadata' +); + +const clangd = (report.providers || []).find((entry) => entry.id === 'clangd'); +assert.ok(clangd, 'expected clangd provider in doctor report'); +assert.equal(clangd.preflight?.supported, true, 'expected clangd preflight support metadata'); +assert.equal( + clangd.preflight?.id, + 'clangd.workspace-model', + 'expected clangd preflight id in doctor report' +); +assert.equal( + clangd.preflight?.class, + 'workspace', + 'expected clangd preflight class metadata' +); +assert.equal( + clangd.preflight?.policy, + 'optional', + 'expected clangd preflight policy metadata' +); + +const typescript = (report.providers || []).find((entry) => entry.id === 'typescript'); +assert.ok(typescript, 'expected typescript provider in doctor report'); +assert.equal( + typescript.preflight?.supported, + false, + 'expected typescript provider to report no preflight support' +); + +console.log('tooling doctor preflight capabilities report test passed'); diff --git a/tests/tooling/doctor/provider-requires-args.test.js b/tests/tooling/doctor/provider-requires-args.test.js new file mode 100644 index 000000000..c93aec32c --- /dev/null +++ b/tests/tooling/doctor/provider-requires-args.test.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-doctor-provider-requires-args-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const calls = []; +const resolveCommandProfile = ({ cmd, args = [] }) => { + calls.push({ cmd, args: Array.isArray(args) ? args.slice() : [] }); + return { + requested: { cmd, args }, + resolved: { cmd, args, mode: 'mock', reason: 'test' }, + probe: { ok: true, attempted: [{ cmd, args }] } + }; +}; + +registerDefaultToolingProviders(); +await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['solargraph', 'phpactor'] + }, + strict: false +}, ['solargraph', 'phpactor'], { + log: () => {}, + probeHandshake: false, + resolveCommandProfile +}); + +const solargraphCalls = calls.filter((entry) => entry.cmd === 'solargraph'); +assert.ok(solargraphCalls.length > 0, 'expected solargraph command probe'); +assert.equal( + solargraphCalls.some((entry) => entry.args.length === 1 && entry.args[0] === 'stdio'), + true, + 'expected doctor to pass provider requires.args to command profile resolver' +); +const phpactorCalls = calls.filter((entry) => entry.cmd === 'phpactor'); +assert.ok(phpactorCalls.length > 0, 'expected phpactor command probe'); +assert.equal( + phpactorCalls.some((entry) => entry.args.length === 1 && entry.args[0] === 'language-server'), + true, + 'expected doctor to pass phpactor requires.args to command profile resolver' +); + +console.log('tooling doctor provider requires args test passed'); diff --git a/tests/tooling/doctor/report-emission-contract-matrix.test.js b/tests/tooling/doctor/report-emission-contract-matrix.test.js new file mode 100644 index 000000000..668ca9856 --- /dev/null +++ b/tests/tooling/doctor/report-emission-contract-matrix.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-doctor-report-emission-${process.pid}-${Date.now()}`); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); +await fs.mkdir(cacheRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'index.js'), 'export const value = 1;\n', 'utf8'); + +applyTestEnv({ cacheRoot }); +registerDefaultToolingProviders(); + +const report = await runToolingDoctor({ + repoRoot, + buildRoot: tempRoot, + toolingConfig: {}, + strict: false +}, [], { + log: () => {}, + probeTimeoutMs: 750, + handshakeTimeoutMs: 750 +}); + +assert.ok(report, 'expected report object'); +assert.ok(report.repoRoot, 'expected repoRoot'); +assert.ok(report.config, 'expected config section'); +assert.ok(report.xxhash, 'expected xxhash section'); +assert.ok(Array.isArray(report.providers), 'expected providers array'); +assert.ok(report.summary, 'expected summary section'); + +const typescript = report.providers.find((entry) => entry.id === 'typescript'); +assert.ok(typescript, 'expected typescript provider entry'); +assert.ok(Object.prototype.hasOwnProperty.call(typescript, 'enabled'), 'expected enabled field'); + +assert.ok(report.identity?.chunkUid?.available, 'expected xxhash backend to be available'); +assert.notEqual(report.identity?.chunkUid?.backend, 'none'); + +const reportPath = path.join(tempRoot, 'tooling_doctor_report.json'); +const raw = await fs.readFile(reportPath, 'utf8'); +const parsed = JSON.parse(raw); +assert.ok(parsed.identity?.chunkUid, 'expected identity chunkUid section'); +assert.ok(parsed.providers, 'expected providers section'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('tooling doctor report emission contract matrix test passed'); diff --git a/tests/tooling/doctor/runtime-requirements-provider-matrix.test.js b/tests/tooling/doctor/runtime-requirements-provider-matrix.test.js new file mode 100644 index 000000000..5b50fe4f7 --- /dev/null +++ b/tests/tooling/doctor/runtime-requirements-provider-matrix.test.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createDoctorCommandResolver, + createToolingDoctorTempRoot, + runToolingDoctorFixture +} from '../../helpers/tooling-doctor-fixture.js'; + +const cases = [ + { + fixtureName: 'tooling-doctor-runtime-reqs-jdtls', + providerId: 'jdtls', + enabledTools: ['jdtls'], + available: ['jdtls'], + missing: ['java', 'javac'], + expectedChecks: [ + { name: 'jdtls-runtime-java', status: 'error' }, + { name: 'jdtls-runtime-javac', status: 'error' } + ] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-csharp', + providerId: 'csharp-ls', + enabledTools: ['csharp-ls'], + available: ['csharp-ls'], + missing: ['dotnet'], + expectedChecks: [ + { name: 'csharp-ls-runtime-dotnet', status: 'error' } + ] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-phpactor', + providerId: 'phpactor', + enabledTools: ['phpactor'], + available: ['phpactor'], + missing: ['php'], + expectedChecks: [ + { name: 'phpactor-runtime-php', status: 'error' } + ], + absentChecks: ['phpactor-runtime-composer'] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-java-csharp-php', + providerId: 'lsp-java-dedicated', + enabledTools: ['lsp-java-dedicated', 'lsp-csharp-dedicated', 'lsp-php-dedicated'], + available: ['jdtls', 'csharp-ls', 'phpactor'], + missing: ['java', 'dotnet', 'php'], + toolingConfig: { + lsp: { + enabled: true, + servers: [ + { id: 'java-dedicated', cmd: 'jdtls', languages: ['java'] }, + { id: 'csharp-dedicated', cmd: 'csharp-ls', languages: ['csharp'] }, + { id: 'php-dedicated', cmd: 'phpactor', languages: ['php'] } + ] + } + }, + expectedChecks: [ + { providerId: 'lsp-java-dedicated', name: 'lsp-java-dedicated-runtime-java', status: 'error' }, + { providerId: 'lsp-csharp-dedicated', name: 'lsp-csharp-dedicated-runtime-dotnet', status: 'error' }, + { providerId: 'lsp-php-dedicated', name: 'lsp-php-dedicated-runtime-php', status: 'error' } + ], + expectedSummaryStatus: 'error' + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-dart', + providerId: 'dart', + enabledTools: ['dart'], + available: ['dart'], + reject: ({ cmd, args }) => cmd === 'dart' && args.length === 1 && args[0] === '--version', + expectedChecks: [ + { name: 'dart-runtime-dart-sdk', status: 'error' } + ] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-elixir', + providerId: 'elixir-ls', + enabledTools: ['elixir-ls'], + available: ['elixir-ls'], + missing: ['elixir', 'erl', 'mix'], + expectedChecks: [ + { name: 'elixir-ls-runtime-elixir', status: 'error' }, + { name: 'elixir-ls-runtime-erl', status: 'error' }, + { name: 'elixir-ls-runtime-mix', status: 'error' } + ] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-haskell', + providerId: 'haskell-language-server', + enabledTools: ['haskell-language-server'], + available: ['haskell-language-server'], + missing: ['ghc'], + expectedChecks: [ + { name: 'haskell-language-server-runtime-ghc', status: 'error' } + ] + }, + { + fixtureName: 'tooling-doctor-runtime-reqs-solargraph', + providerId: 'solargraph', + enabledTools: ['solargraph'], + available: ['solargraph'], + missing: ['ruby', 'gem', 'bundle'], + expectedChecks: [ + { name: 'solargraph-runtime-ruby', status: 'error' }, + { name: 'solargraph-runtime-gem', status: 'error' }, + { name: 'solargraph-runtime-bundle', status: 'error' } + ] + } +]; + +for (const entry of cases) { + const tempRoot = await createToolingDoctorTempRoot(entry.fixtureName); + const resolveCommandProfile = createDoctorCommandResolver({ + available: entry.available, + missing: entry.missing, + reject: entry.reject + }); + const report = await runToolingDoctorFixture({ + tempRoot, + enabledTools: entry.enabledTools, + toolingConfig: entry.toolingConfig || {}, + resolveCommandProfile + }); + + if (entry.expectedSummaryStatus) { + assert.equal(report.summary.status, entry.expectedSummaryStatus, `expected summary status for ${entry.fixtureName}`); + } + + for (const expectedCheck of entry.expectedChecks) { + const providerId = expectedCheck.providerId || entry.providerId; + const provider = (report.providers || []).find((providerEntry) => providerEntry.id === providerId); + assert.ok(provider, `expected provider report for ${providerId}`); + const check = (provider.checks || []).find((providerCheck) => providerCheck.name === expectedCheck.name); + assert.ok(check, `expected runtime requirement check ${expectedCheck.name}`); + assert.equal(check.status, expectedCheck.status, `expected ${expectedCheck.name} status`); + } + + for (const checkName of entry.absentChecks || []) { + const provider = (report.providers || []).find((providerEntry) => providerEntry.id === entry.providerId); + const check = (provider?.checks || []).find((providerCheck) => providerCheck.name === checkName); + assert.equal(check, undefined, `expected no ${checkName} runtime requirement check`); + } +} + +console.log('tooling doctor runtime requirements provider matrix test passed'); diff --git a/tests/tooling/doctor/scm-none-annotate-disabled.test.js b/tests/tooling/doctor/scm-none-annotate-disabled.test.js new file mode 100644 index 000000000..6fe6a300c --- /dev/null +++ b/tests/tooling/doctor/scm-none-annotate-disabled.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-scm-none'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); +await fs.mkdir(cacheRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'index.js'), 'export const value = 1;\n', 'utf8'); + +applyTestEnv({ cacheRoot }); + +registerDefaultToolingProviders(); +const report = await runToolingDoctor({ + repoRoot, + buildRoot: tempRoot, + toolingConfig: {}, + scmConfig: { provider: 'none', annotate: { enabled: true } }, + strict: false +}, [], { + log: () => {}, + probeTimeoutMs: 750, + handshakeTimeoutMs: 750 +}); + +assert.equal(report?.scm?.provider, 'none', 'expected provider none'); +assert.equal(report?.scm?.annotateEnabled, false, 'annotate should be disabled when provider none'); + +console.log('tooling doctor scm none test passed'); diff --git a/tests/tooling/doctor/structured-report-fields.test.js b/tests/tooling/doctor/structured-report-fields.test.js new file mode 100644 index 000000000..d3014009c --- /dev/null +++ b/tests/tooling/doctor/structured-report-fields.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-doctor-structured-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const resolveCommandProfile = ({ cmd, args = [] }) => ({ + providerId: 'dart', + requested: { cmd, args }, + resolved: { cmd, args, mode: 'mock', reason: 'test' }, + probe: { + ok: true, + attempted: [{ + args: ['--version'], + exitCode: 0, + stdout: 'dart sdk version: 3.0.0-test' + }], + versionText: 'dart sdk version: 3.0.0-test', + failureReasons: [] + } +}); + +registerDefaultToolingProviders(); +const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['dart'], + dart: { cmd: 'dart' } + }, + strict: false +}, ['dart'], { + log: () => {}, + probeHandshake: false, + resolveCommandProfile +}); + +const dart = report.providers.find((entry) => entry.id === 'dart'); +assert.ok(dart, 'expected dart provider in doctor report'); +assert.equal(dart.command?.resolved?.cmd, 'dart', 'expected resolved command in report'); +assert.equal( + dart.command?.probe?.versionText, + 'dart sdk version: 3.0.0-test', + 'expected machine-readable command version text' +); +assert.ok(dart.workspaceModel && typeof dart.workspaceModel === 'object', 'expected structured workspace model block'); +assert.equal(dart.workspaceModel.detected, false, 'expected missing workspace model for empty repo'); +assert.equal(dart.workspaceModel.status, 'warn', 'expected workspace model warning status'); +assert.ok(Array.isArray(dart.failureReasons), 'expected structured failure reasons array'); +assert.equal( + dart.failureReasons.some((entry) => entry?.code === 'dart-workspace-model' && entry?.status === 'warn'), + true, + 'expected workspace-model warning in structured failure reasons' +); + +console.log('tooling doctor structured report fields test passed'); diff --git a/tests/tooling/doctor/windows-command-shim-normalization.test.js b/tests/tooling/doctor/windows-command-shim-normalization.test.js new file mode 100644 index 000000000..f22cf0989 --- /dev/null +++ b/tests/tooling/doctor/windows-command-shim-normalization.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createToolingDoctorTempRoot, + runToolingDoctorFixture +} from '../../helpers/tooling-doctor-fixture.js'; + +const tempRoot = await createToolingDoctorTempRoot('tooling-doctor-windows-command-shim-normalization'); + +const resolveCommandProfile = ({ cmd, args = [] }) => { + const normalizedCmd = String(cmd || '').trim().toLowerCase(); + if (normalizedCmd === 'jdtls') { + return { + requested: { cmd, args }, + resolved: { + cmd: 'jdtls.cmd', + args, + mode: 'direct', + source: 'mock' + }, + probe: { + ok: true, + attempted: [{ cmd, args }], + resolvedPath: 'jdtls.cmd' + } + }; + } + if (normalizedCmd === 'java') { + return { + requested: { cmd, args }, + resolved: { + cmd: 'java', + args, + mode: 'direct', + source: 'mock' + }, + probe: { + ok: false, + attempted: [{ cmd, args }], + resolvedPath: null + } + }; + } + return { + requested: { cmd, args }, + resolved: { + cmd, + args, + mode: 'direct', + source: 'mock' + }, + probe: { + ok: true, + attempted: [{ cmd, args }], + resolvedPath: String(cmd || '') + } + }; +}; + +const report = await runToolingDoctorFixture({ + tempRoot, + enabledTools: ['jdtls'], + resolveCommandProfile +}); + +const provider = (report.providers || []).find((entry) => entry.id === 'jdtls'); +assert.ok(provider, 'expected jdtls provider report'); + +const runtimeCheck = (provider.checks || []).find((check) => check.name === 'jdtls-runtime-java'); +assert.ok(runtimeCheck, 'expected Java runtime check when jdtls resolves through .cmd shim'); +assert.equal(runtimeCheck.status, 'error', 'expected Java runtime check to fail for missing java command'); + +const workspaceCheck = (provider.checks || []).find((check) => check.name === 'jdtls-workspace-model'); +assert.ok(workspaceCheck, 'expected workspace-model check when jdtls resolves through .cmd shim'); +assert.equal(workspaceCheck.status, 'warn', 'expected warn without workspace model markers'); + +console.log('tooling doctor windows command shim normalization test passed'); diff --git a/tests/tooling/doctor/workspace-model-provider-matrix.test.js b/tests/tooling/doctor/workspace-model-provider-matrix.test.js new file mode 100644 index 000000000..7c78f13c8 --- /dev/null +++ b/tests/tooling/doctor/workspace-model-provider-matrix.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createDoctorCommandResolver, + createDoctorRunner, + createToolingDoctorTempRoot, + writeDoctorWorkspaceMarker +} from '../../helpers/tooling-doctor-fixture.js'; + +const cases = [ + { + fixtureName: 'tooling-doctor-workspace-java-dedicated', + providerId: 'lsp-java-dedicated', + available: ['jdtls'], + enabledTools: ['lsp-java-dedicated'], + toolingConfig: { + lsp: { + enabled: true, + servers: [ + { id: 'java-dedicated', cmd: 'jdtls', languages: ['java'] } + ] + } + }, + checkName: 'lsp-java-dedicated-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-dart', + providerId: 'dart', + available: ['dart'], + enabledTools: ['dart'], + checkName: 'dart-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-elixir', + providerId: 'elixir-ls', + available: ['elixir-ls'], + enabledTools: ['elixir-ls'], + checkName: 'elixir-ls-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-haskell', + providerId: 'haskell-language-server', + available: ['haskell-language-server'], + enabledTools: ['haskell-language-server'], + checkName: 'haskell-language-server-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-phpactor', + providerId: 'phpactor', + available: ['phpactor'], + enabledTools: ['phpactor'], + checkName: 'phpactor-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-solargraph', + providerId: 'solargraph', + available: ['solargraph'], + enabledTools: ['solargraph'], + checkName: 'solargraph-workspace-model' + }, + { + fixtureName: 'tooling-doctor-workspace-csharp', + providerId: 'csharp-ls', + available: ['csharp-ls'], + enabledTools: ['csharp-ls'], + checkName: 'csharp-ls-workspace-model' + } +]; + +for (const entry of cases) { + const tempRoot = await createToolingDoctorTempRoot(entry.fixtureName); + const resolveCommandProfile = createDoctorCommandResolver({ + available: entry.available + }); + const { runDoctor } = createDoctorRunner({ + tempRoot, + enabledTools: entry.enabledTools, + toolingConfig: entry.toolingConfig || {}, + resolveCommandProfile + }); + + const reportMissingMarkers = await runDoctor(); + const providerMissing = (reportMissingMarkers.providers || []).find((provider) => provider.id === entry.providerId); + const missingCheck = (providerMissing?.checks || []).find((check) => check.name === entry.checkName); + assert.ok(missingCheck, `expected workspace-model check for ${entry.providerId}`); + assert.equal(missingCheck.status, 'warn', `expected warn when workspace markers are missing for ${entry.providerId}`); + + await writeDoctorWorkspaceMarker(tempRoot, entry.providerId); + const reportWithMarkers = await runDoctor(); + const providerPresent = (reportWithMarkers.providers || []).find((provider) => provider.id === entry.providerId); + const presentCheck = (providerPresent?.checks || []).find((check) => check.name === entry.checkName); + assert.ok(presentCheck, `expected workspace-model check after marker creation for ${entry.providerId}`); + assert.equal(presentCheck.status, 'ok', `expected ok when workspace markers are present for ${entry.providerId}`); +} + +console.log('tooling doctor workspace model provider matrix test passed'); diff --git a/tests/tooling/doctor/zls-zig-compatibility.test.js b/tests/tooling/doctor/zls-zig-compatibility.test.js new file mode 100644 index 000000000..e0325779c --- /dev/null +++ b/tests/tooling/doctor/zls-zig-compatibility.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingDoctor } from '../../../src/index/tooling/doctor.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'tooling-doctor-zls-zig-compat'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + const report = await runToolingDoctor({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + lsp: { + enabled: true, + servers: [{ + preset: 'zls', + languages: ['zig'] + }] + } + }, + strict: false + }, ['lsp-zls'], { + log: () => {}, + probeHandshake: false + }); + + const zlsProvider = (report.providers || []).find((provider) => provider.id === 'lsp-zls'); + assert.ok(zlsProvider, 'expected zls provider report entry'); + const compatibilityCheck = (zlsProvider.checks || []).find((check) => check.name === 'zls-zig-compatibility'); + assert.ok(compatibilityCheck, 'expected zls-zig compatibility check'); + assert.equal(compatibilityCheck.status, 'ok', 'expected zls-zig compatibility check to pass'); + + console.log('tooling doctor zls zig compatibility test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/editors/harness-coverage.test.js b/tests/tooling/editors/harness-coverage.test.js new file mode 100644 index 000000000..a91c56b54 --- /dev/null +++ b/tests/tooling/editors/harness-coverage.test.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { loadLaneManifestConfig, loadOrderedLaneManifest } from '../../runner/lane-manifests.js'; + +const root = process.cwd(); + +const readText = (filePath) => fs.readFileSync(filePath, 'utf8'); + +const toLaneId = (testPath) => testPath + .replace(/\\/g, '/') + .replace(/^tests\//, '') + .replace(/\.test\.js$/, ''); + +const manifestConfig = await loadLaneManifestConfig({ root }); +const ciLiteManifest = await loadOrderedLaneManifest({ root, lane: 'ci-lite', config: manifestConfig }); +const ciManifest = await loadOrderedLaneManifest({ root, lane: 'ci', config: manifestConfig }); +const ciLongManifest = await loadOrderedLaneManifest({ root, lane: 'ci-long', config: manifestConfig }); +const ciLiteEntries = new Set(Array.isArray(ciLiteManifest?.tests) ? ciLiteManifest.tests.map((entry) => entry.id) : []); +const ciEntries = new Set(Array.isArray(ciManifest?.tests) ? ciManifest.tests.map((entry) => entry.id) : []); +const ciLongEntries = new Set(Array.isArray(ciLongManifest?.tests) ? ciLongManifest.tests.map((entry) => entry.id) : []); + +const matrix = [ + { + editor: 'vscode', + flow: 'search smoke harness', + testPath: 'tests/tooling/vscode/integration-harness.test.js', + requiredContent: [ + 'pairofcleats.search', + 'nested symbol', + 'searchHistory' + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'vscode', + flow: 'index and validate harness', + testPath: 'tests/tooling/vscode/operations-runtime.test.js', + requiredContent: [ + 'pairofcleats.indexValidate', + 'Index Validate completed.' + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'vscode', + flow: 'context-pack and risk-explain harness', + testPath: 'tests/tooling/vscode/context-risk-runtime.test.js', + requiredContent: [ + 'pairofcleats.contextPack', + 'pairofcleats.riskExplain', + 'Context Pack completed.', + 'Risk Explain completed.' + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'sublime', + flow: 'search harness', + testPath: 'tests/tooling/sublime/behavior-contract-matrix.test.js', + requiredContent: [ + "['search', 'search_behavior.py']" + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'sublime', + flow: 'index harness', + testPath: 'tests/tooling/sublime/behavior-contract-matrix.test.js', + requiredContent: [ + "['index', 'index_behavior.py']" + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'sublime', + flow: 'context-pack and risk-explain harness', + testPath: 'tests/tooling/sublime/behavior-contract-matrix.test.js', + requiredContent: [ + "['analysis', 'analysis_behavior.py']" + ], + requiredLanes: ['ci-lite'] + }, + { + editor: 'sublime', + flow: 'fixture-backed package harness', + testPath: 'tests/tooling/sublime/package-harness.test.js', + requiredContent: [ + 'sublime package harness test passed' + ], + requiredLanes: ['ci'] + }, + { + editor: 'sublime', + flow: 'real package harness implementation', + testPath: 'tests/helpers/sublime/package_harness.py', + requiredContent: [ + 'test_package_harness_exercises_real_search_index_map_and_advanced_workflows', + 'PairOfCleatsIndexBuildCodeCommand', + 'PairOfCleatsSearchCommand', + 'PairOfCleatsArchitectureCheckCommand' + ], + requiredLanes: [] + } +]; + +for (const entry of matrix) { + const absolutePath = path.join(root, entry.testPath); + if (!fs.existsSync(absolutePath)) { + console.error(`missing ${entry.editor} ${entry.flow} harness: ${absolutePath}`); + process.exit(1); + } + const source = readText(absolutePath); + for (const required of entry.requiredContent) { + if (!source.includes(required)) { + console.error(`${entry.editor} ${entry.flow} harness missing expected marker "${required}" in ${entry.testPath}`); + process.exit(1); + } + } + const laneId = toLaneId(entry.testPath); + for (const lane of entry.requiredLanes) { + const targetSet = lane === 'ci-lite' + ? ciLiteEntries + : lane === 'ci' + ? ciEntries + : ciLongEntries; + if (!targetSet.has(laneId)) { + console.error(`${entry.editor} ${entry.flow} harness is not registered in ${lane}: ${laneId}`); + process.exit(1); + } + } +} + +console.log('editor harness coverage contract test passed'); diff --git a/tests/tooling/embeddings/chunks-from-bundle-shards.test.js b/tests/tooling/embeddings/chunks-from-bundle-shards.test.js new file mode 100644 index 000000000..579ad99f7 --- /dev/null +++ b/tests/tooling/embeddings/chunks-from-bundle-shards.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { buildChunksFromBundles } from '../../../tools/build/embeddings/chunks.js'; +import { resolveBundleShardFilename } from '../../../src/shared/bundle-io-paths.js'; +import { writeBundleFile } from '../../../src/shared/bundle-io.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `chunks-from-bundle-shards-${process.pid}-${Date.now()}`); +const bundleDir = path.join(tempRoot, 'files'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(bundleDir, { recursive: true }); + +const relPath = 'src/example.ts'; +const shard0 = resolveBundleShardFilename(relPath, 'json', 0); +const shard1 = resolveBundleShardFilename(relPath, 'json', 1); + +try { + await writeBundleFile({ + bundlePath: path.join(bundleDir, shard0), + format: 'json', + bundle: { + file: relPath, + hash: 'abc', + mtimeMs: 1, + size: 1, + chunks: [{ id: 0, file: relPath, chunkUid: 'ck:0', text: 'zero' }] + } + }); + await writeBundleFile({ + bundlePath: path.join(bundleDir, shard1), + format: 'json', + bundle: { + file: relPath, + hash: 'abc', + mtimeMs: 1, + size: 1, + chunks: [{ id: 1, file: relPath, chunkUid: 'ck:1', text: 'one' }] + } + }); + + const { chunksByFile, totalChunks } = await buildChunksFromBundles(bundleDir, { + [relPath]: { + hash: 'abc', + mtimeMs: 1, + size: 1, + bundles: [shard0, shard1], + bundleFormat: 'json' + } + }, 'json'); + + assert.equal(totalChunks, 2, 'expected both shard chunks to be indexed'); + const rows = chunksByFile.get(relPath) || []; + assert.equal(rows.length, 2, 'expected combined shard rows for file'); + assert.deepEqual( + rows.map((entry) => entry.index).sort((a, b) => a - b), + [0, 1], + 'expected chunk ids from both bundle shards' + ); + console.log('embeddings chunks-from-bundle-shards test passed'); +} finally { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} + diff --git a/tests/tooling/eval/eval-quality.test.js b/tests/tooling/eval/eval-quality.test.js deleted file mode 100644 index 9e796f5b8..000000000 --- a/tests/tooling/eval/eval-quality.test.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'eval-quality'); -const cacheRoot = path.join(tempRoot, 'cache'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const datasetPath = path.join(fixtureRoot, 'eval.json'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); - -const env = { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PAIROFCLEATS_EMBEDDINGS: 'stub' -}; - -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--repo', fixtureRoot], - { env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - console.error('eval quality test failed: build_index failed'); - process.exit(buildResult.status ?? 1); -} - -const evalResult = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'eval', 'run.js'), - '--repo', - fixtureRoot, - '--dataset', - datasetPath, - '--backend', - 'memory', - '--no-ann', - '--top', - '5' - ], - { env, encoding: 'utf8' } -); - -if (evalResult.status !== 0) { - console.error('eval quality test failed: eval run returned error'); - if (evalResult.stderr) console.error(evalResult.stderr.trim()); - process.exit(evalResult.status ?? 1); -} - -let payload = null; -try { - payload = JSON.parse(evalResult.stdout || '{}'); -} catch (err) { - console.error('eval quality test failed: invalid JSON output'); - process.exit(1); -} - -const summary = payload?.summary || {}; -const recallAt5 = summary?.recallAtK?.['5'] ?? 0; -const ndcgAt5 = summary?.ndcgAtK?.['5'] ?? 0; -const mrr = summary?.mrr ?? 0; - -if (recallAt5 < 0.6) { - console.error(`eval quality test failed: recall@5 too low (${recallAt5.toFixed(3)})`); - process.exit(1); -} -if (ndcgAt5 < 0.6) { - console.error(`eval quality test failed: ndcg@5 too low (${ndcgAt5.toFixed(3)})`); - process.exit(1); -} -if (mrr < 0.5) { - console.error(`eval quality test failed: mrr too low (${mrr.toFixed(3)})`); - process.exit(1); -} - -console.log('eval quality tests passed'); - diff --git a/tests/tooling/eval/eval-match-mode.test.js b/tests/tooling/eval/match-mode.test.js similarity index 100% rename from tests/tooling/eval/eval-match-mode.test.js rename to tests/tooling/eval/match-mode.test.js diff --git a/tests/tooling/eval/quality.test.js b/tests/tooling/eval/quality.test.js new file mode 100644 index 000000000..c482a3595 --- /dev/null +++ b/tests/tooling/eval/quality.test.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'eval-quality'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const datasetPath = path.join(tempRoot, 'eval-code-only.json'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'index.js'), + [ + 'export function greet(name) {', + ' return `hello ${name}`;', + '}', + '', + 'export function sum(left, right) {', + ' return left + right;', + '}', + '' + ].join('\n') +); +await fsPromises.writeFile( + path.join(repoRoot, 'src', 'util.js'), + [ + 'export function clamp(value, min, max) {', + ' return Math.max(min, Math.min(max, value));', + '}', + '' + ].join('\n') +); +const codeOnlyDataset = [ + { query: 'greet', mode: 'code', expect: [{ file: 'src/index.js', name: 'greet' }] }, + { query: 'sum', mode: 'code', expect: [{ file: 'src/index.js', name: 'sum' }] }, + { query: 'clamp', mode: 'code', expect: [{ file: 'src/util.js', name: 'clamp' }] } +]; +await fsPromises.writeFile(datasetPath, JSON.stringify(codeOnlyDataset, null, 2)); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + workerPool: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + syncProcess: false +}); + +const buildResult = runNode( + [ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot + ], + 'eval quality build', + repoRoot, + env, + { stdio: 'inherit', allowFailure: true } +); +if (buildResult.status !== 0) { + console.error('eval quality test failed: build_index failed'); + process.exit(buildResult.status ?? 1); +} + +const evalResult = runNode( + [ + path.join(root, 'tools', 'eval', 'run.js'), + '--repo', + repoRoot, + '--dataset', + datasetPath, + '--backend', + 'memory', + '--no-ann', + '--top', + '5' + ], + 'eval quality run', + root, + env, + { stdio: 'pipe', allowFailure: true } +); + +if (evalResult.status !== 0) { + console.error('eval quality test failed: eval run returned error'); + if (evalResult.stderr) console.error(evalResult.stderr.trim()); + process.exit(evalResult.status ?? 1); +} + +let payload = null; +try { + payload = JSON.parse(evalResult.stdout || '{}'); +} catch (err) { + console.error('eval quality test failed: invalid JSON output'); + process.exit(1); +} + +const summary = payload?.summary || {}; +const recallAt5 = summary?.recallAtK?.['5'] ?? 0; +const ndcgAt5 = summary?.ndcgAtK?.['5'] ?? 0; +const mrr = summary?.mrr ?? 0; + +if (recallAt5 < 0.6) { + console.error(`eval quality test failed: recall@5 too low (${recallAt5.toFixed(3)})`); + process.exit(1); +} +if (ndcgAt5 < 0.6) { + console.error(`eval quality test failed: ndcg@5 too low (${ndcgAt5.toFixed(3)})`); + process.exit(1); +} +if (mrr < 0.5) { + console.error(`eval quality test failed: mrr too low (${mrr.toFixed(3)})`); + process.exit(1); +} + +console.log('eval quality tests passed'); + diff --git a/tests/tooling/eval/risk-pack-quality.test.js b/tests/tooling/eval/risk-pack-quality.test.js new file mode 100644 index 000000000..c803cfe85 --- /dev/null +++ b/tests/tooling/eval/risk-pack-quality.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { createRiskPackEvalFixtureSet } from '../../helpers/risk-pack-eval.js'; +import { runNode } from '../../helpers/run-node.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'eval-risk-pack-quality'); +const { datasetPath, gatesPath } = await createRiskPackEvalFixtureSet(tempRoot); + +const result = runNode( + [ + path.join(root, 'tools', 'eval', 'risk-pack.js'), + '--dataset', + datasetPath, + '--gates', + gatesPath, + '--enforce-gates' + ], + 'risk-pack quality eval', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); + +assert.equal(result.status, 0, `expected risk-pack eval gates to pass: ${result.stderr || result.stdout}`); + +const payload = JSON.parse(result.stdout || '{}'); +assert.equal(payload.summary?.cases, 3, 'expected three golden cases'); +assert.equal(payload.summary?.summaryExactRate, 1, 'expected exact summary matches for all goldens'); +assert.equal(payload.summary?.flowPrecisionAvg, 1, 'expected exact flow precision across goldens'); +assert.equal(payload.summary?.flowRecallAvg, 1, 'expected exact flow recall across goldens'); +assert.equal(payload.summary?.capBehaviorRate, 1, 'expected exact capped-output behavior across goldens'); +assert.ok(Array.isArray(payload.gates) && payload.gates.every((gate) => gate.pass === true), 'expected all configured gates to pass'); + +console.log('risk pack quality eval test passed'); diff --git a/tests/tooling/fixtures/fixture-empty.test.js b/tests/tooling/fixtures/fixture-empty.test.js index af6f328f4..72a6fcc7a 100644 --- a/tests/tooling/fixtures/fixture-empty.test.js +++ b/tests/tooling/fixtures/fixture-empty.test.js @@ -49,15 +49,25 @@ function assertEmptyChunkMeta(label, dir) { } } +function assertZeroStateManifest(label, dir) { + const zeroStatePath = path.join(dir, 'pieces', 'sqlite-zero-state.json'); + if (!fs.existsSync(zeroStatePath)) { + console.error(`Missing ${label} zero-state manifest at ${zeroStatePath}`); + process.exit(1); + } +} + assertEmptyChunkMeta('code', codeDir); assertEmptyChunkMeta('prose', proseDir); +assertZeroStateManifest('code', codeDir); +assertZeroStateManifest('prose', proseDir); -if (!fs.existsSync(sqlitePaths.codePath)) { - console.error(`Missing sqlite code db at ${sqlitePaths.codePath}`); +if (fs.existsSync(sqlitePaths.codePath)) { + console.error(`Expected no sqlite code db for zero-state fixture at ${sqlitePaths.codePath}`); process.exit(1); } -if (!fs.existsSync(sqlitePaths.prosePath)) { - console.error(`Missing sqlite prose db at ${sqlitePaths.prosePath}`); +if (fs.existsSync(sqlitePaths.prosePath)) { + console.error(`Expected no sqlite prose db for zero-state fixture at ${sqlitePaths.prosePath}`); process.exit(1); } diff --git a/tests/tooling/fixtures/fixture-eval.test.js b/tests/tooling/fixtures/fixture-eval.test.js index 504e936b8..c073df9ce 100644 --- a/tests/tooling/fixtures/fixture-eval.test.js +++ b/tests/tooling/fixtures/fixture-eval.test.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { createCli } from '../../../src/shared/cli.js'; +import { runNode } from '../../helpers/run-node.js'; import { runSqliteBuild } from '../../helpers/sqlite-builder.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -30,11 +30,9 @@ function resolveFixtures() { } function run(args, label, cwd, env, inherit = false) { - const result = spawnSync(process.execPath, args, { - cwd, - env, - encoding: 'utf8', - stdio: inherit ? 'inherit' : 'pipe' + const result = runNode(args, label, cwd, env, { + stdio: inherit ? 'inherit' : 'pipe', + allowFailure: true }); if (result.status !== 0) { console.error(`Failed: ${label}`); diff --git a/tests/tooling/fixtures/fixture-parity.test.js b/tests/tooling/fixtures/fixture-parity.test.js index 499f94ffb..9ffe6adf2 100644 --- a/tests/tooling/fixtures/fixture-parity.test.js +++ b/tests/tooling/fixtures/fixture-parity.test.js @@ -84,7 +84,7 @@ for (const fixtureName of fixtures) { runNode( [ - path.join(root, 'tests', 'retrieval', 'parity', 'parity.test.js'), + path.join(root, 'tests', 'retrieval', 'parity', 'equivalence.test.js'), '--no-ann', '--queries', queryFile, diff --git a/tests/tooling/fixtures/prewarm-shared-indexes.test.js b/tests/tooling/fixtures/prewarm-shared-indexes.test.js new file mode 100644 index 000000000..4d6ebcd3c --- /dev/null +++ b/tests/tooling/fixtures/prewarm-shared-indexes.test.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { ensureFixtureIndex } from '../../helpers/fixture-index.js'; + +await ensureFixtureIndex({ + fixtureName: 'sample', + cacheName: 'fixture-sample', + cacheScope: 'shared', + requiredModes: ['code'] +}); + +await ensureFixtureIndex({ + fixtureName: 'languages', + cacheName: 'language-fixture', + cacheScope: 'shared', + requiredModes: ['code'] +}); + +await ensureFixtureIndex({ + fixtureName: 'type-filters', + cacheName: 'type-filters', + cacheScope: 'shared', + requiredModes: ['code'] +}); + +console.log('fixture prewarm complete.'); diff --git a/tests/tooling/impact/impact-empty-input-strict.test.js b/tests/tooling/impact/empty-input-strict.test.js similarity index 100% rename from tests/tooling/impact/impact-empty-input-strict.test.js rename to tests/tooling/impact/empty-input-strict.test.js diff --git a/tests/tooling/impact/impact-seed-and-changed-behavior.test.js b/tests/tooling/impact/impact-seed-and-changed-behavior.test.js deleted file mode 100644 index 1eb396c2e..000000000 --- a/tests/tooling/impact/impact-seed-and-changed-behavior.test.js +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { runImpactCli } from '../../../src/integrations/tooling/impact.js'; -import { createImpactRepoFixture, removeImpactRepoFixture } from '../../helpers/impact-fixture.js'; - -const seedFixture = createImpactRepoFixture({ - prefix: 'impact-seed-', - compatibilityKey: 'compat-impact-seed', - graphRelations: { - version: 1, - callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { - nodeCount: 1, - edgeCount: 0, - nodes: [{ id: 'src/alpha.js', out: [], in: [] }] - } - } -}); - -const changedFixture = createImpactRepoFixture({ - prefix: 'impact-warning-', - compatibilityKey: 'compat-impact-warning', - graphRelations: { - version: 1, - callGraph: { - nodeCount: 1, - edgeCount: 0, - nodes: [{ id: 'chunk-a', file: 'src/a.js', out: [], in: [] }] - }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } - } -}); - -try { - const seedPayload = await runImpactCli([ - '--repo', - seedFixture.repoRoot, - '--seed', - 'file:src\\alpha.js', - '--depth', - '1', - '--direction', - 'downstream', - '--json' - ]); - assert.equal(seedPayload?.seed?.path, 'src/alpha.js'); - - const changedPayload = await runImpactCli([ - '--repo', - changedFixture.repoRoot, - '--seed', - 'chunk:chunk-a', - '--changed', - 'src/alpha.js', - '--depth', - '1', - '--direction', - 'downstream', - '--json' - ]); - assert.equal( - changedPayload?.warnings?.some((warning) => warning?.code === 'CHANGED_IGNORED'), - true, - 'expected warning when --changed is ignored because --seed is present' - ); -} finally { - removeImpactRepoFixture(seedFixture.repoRoot); - removeImpactRepoFixture(changedFixture.repoRoot); -} - -console.log('impact seed/changed behavior test passed'); diff --git a/tests/tooling/impact/seed-and-changed-behavior.test.js b/tests/tooling/impact/seed-and-changed-behavior.test.js new file mode 100644 index 000000000..e7174c09c --- /dev/null +++ b/tests/tooling/impact/seed-and-changed-behavior.test.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runImpactCli } from '../../../src/integrations/tooling/impact.js'; +import { createImpactRepoFixture, removeImpactRepoFixture } from '../../helpers/impact-fixture.js'; + +const seedFixture = createImpactRepoFixture({ + prefix: 'impact-seed-', + compatibilityKey: 'compat-impact-seed', + graphRelations: { + version: 1, + callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { + nodeCount: 1, + edgeCount: 0, + nodes: [{ id: 'src/alpha.js', out: [], in: [] }] + } + } +}); + +const changedFixture = createImpactRepoFixture({ + prefix: 'impact-warning-', + compatibilityKey: 'compat-impact-warning', + graphRelations: { + version: 1, + callGraph: { + nodeCount: 1, + edgeCount: 0, + nodes: [{ id: 'chunk-a', file: 'src/a.js', out: [], in: [] }] + }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } + } +}); + +const bothFixture = createImpactRepoFixture({ + prefix: 'impact-both-', + compatibilityKey: 'compat-impact-both', + graphRelations: { + version: 1, + callGraph: { + nodeCount: 3, + edgeCount: 2, + nodes: [ + { id: 'chunk-a', file: 'src/a.js', out: ['chunk-b'], in: [] }, + { id: 'chunk-b', file: 'src/b.js', out: ['chunk-c'], in: ['chunk-a'] }, + { id: 'chunk-c', file: 'src/c.js', out: [], in: ['chunk-b'] } + ] + }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { nodeCount: 0, edgeCount: 0, nodes: [] } + } +}); + +try { + const seedPayload = await runImpactCli([ + '--repo', + seedFixture.repoRoot, + '--seed', + 'file:src\\alpha.js', + '--depth', + '1', + '--direction', + 'downstream', + '--json' + ]); + assert.equal(seedPayload?.seed?.path, 'src/alpha.js'); + + const changedPayload = await runImpactCli([ + '--repo', + changedFixture.repoRoot, + '--seed', + 'chunk:chunk-a', + '--changed', + 'src/alpha.js', + '--depth', + '1', + '--direction', + 'downstream', + '--json' + ]); + assert.equal( + changedPayload?.warnings?.some((warning) => warning?.code === 'CHANGED_IGNORED'), + true, + 'expected warning when --changed is ignored because --seed is present' + ); + + const bothPayload = await runImpactCli([ + '--repo', + bothFixture.repoRoot, + '--seed', + 'chunk:chunk-b', + '--depth', + '1', + '--direction', + 'both', + '--json' + ]); + assert.equal(bothPayload.direction, 'both'); + const impacted = bothPayload.impacted.map((entry) => entry.ref?.chunkUid).filter(Boolean); + assert.ok(impacted.includes('chunk-a'), 'expected both direction to include upstream chunk'); + assert.ok(impacted.includes('chunk-c'), 'expected both direction to include downstream chunk'); +} finally { + removeImpactRepoFixture(seedFixture.repoRoot); + removeImpactRepoFixture(changedFixture.repoRoot); + removeImpactRepoFixture(bothFixture.repoRoot); +} + +console.log('impact seed/changed behavior test passed'); diff --git a/tests/tooling/index-stats/contract-matrix.test.js b/tests/tooling/index-stats/contract-matrix.test.js new file mode 100644 index 000000000..737de4671 --- /dev/null +++ b/tests/tooling/index-stats/contract-matrix.test.js @@ -0,0 +1,251 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; +import { getRepoCacheRoot, getRepoId, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const toolPath = path.join(root, 'tools', 'index', 'stats.js'); + +const runStats = (args, { + env = applyTestEnv({ syncProcess: false }), + allowFailure = false +} = {}) => runNode( + [toolPath, ...args], + 'index stats contract matrix', + root, + env, + { stdio: 'pipe', allowFailure } +); + +const writeJson = (filePath, value) => fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8'); + +const createIndexStatsRepoFixture = async (prefix, { + repoDirName = 'repo', + configCacheRootName = 'cache' +} = {}) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), `pairofcleats-${prefix}-`)); + const cacheRoot = path.join(tempRoot, 'cache'); + const configCacheRoot = path.join(tempRoot, configCacheRootName); + const repoRoot = path.join(tempRoot, repoDirName); + await fs.mkdir(repoRoot, { recursive: true }); + await writeJson(path.join(repoRoot, '.pairofcleats.json'), { + cache: { root: configCacheRoot } + }); + const userConfig = loadUserConfig(repoRoot); + const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); + const createBuildIndexDir = async (buildId, mode = 'code') => { + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + const indexDir = path.join(buildRoot, `index-${mode}`); + await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); + return { buildRoot, indexDir }; + }; + const writeCurrentBuild = (buildId, buildRoot) => writeJson( + path.join(repoCacheRoot, 'builds', 'current.json'), + { + buildId, + buildRoot + } + ); + return { + tempRoot, + cacheRoot, + repoRoot, + userConfig, + repoCacheRoot, + createBuildIndexDir, + writeCurrentBuild + }; +}; + +{ + const tempRoot = resolveTestCachePath(root, 'index-stats-aggregate'); + const indexRoot = path.join(tempRoot, 'build-root'); + await fs.rm(tempRoot, { recursive: true, force: true }); + + const writeModeManifest = async (mode, values) => { + const modeDir = path.join(indexRoot, `index-${mode}`); + await fs.mkdir(path.join(modeDir, 'pieces'), { recursive: true }); + await fs.writeFile(path.join(modeDir, 'index_state.json'), JSON.stringify({ + compatibilityKey: `compat-${mode}` + }, null, 2), 'utf8'); + await fs.writeFile(path.join(modeDir, 'pieces', 'manifest.json'), JSON.stringify({ + version: 2, + buildId: 'build-aggregate', + compatibilityKey: `compat-${mode}`, + artifactSurfaceVersion: 'surf-1', + pieces: [ + { name: 'chunk_meta', path: 'chunk_meta.json', bytes: values.chunkMeta, count: values.chunkRows }, + { name: 'token_postings', path: 'token_postings.json', bytes: values.tokenPostings, count: values.tokenRows }, + { name: 'phrase_ngrams', path: 'phrase_ngrams.json', bytes: values.phraseNgrams, count: values.phraseRows }, + { name: 'chargram_postings', path: 'chargram_postings.json', bytes: values.chargramPostings, count: values.chargramRows }, + { name: 'symbols', path: 'symbols.json', bytes: values.symbols, count: values.symbolRows }, + { name: 'symbol_occurrences', path: 'symbol_occurrences.json', bytes: values.symbolOccurrences, count: values.symbolOccurrenceRows }, + { name: 'symbol_edges', path: 'symbol_edges.json', bytes: values.symbolEdges, count: values.symbolEdgeRows }, + { name: 'graph_relations', path: 'graph_relations.json', bytes: values.graphRelations, count: values.graphRows }, + { name: 'call_sites', path: 'call_sites.json', bytes: values.callSites, count: values.callRows }, + { name: 'file_meta', path: 'file_meta.json', bytes: values.fileMeta, count: values.fileRows }, + { name: 'dense_vectors', path: 'dense_vectors.json', bytes: values.denseVectors, count: values.denseCount }, + { name: 'dense_vectors_hnsw', path: 'dense_vectors_hnsw.bin', bytes: values.hnsw }, + { name: 'dense_vectors_lancedb', path: 'dense_vectors_lancedb.db', bytes: values.lancedb } + ] + }, null, 2), 'utf8'); + }; + + await writeModeManifest('code', { + chunkMeta: 10, chunkRows: 2, tokenPostings: 20, tokenRows: 5, phraseNgrams: 30, phraseRows: 7, + chargramPostings: 40, chargramRows: 8, symbols: 50, symbolRows: 3, symbolOccurrences: 60, + symbolOccurrenceRows: 4, symbolEdges: 70, symbolEdgeRows: 5, graphRelations: 80, graphRows: 6, + callSites: 90, callRows: 7, fileMeta: 11, fileRows: 4, denseVectors: 100, denseCount: 9, hnsw: 110, lancedb: 120 + }); + await writeModeManifest('prose', { + chunkMeta: 4, chunkRows: 1, tokenPostings: 6, tokenRows: 2, phraseNgrams: 8, phraseRows: 3, + chargramPostings: 10, chargramRows: 4, symbols: 12, symbolRows: 1, symbolOccurrences: 14, + symbolOccurrenceRows: 1, symbolEdges: 16, symbolEdgeRows: 1, graphRelations: 18, graphRows: 1, + callSites: 20, callRows: 1, fileMeta: 5, fileRows: 2, denseVectors: 22, denseCount: 3, hnsw: 24, lancedb: 26 + }); + + const run = runStats(['--index-dir', indexRoot, '--json']); + assert.equal(run.status, 0, run.stderr || run.stdout); + const payload = JSON.parse(run.stdout); + assert.deepEqual(Object.keys(payload.modes), ['code', 'prose']); + assert.equal(payload.totals.chunkCount, 3); + assert.equal(payload.totals.fileCount, 6); + assert.equal(payload.totals.bytesByFamily.chunks, 14); + assert.equal(payload.totals.bytesByFamily.postings, 114); + assert.equal(payload.totals.bytesByFamily.symbols, 222); + assert.equal(payload.totals.bytesByFamily.relations, 208); + assert.equal(payload.totals.bytesByFamily.embeddings, 402); + assert.equal(payload.totals.totalBytes, 960); +} + +{ + const { + tempRoot, + cacheRoot, + repoRoot: explicitRepoPath, + createBuildIndexDir, + writeCurrentBuild + } = await createIndexStatsRepoFixture('index-stats-explicit-repo', { + repoDirName: 'explicit-repo', + configCacheRootName: 'parent-cache-root' + }); + const { buildRoot, indexDir } = await createBuildIndexDir('build-child'); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1}]', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"tokens":["alpha"]}', 'utf8'); + await writeJson(path.join(indexDir, 'index_state.json'), { + compatibilityKey: 'compat-child' + }); + await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { + version: 2, + repoId: getRepoId(explicitRepoPath), + buildId: 'build-child', + compatibilityKey: 'compat-child', + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + pieces: [ + { name: 'chunk_meta', path: 'chunk_meta.json', bytes: Buffer.byteLength('[{"id":1}]', 'utf8'), count: 1 }, + { name: 'token_postings', path: 'token_postings.json', bytes: Buffer.byteLength('{"tokens":["alpha"]}', 'utf8'), count: 1 } + ] + }); + await writeCurrentBuild('build-child', buildRoot); + + const run = runStats(['--repo', explicitRepoPath, '--json'], { + env: applyTestEnv({ cacheRoot, syncProcess: false }) + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + const payload = JSON.parse(run.stdout); + assert.equal(payload.repoId, getRepoId(explicitRepoPath)); + assert.equal(toRealPathSync(payload.indexRoot), toRealPathSync(buildRoot)); +} + +{ + const { + repoRoot, + createBuildIndexDir, + writeCurrentBuild + } = await createIndexStatsRepoFixture('index-stats-json'); + const { buildRoot, indexDir } = await createBuildIndexDir('build-1'); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1},{"id":2}]', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"tokens":["alpha"]}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'phrase_ngrams.json'), '{"rows":1}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'chargram_postings.json'), '{"rows":1}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'file_meta.json'), '[{"path":"a.js"},{"path":"b.js"}]', 'utf8'); + await writeJson(path.join(indexDir, 'index_state.json'), { compatibilityKey: 'compat-build-1' }); + + const chunkBytes = Buffer.byteLength('[{"id":1},{"id":2}]', 'utf8'); + const tokenBytes = Buffer.byteLength('{"tokens":["alpha"]}', 'utf8'); + const phraseBytes = Buffer.byteLength('{"rows":1}', 'utf8'); + const chargramBytes = Buffer.byteLength('{"rows":1}', 'utf8'); + const fileMetaBytes = Buffer.byteLength('[{"path":"a.js"},{"path":"b.js"}]', 'utf8'); + + await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { + version: 2, + repoId: 'repo-manifest-id', + buildId: 'build-1', + compatibilityKey: 'compat-build-1', + artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, + pieces: [ + { name: 'chunk_meta', path: 'chunk_meta.json', bytes: chunkBytes, count: 2 }, + { name: 'token_postings', path: 'token_postings.json', bytes: tokenBytes, count: 3 }, + { name: 'phrase_ngrams', path: 'phrase_ngrams.json', bytes: phraseBytes, count: 1 }, + { name: 'chargram_postings', path: 'chargram_postings.json', bytes: chargramBytes, count: 1 }, + { name: 'file_meta', path: 'file_meta.json', bytes: fileMetaBytes, count: 2 } + ] + }); + await writeCurrentBuild('build-1', buildRoot); + + const run = runStats(['--repo', repoRoot, '--json']); + assert.equal(run.status, 0, run.stderr || run.stdout); + const payload = JSON.parse(run.stdout); + assert.equal(payload.schemaVersion, 1); + assert.equal(payload.buildId, 'build-1'); + assert.equal(payload.compatibilityKey, 'compat-build-1'); + assert.equal(payload.artifactSurfaceVersion, ARTIFACT_SURFACE_VERSION); + assert.deepEqual(Object.keys(payload.modes), ['code']); + assert.equal(payload.modes.code.chunkMeta.rows, 2); + assert.equal(payload.modes.code.tokenPostings.rows, 3); + assert.equal(payload.modes.code.fileMeta.rows, 2); + assert.equal(payload.totals.chunkCount, 2); + assert.equal(payload.totals.fileCount, 2); + assert.equal(payload.totals.bytesByFamily.chunks, chunkBytes); + assert.equal(payload.totals.bytesByFamily.postings, tokenBytes + phraseBytes + chargramBytes); +} + +{ + const { + repoRoot, + createBuildIndexDir, + writeCurrentBuild + } = await createIndexStatsRepoFixture('index-stats-missing'); + const { buildRoot, indexDir } = await createBuildIndexDir('build-verify'); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1}]', 'utf8'); + await writeJson(path.join(indexDir, 'index_state.json'), { compatibilityKey: 'compat-verify' }); + await writeJson(path.join(indexDir, 'pieces', 'manifest.json'), { + version: 2, + buildId: 'build-verify', + compatibilityKey: 'compat-verify', + pieces: [ + { name: 'chunk_meta', path: 'chunk_meta.json', bytes: 10, count: 1, checksum: 'xxh64:deadbeef' }, + { name: 'token_postings', path: 'token_postings.json', bytes: 24, count: 2 } + ] + }); + await writeCurrentBuild('build-verify', buildRoot); + + const run = runStats(['--repo', repoRoot, '--verify', '--json'], { allowFailure: true }); + assert.equal(run.status, 1); + const payload = JSON.parse(run.stdout); + assert.equal(payload.verify?.ok, false); + assert.ok(payload.verify.errors.some((entry) => entry.includes('missing artifact token_postings.json'))); + assert.ok(payload.verify.warnings.some((entry) => entry.includes('checksum mismatch'))); +} + +console.log('index stats contract matrix test passed'); diff --git a/tests/tooling/index-stats/index-diff-mode-alias-and-compact-validation.test.js b/tests/tooling/index-stats/index-diff-mode-alias-and-compact-validation.test.js index 807cb915f..b920cc468 100644 --- a/tests/tooling/index-stats/index-diff-mode-alias-and-compact-validation.test.js +++ b/tests/tooling/index-stats/index-diff-mode-alias-and-compact-validation.test.js @@ -2,19 +2,20 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); const tempRoot = resolveTestCachePath(root, 'index-diff-mode-alias-and-compact-validation'); const repoRoot = path.join(tempRoot, 'repo'); +const env = applyTestEnv({ syncProcess: false }); await fs.rm(tempRoot, { recursive: true, force: true }); await fs.mkdir(repoRoot, { recursive: true }); -const listWithMode = spawnSync( - process.execPath, +const listWithMode = runNode( [ path.join(root, 'tools', 'index-diff.js'), 'list', @@ -24,7 +25,10 @@ const listWithMode = spawnSync( 'code', '--json' ], - { cwd: root, encoding: 'utf8' } + 'index diff list mode alias', + root, + env, + { stdio: 'pipe' } ); assert.equal(listWithMode.status, 0, 'expected tools/index-diff.js list --mode to succeed'); @@ -32,8 +36,7 @@ const payload = JSON.parse(String(listWithMode.stdout || '{}')); assert.equal(payload?.ok, true, 'expected json payload ok=true'); assert.ok(Array.isArray(payload?.diffs), 'expected diffs array in json payload'); -const compactRejected = spawnSync( - process.execPath, +const compactRejected = runNode( [ path.join(root, 'bin', 'pairofcleats.js'), 'index', @@ -43,7 +46,10 @@ const compactRejected = spawnSync( repoRoot, '--compact' ], - { cwd: root, encoding: 'utf8' } + 'index diff compact rejection', + root, + env, + { stdio: 'pipe', allowFailure: true } ); assert.notEqual(compactRejected.status, 0, 'expected --compact to be rejected'); diff --git a/tests/tooling/index-stats/index-stats-aggregate.test.js b/tests/tooling/index-stats/index-stats-aggregate.test.js deleted file mode 100644 index 426b22418..000000000 --- a/tests/tooling/index-stats/index-stats-aggregate.test.js +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'index-stats-aggregate'); -const indexRoot = path.join(tempRoot, 'build-root'); -const toolPath = path.join(root, 'tools', 'index', 'stats.js'); - -const writeModeManifest = async (mode, values) => { - const modeDir = path.join(indexRoot, `index-${mode}`); - await fs.mkdir(path.join(modeDir, 'pieces'), { recursive: true }); - await fs.writeFile(path.join(modeDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: `compat-${mode}` - }, null, 2), 'utf8'); - await fs.writeFile(path.join(modeDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - buildId: 'build-aggregate', - compatibilityKey: `compat-${mode}`, - artifactSurfaceVersion: 'surf-1', - pieces: [ - { name: 'chunk_meta', path: 'chunk_meta.json', bytes: values.chunkMeta, count: values.chunkRows }, - { name: 'token_postings', path: 'token_postings.json', bytes: values.tokenPostings, count: values.tokenRows }, - { name: 'phrase_ngrams', path: 'phrase_ngrams.json', bytes: values.phraseNgrams, count: values.phraseRows }, - { name: 'chargram_postings', path: 'chargram_postings.json', bytes: values.chargramPostings, count: values.chargramRows }, - { name: 'symbols', path: 'symbols.json', bytes: values.symbols, count: values.symbolRows }, - { name: 'symbol_occurrences', path: 'symbol_occurrences.json', bytes: values.symbolOccurrences, count: values.symbolOccurrenceRows }, - { name: 'symbol_edges', path: 'symbol_edges.json', bytes: values.symbolEdges, count: values.symbolEdgeRows }, - { name: 'graph_relations', path: 'graph_relations.json', bytes: values.graphRelations, count: values.graphRows }, - { name: 'call_sites', path: 'call_sites.json', bytes: values.callSites, count: values.callRows }, - { name: 'file_meta', path: 'file_meta.json', bytes: values.fileMeta, count: values.fileRows }, - { name: 'dense_vectors', path: 'dense_vectors.json', bytes: values.denseVectors, count: values.denseCount }, - { name: 'dense_vectors_hnsw', path: 'dense_vectors_hnsw.bin', bytes: values.hnsw }, - { name: 'dense_vectors_lancedb', path: 'dense_vectors_lancedb.db', bytes: values.lancedb } - ] - }, null, 2), 'utf8'); -}; - -await fs.rm(tempRoot, { recursive: true, force: true }); - -await writeModeManifest('code', { - chunkMeta: 10, - chunkRows: 2, - tokenPostings: 20, - tokenRows: 5, - phraseNgrams: 30, - phraseRows: 7, - chargramPostings: 40, - chargramRows: 8, - symbols: 50, - symbolRows: 3, - symbolOccurrences: 60, - symbolOccurrenceRows: 4, - symbolEdges: 70, - symbolEdgeRows: 5, - graphRelations: 80, - graphRows: 6, - callSites: 90, - callRows: 7, - fileMeta: 11, - fileRows: 4, - denseVectors: 100, - denseCount: 9, - hnsw: 110, - lancedb: 120 -}); - -await writeModeManifest('prose', { - chunkMeta: 4, - chunkRows: 1, - tokenPostings: 6, - tokenRows: 2, - phraseNgrams: 8, - phraseRows: 3, - chargramPostings: 10, - chargramRows: 4, - symbols: 12, - symbolRows: 1, - symbolOccurrences: 14, - symbolOccurrenceRows: 1, - symbolEdges: 16, - symbolEdgeRows: 1, - graphRelations: 18, - graphRows: 1, - callSites: 20, - callRows: 1, - fileMeta: 5, - fileRows: 2, - denseVectors: 22, - denseCount: 3, - hnsw: 24, - lancedb: 26 -}); - -const run = spawnSync( - process.execPath, - [toolPath, '--index-dir', indexRoot, '--json'], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); -assert.deepEqual(Object.keys(payload.modes), ['code', 'prose']); -assert.equal(payload.totals.chunkCount, 3); -assert.equal(payload.totals.fileCount, 6); -assert.equal(payload.totals.bytesByFamily.chunks, 14); -assert.equal(payload.totals.bytesByFamily.postings, 114); -assert.equal(payload.totals.bytesByFamily.symbols, 222); -assert.equal(payload.totals.bytesByFamily.relations, 208); -assert.equal(payload.totals.bytesByFamily.embeddings, 402); -assert.equal(payload.totals.totalBytes, 960); - -console.log('index stats aggregate test passed'); diff --git a/tests/tooling/index-stats/index-stats-explicit-repo-path.test.js b/tests/tooling/index-stats/index-stats-explicit-repo-path.test.js deleted file mode 100644 index a34b8dbe7..000000000 --- a/tests/tooling/index-stats/index-stats-explicit-repo-path.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot, getRepoId, loadUserConfig, toRealPathSync } from '../../../tools/shared/dict-utils.js'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-index-stats-explicit-repo-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const parentRoot = path.join(tempRoot, 'parent'); -const explicitRepoPath = path.join(parentRoot, 'child'); -const toolPath = path.join(root, 'tools', 'index', 'stats.js'); -process.env.PAIROFCLEATS_CACHE_ROOT = cacheRoot; - -await fs.mkdir(explicitRepoPath, { recursive: true }); -await fs.writeFile(path.join(parentRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: path.join(tempRoot, 'parent-cache-root') } -}, null, 2), 'utf8'); - -const explicitUserConfig = loadUserConfig(explicitRepoPath); -const explicitRepoCacheRoot = getRepoCacheRoot(explicitRepoPath, explicitUserConfig); -const buildRoot = path.join(explicitRepoCacheRoot, 'builds', 'build-child'); -const indexDir = path.join(buildRoot, 'index-code'); - -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1}]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"tokens":["alpha"]}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-child' -}, null, 2), 'utf8'); -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - repoId: getRepoId(explicitRepoPath), - buildId: 'build-child', - compatibilityKey: 'compat-child', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - pieces: [ - { name: 'chunk_meta', path: 'chunk_meta.json', bytes: Buffer.byteLength('[{"id":1}]', 'utf8'), count: 1 }, - { name: 'token_postings', path: 'token_postings.json', bytes: Buffer.byteLength('{"tokens":["alpha"]}', 'utf8'), count: 1 } - ] -}, null, 2), 'utf8'); -await fs.mkdir(path.join(explicitRepoCacheRoot, 'builds'), { recursive: true }); -await fs.writeFile(path.join(explicitRepoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'build-child', - buildRoot -}, null, 2), 'utf8'); - -const run = spawnSync( - process.execPath, - [toolPath, '--repo', explicitRepoPath, '--json'], - { - encoding: 'utf8', - env: { - ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot - } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); - -assert.equal(payload.repoId, getRepoId(explicitRepoPath), 'repoId should derive from explicit --repo path'); -assert.equal( - toRealPathSync(payload.indexRoot), - toRealPathSync(buildRoot), - 'index stats should use explicit --repo cache/index roots' -); - -console.log('index stats explicit repo path test passed'); diff --git a/tests/tooling/index-stats/index-stats-json.test.js b/tests/tooling/index-stats/index-stats-json.test.js deleted file mode 100644 index d0919978a..000000000 --- a/tests/tooling/index-stats/index-stats-json.test.js +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { ARTIFACT_SURFACE_VERSION } from '../../../src/contracts/versioning.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-index-stats-json-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const toolPath = path.join(root, 'tools', 'index', 'stats.js'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); -const indexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1},{"id":2}]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"tokens":["alpha"]}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'phrase_ngrams.json'), '{"rows":1}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'chargram_postings.json'), '{"rows":1}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'file_meta.json'), '[{"path":"a.js"},{"path":"b.js"}]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-build-1' -}, null, 2), 'utf8'); - -const chunkBytes = Buffer.byteLength('[{"id":1},{"id":2}]', 'utf8'); -const tokenBytes = Buffer.byteLength('{"tokens":["alpha"]}', 'utf8'); -const phraseBytes = Buffer.byteLength('{"rows":1}', 'utf8'); -const chargramBytes = Buffer.byteLength('{"rows":1}', 'utf8'); -const fileMetaBytes = Buffer.byteLength('[{"path":"a.js"},{"path":"b.js"}]', 'utf8'); - -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - repoId: 'repo-manifest-id', - buildId: 'build-1', - compatibilityKey: 'compat-build-1', - artifactSurfaceVersion: ARTIFACT_SURFACE_VERSION, - pieces: [ - { name: 'chunk_meta', path: 'chunk_meta.json', bytes: chunkBytes, count: 2 }, - { name: 'token_postings', path: 'token_postings.json', bytes: tokenBytes, count: 3 }, - { name: 'phrase_ngrams', path: 'phrase_ngrams.json', bytes: phraseBytes, count: 1 }, - { name: 'chargram_postings', path: 'chargram_postings.json', bytes: chargramBytes, count: 1 }, - { name: 'file_meta', path: 'file_meta.json', bytes: fileMetaBytes, count: 2 } - ] -}, null, 2), 'utf8'); - -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'build-1', - buildRoot -}, null, 2), 'utf8'); - -const run = spawnSync( - process.execPath, - [toolPath, '--repo', repoRoot, '--json'], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); -assert.equal(payload.schemaVersion, 1); -assert.equal(payload.buildId, 'build-1'); -assert.equal(payload.compatibilityKey, 'compat-build-1'); -assert.equal(payload.artifactSurfaceVersion, ARTIFACT_SURFACE_VERSION); -assert.deepEqual(Object.keys(payload.modes), ['code']); -assert.equal(payload.modes.code.chunkMeta.rows, 2); -assert.equal(payload.modes.code.tokenPostings.rows, 3); -assert.equal(payload.modes.code.fileMeta.rows, 2); -assert.equal(payload.totals.chunkCount, 2); -assert.equal(payload.totals.fileCount, 2); -assert.equal(payload.totals.bytesByFamily.chunks, chunkBytes); -assert.equal(payload.totals.bytesByFamily.postings, tokenBytes + phraseBytes + chargramBytes); - -console.log('index stats json test passed'); diff --git a/tests/tooling/index-stats/index-stats-missing-artifact.test.js b/tests/tooling/index-stats/index-stats-missing-artifact.test.js deleted file mode 100644 index 0d7fcb7e8..000000000 --- a/tests/tooling/index-stats/index-stats-missing-artifact.test.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-index-stats-missing-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const toolPath = path.join(root, 'tools', 'index', 'stats.js'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const buildRoot = path.join(repoCacheRoot, 'builds', 'build-verify'); -const indexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[{"id":1}]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-verify' -}, null, 2), 'utf8'); - -await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ - version: 2, - buildId: 'build-verify', - compatibilityKey: 'compat-verify', - pieces: [ - { - name: 'chunk_meta', - path: 'chunk_meta.json', - bytes: 10, - count: 1, - checksum: 'xxh64:deadbeef' - }, - { - name: 'token_postings', - path: 'token_postings.json', - bytes: 24, - count: 2 - } - ] -}, null, 2), 'utf8'); - -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'build-verify', - buildRoot -}, null, 2), 'utf8'); - -const run = spawnSync( - process.execPath, - [toolPath, '--repo', repoRoot, '--verify', '--json'], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 1, 'verify should fail when required artifacts are missing/mismatched'); -const payload = JSON.parse(run.stdout); -assert.equal(payload.verify?.ok, false); -assert.ok( - payload.verify.errors.some((entry) => entry.includes('missing artifact token_postings.json')), - `expected missing token_postings artifact error, got: ${payload.verify.errors.join('; ')}` -); -assert.ok( - payload.verify.warnings.some((entry) => entry.includes('checksum mismatch')), - `expected checksum mismatch warning, got: ${payload.verify.warnings.join('; ')}` -); - -console.log('index stats missing artifact test passed'); diff --git a/tests/tooling/index/reconcile-identity-cli.test.js b/tests/tooling/index/reconcile-identity-cli.test.js new file mode 100644 index 000000000..c7ca2816f --- /dev/null +++ b/tests/tooling/index/reconcile-identity-cli.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { createIdentityReconciliationDriftIndex } from '../../helpers/identity-reconciliation-fixture.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const { indexRoot } = await createIdentityReconciliationDriftIndex({ + root, + cacheName: 'reconcile-identity-cli' +}); + +const result = runNode( + ['tools/index/reconcile-identity.js', '--index-root', indexRoot, '--mode', 'code', '--json'], + 'reconcile identity cli', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', allowFailure: true } +); + +assert.equal(result.status, 1, `expected failing exit status, got ${result.status}\n${result.stderr}`); +const report = JSON.parse(result.stdout); +assert.equal(report.ok, false, 'expected failing JSON report'); +assert.ok( + report.issues.some((issue) => /symbols chunkUid missing in chunk_meta/i.test(issue.message)), + 'expected CLI report to surface symbol drift' +); + +console.log('reconcile identity CLI test passed'); diff --git a/tests/tooling/ingest/ctags/ctags-ingest.test.js b/tests/tooling/ingest/ctags/ctags-ingest.test.js deleted file mode 100644 index b77e71416..000000000 --- a/tests/tooling/ingest/ctags/ctags-ingest.test.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'ctags-ingest'); -const cliPath = path.join(root, 'bin', 'pairofcleats.js'); -const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const inputPath = path.join(root, 'tests', 'fixtures', 'ctags', 'tags.jsonl'); -const outPath = path.join(tempRoot, 'ctags.jsonl'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - - -const result = spawnSync( - process.execPath, - [cliPath, 'ingest', 'ctags', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], - { encoding: 'utf8' } -); -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'ctags-ingest failed'); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('ctags output not found'); - process.exit(1); -} - -const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); -assert.ok(lines.length >= 2, 'expected ctags output lines'); - -const first = JSON.parse(lines[0]); -assert.equal(first.file, 'src/widget.js'); -assert.equal(first.name, 'Widget'); -assert.equal(first.kind, 'class'); -assert.equal(first.language, 'JavaScript'); -assert.equal(first.startLine, 3); - -const metaPath = `${outPath}.meta.json`; -const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); -assert.equal(meta.stats.entries, lines.length); - -const escapeInputPath = path.join(tempRoot, 'escape-tags.jsonl'); -const escapeOutPath = path.join(tempRoot, 'escape-ctags.jsonl'); -const outsidePath = path.join(root, 'outside.js'); -await fsPromises.writeFile(escapeInputPath, [ - JSON.stringify({ _type: 'tag', name: 'kept', path: 'src/kept.js', line: 1, kind: 'function' }), - JSON.stringify({ _type: 'tag', name: 'escaped', path: '../outside.js', line: 2, kind: 'function' }), - JSON.stringify({ _type: 'tag', name: 'absolute', path: outsidePath, line: 3, kind: 'function' }) -].join('\n')); -const escapeResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'ctags', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], - { encoding: 'utf8' } -); -if (escapeResult.status !== 0) { - console.error(escapeResult.stderr || escapeResult.stdout || 'ctags escape ingest failed'); - process.exit(escapeResult.status ?? 1); -} -const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -assert.equal(escapedLines.length, 1, 'expected out-of-repo ctags paths to be dropped'); -assert.equal(escapedLines[0].file, 'src/kept.js'); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); -assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); - -console.log('ctags ingest test passed'); - diff --git a/tests/tooling/ingest/ctags/ingest.test.js b/tests/tooling/ingest/ctags/ingest.test.js new file mode 100644 index 000000000..3c1de756e --- /dev/null +++ b/tests/tooling/ingest/ctags/ingest.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { runNode } from '../../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'ctags-ingest'); +const cliPath = path.join(root, 'bin', 'pairofcleats.js'); +const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const inputPath = path.join(root, 'tests', 'fixtures', 'ctags', 'tags.jsonl'); +const outPath = path.join(tempRoot, 'ctags.jsonl'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + + +const result = runNode( + [cliPath, 'ingest', 'ctags', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], + 'ctags ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'ctags-ingest failed'); + process.exit(result.status ?? 1); +} + +if (!fs.existsSync(outPath)) { + console.error('ctags output not found'); + process.exit(1); +} + +const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); +assert.ok(lines.length >= 2, 'expected ctags output lines'); + +const first = JSON.parse(lines[0]); +assert.equal(first.file, 'src/widget.js'); +assert.equal(first.name, 'Widget'); +assert.equal(first.kind, 'class'); +assert.equal(first.language, 'JavaScript'); +assert.equal(first.startLine, 3); + +const metaPath = `${outPath}.meta.json`; +const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); +assert.equal(meta.stats.entries, lines.length); + +const escapeInputPath = path.join(tempRoot, 'escape-tags.jsonl'); +const escapeOutPath = path.join(tempRoot, 'escape-ctags.jsonl'); +const outsidePath = path.join(root, 'outside.js'); +await fsPromises.writeFile(escapeInputPath, [ + JSON.stringify({ _type: 'tag', name: 'kept', path: 'src/kept.js', line: 1, kind: 'function' }), + JSON.stringify({ _type: 'tag', name: 'escaped', path: '../outside.js', line: 2, kind: 'function' }), + JSON.stringify({ _type: 'tag', name: 'absolute', path: outsidePath, line: 3, kind: 'function' }) +].join('\n')); +const escapeResult = runNode( + [cliPath, 'ingest', 'ctags', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], + 'ctags escape ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (escapeResult.status !== 0) { + console.error(escapeResult.stderr || escapeResult.stdout || 'ctags escape ingest failed'); + process.exit(escapeResult.status ?? 1); +} +const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +assert.equal(escapedLines.length, 1, 'expected out-of-repo ctags paths to be dropped'); +assert.equal(escapedLines[0].file, 'src/kept.js'); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); +assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); + +console.log('ctags ingest test passed'); + diff --git a/tests/tooling/ingest/gtags/gtags-ingest.test.js b/tests/tooling/ingest/gtags/gtags-ingest.test.js deleted file mode 100644 index 9e8b4e807..000000000 --- a/tests/tooling/ingest/gtags/gtags-ingest.test.js +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'gtags-ingest'); -const cliPath = path.join(root, 'bin', 'pairofcleats.js'); -const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const inputPath = path.join(root, 'tests', 'fixtures', 'gtags', 'gtags.txt'); -const outPath = path.join(tempRoot, 'gtags.jsonl'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - - -const result = spawnSync( - process.execPath, - [cliPath, 'ingest', 'gtags', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], - { encoding: 'utf8' } -); -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'gtags-ingest failed'); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('gtags output not found'); - process.exit(1); -} - -const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); -assert.ok(lines.length >= 2, 'expected gtags output lines'); - -const first = JSON.parse(lines[0]); -assert.equal(first.file, 'src/widget.js'); -assert.equal(first.name, 'Widget'); -assert.equal(first.startLine, 3); -assert.equal(first.source, 'gtags'); - -const metaPath = `${outPath}.meta.json`; -const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); -assert.equal(meta.stats.entries, lines.length); - -const escapeInputPath = path.join(tempRoot, 'escape-gtags.txt'); -const escapeOutPath = path.join(tempRoot, 'escape-gtags.jsonl'); -const outsidePath = path.join(root, 'outside.js'); -await fsPromises.writeFile(escapeInputPath, [ - 'kept 1 src/kept.js', - 'escaped 2 ../outside.js', - `absolute 3 ${outsidePath}` -].join('\n')); -const escapeResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'gtags', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], - { encoding: 'utf8' } -); -if (escapeResult.status !== 0) { - console.error(escapeResult.stderr || escapeResult.stdout || 'gtags escape ingest failed'); - process.exit(escapeResult.status ?? 1); -} -const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -assert.equal(escapedLines.length, 1, 'expected out-of-repo gtags paths to be dropped'); -assert.equal(escapedLines[0].file, 'src/kept.js'); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); -assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); - -const missingInputPath = path.join(tempRoot, 'missing-gtags.txt'); -const missingResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'gtags', '--repo', repoRoot, '--input', missingInputPath, '--out', path.join(tempRoot, 'missing.jsonl'), '--json'], - { encoding: 'utf8' } -); -assert.notEqual(missingResult.status, 0, 'expected missing input to fail'); -const missingOutput = `${missingResult.stderr || ''}${missingResult.stdout || ''}`; -assert.equal( - missingOutput.includes("Unhandled 'error' event"), - false, - 'expected missing input failure to avoid unhandled stream error' -); - -console.log('gtags ingest test passed'); - diff --git a/tests/tooling/ingest/gtags/ingest.test.js b/tests/tooling/ingest/gtags/ingest.test.js new file mode 100644 index 000000000..6666f6d2f --- /dev/null +++ b/tests/tooling/ingest/gtags/ingest.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { assertMissingIngestInputFailsCleanly } from '../missing-input-helper.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'gtags-ingest'); +const cliPath = path.join(root, 'bin', 'pairofcleats.js'); +const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const inputPath = path.join(root, 'tests', 'fixtures', 'gtags', 'gtags.txt'); +const outPath = path.join(tempRoot, 'gtags.jsonl'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + + +const result = runNode( + [cliPath, 'ingest', 'gtags', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], + 'gtags ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'gtags-ingest failed'); + process.exit(result.status ?? 1); +} + +if (!fs.existsSync(outPath)) { + console.error('gtags output not found'); + process.exit(1); +} + +const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); +assert.ok(lines.length >= 2, 'expected gtags output lines'); + +const first = JSON.parse(lines[0]); +assert.equal(first.file, 'src/widget.js'); +assert.equal(first.name, 'Widget'); +assert.equal(first.startLine, 3); +assert.equal(first.source, 'gtags'); + +const metaPath = `${outPath}.meta.json`; +const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); +assert.equal(meta.stats.entries, lines.length); + +const escapeInputPath = path.join(tempRoot, 'escape-gtags.txt'); +const escapeOutPath = path.join(tempRoot, 'escape-gtags.jsonl'); +const outsidePath = path.join(root, 'outside.js'); +await fsPromises.writeFile(escapeInputPath, [ + 'kept 1 src/kept.js', + 'escaped 2 ../outside.js', + `absolute 3 ${outsidePath}` +].join('\n')); +const escapeResult = runNode( + [cliPath, 'ingest', 'gtags', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], + 'gtags escape ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (escapeResult.status !== 0) { + console.error(escapeResult.stderr || escapeResult.stdout || 'gtags escape ingest failed'); + process.exit(escapeResult.status ?? 1); +} +const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +assert.equal(escapedLines.length, 1, 'expected out-of-repo gtags paths to be dropped'); +assert.equal(escapedLines[0].file, 'src/kept.js'); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); +assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); + +const missingInputPath = path.join(tempRoot, 'missing-gtags.txt'); +assertMissingIngestInputFailsCleanly({ + cliPath, + kind: 'gtags', + repoRoot, + missingInputPath, + outPath: path.join(tempRoot, 'missing.jsonl') +}); + +console.log('gtags ingest test passed'); + diff --git a/tests/tooling/ingest/lsif/ingest.test.js b/tests/tooling/ingest/lsif/ingest.test.js new file mode 100644 index 000000000..6efa801c6 --- /dev/null +++ b/tests/tooling/ingest/lsif/ingest.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { assertMissingIngestInputFailsCleanly } from '../missing-input-helper.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsif-ingest'); +const cliPath = path.join(root, 'bin', 'pairofcleats.js'); +const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const inputPath = path.join(root, 'tests', 'fixtures', 'lsif', 'dump.lsif'); +const outPath = path.join(tempRoot, 'lsif.jsonl'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + + +const result = runNode( + [cliPath, 'ingest', 'lsif', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], + 'lsif ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'lsif-ingest failed'); + process.exit(result.status ?? 1); +} + +if (!fs.existsSync(outPath)) { + console.error('lsif output not found'); + process.exit(1); +} + +const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); +assert.ok(lines.length >= 1, 'expected lsif output lines'); + +const first = JSON.parse(lines[0]); +assert.equal(first.file, 'src/sample.ts'); +assert.equal(first.role, 'definition'); +assert.equal(first.startLine, 2); +assert.equal(first.language, 'typescript'); + +const metaPath = `${outPath}.meta.json`; +const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); +assert.ok(meta.stats.vertices >= 4); +assert.ok(meta.stats.edges >= 2); +assert.ok(meta.stats.definitions >= 1); +assert.ok(meta.stats.references >= 1); + +const escapeInputPath = path.join(tempRoot, 'escape.lsif'); +const escapeOutPath = path.join(tempRoot, 'escape-lsif.jsonl'); +await fsPromises.writeFile(escapeInputPath, [ + JSON.stringify({ id: 1, type: 'vertex', label: 'document', uri: 'file:///repo/src/kept.ts', languageId: 'typescript' }), + JSON.stringify({ id: 2, type: 'vertex', label: 'document', uri: 'file:///repo/../outside.ts', languageId: 'typescript' }), + JSON.stringify({ id: 3, type: 'vertex', label: 'document', uri: 'not a valid uri', languageId: 'typescript' }), + JSON.stringify({ id: 10, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), + JSON.stringify({ id: 11, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), + JSON.stringify({ id: 12, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), + JSON.stringify({ id: 100, type: 'vertex', label: 'definitionResult' }), + JSON.stringify({ id: 101, type: 'vertex', label: 'definitionResult' }), + JSON.stringify({ id: 102, type: 'vertex', label: 'definitionResult' }), + JSON.stringify({ id: 200, type: 'edge', label: 'contains', outV: 1, inVs: [10] }), + JSON.stringify({ id: 201, type: 'edge', label: 'contains', outV: 2, inVs: [11] }), + JSON.stringify({ id: 202, type: 'edge', label: 'contains', outV: 3, inVs: [12] }), + JSON.stringify({ id: 300, type: 'edge', label: 'item', outV: 10, inVs: [100] }), + JSON.stringify({ id: 301, type: 'edge', label: 'item', outV: 11, inVs: [101] }), + JSON.stringify({ id: 302, type: 'edge', label: 'item', outV: 12, inVs: [102] }) +].join('\n')); +const escapeResult = runNode( + [cliPath, 'ingest', 'lsif', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], + 'lsif escape ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (escapeResult.status !== 0) { + console.error(escapeResult.stderr || escapeResult.stdout || 'lsif escape ingest failed'); + process.exit(escapeResult.status ?? 1); +} +const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +assert.equal(escapedLines.length, 1, 'expected out-of-repo lsif paths to be dropped'); +assert.equal(escapedLines[0].file, 'src/kept.ts'); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); +assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); + +const missingInputPath = path.join(tempRoot, 'missing.lsif'); +assertMissingIngestInputFailsCleanly({ + cliPath, + kind: 'lsif', + repoRoot, + missingInputPath, + outPath: path.join(tempRoot, 'missing.jsonl') +}); + +console.log('lsif ingest test passed'); + diff --git a/tests/tooling/ingest/lsif/lsif-ingest.test.js b/tests/tooling/ingest/lsif/lsif-ingest.test.js deleted file mode 100644 index 8a737e615..000000000 --- a/tests/tooling/ingest/lsif/lsif-ingest.test.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsif-ingest'); -const cliPath = path.join(root, 'bin', 'pairofcleats.js'); -const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const inputPath = path.join(root, 'tests', 'fixtures', 'lsif', 'dump.lsif'); -const outPath = path.join(tempRoot, 'lsif.jsonl'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - - -const result = spawnSync( - process.execPath, - [cliPath, 'ingest', 'lsif', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], - { encoding: 'utf8' } -); -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'lsif-ingest failed'); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('lsif output not found'); - process.exit(1); -} - -const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); -assert.ok(lines.length >= 1, 'expected lsif output lines'); - -const first = JSON.parse(lines[0]); -assert.equal(first.file, 'src/sample.ts'); -assert.equal(first.role, 'definition'); -assert.equal(first.startLine, 2); -assert.equal(first.language, 'typescript'); - -const metaPath = `${outPath}.meta.json`; -const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); -assert.ok(meta.stats.vertices >= 4); -assert.ok(meta.stats.edges >= 2); -assert.ok(meta.stats.definitions >= 1); -assert.ok(meta.stats.references >= 1); - -const escapeInputPath = path.join(tempRoot, 'escape.lsif'); -const escapeOutPath = path.join(tempRoot, 'escape-lsif.jsonl'); -await fsPromises.writeFile(escapeInputPath, [ - JSON.stringify({ id: 1, type: 'vertex', label: 'document', uri: 'file:///repo/src/kept.ts', languageId: 'typescript' }), - JSON.stringify({ id: 2, type: 'vertex', label: 'document', uri: 'file:///repo/../outside.ts', languageId: 'typescript' }), - JSON.stringify({ id: 3, type: 'vertex', label: 'document', uri: 'not a valid uri', languageId: 'typescript' }), - JSON.stringify({ id: 10, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), - JSON.stringify({ id: 11, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), - JSON.stringify({ id: 12, type: 'vertex', label: 'range', start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), - JSON.stringify({ id: 100, type: 'vertex', label: 'definitionResult' }), - JSON.stringify({ id: 101, type: 'vertex', label: 'definitionResult' }), - JSON.stringify({ id: 102, type: 'vertex', label: 'definitionResult' }), - JSON.stringify({ id: 200, type: 'edge', label: 'contains', outV: 1, inVs: [10] }), - JSON.stringify({ id: 201, type: 'edge', label: 'contains', outV: 2, inVs: [11] }), - JSON.stringify({ id: 202, type: 'edge', label: 'contains', outV: 3, inVs: [12] }), - JSON.stringify({ id: 300, type: 'edge', label: 'item', outV: 10, inVs: [100] }), - JSON.stringify({ id: 301, type: 'edge', label: 'item', outV: 11, inVs: [101] }), - JSON.stringify({ id: 302, type: 'edge', label: 'item', outV: 12, inVs: [102] }) -].join('\n')); -const escapeResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'lsif', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], - { encoding: 'utf8' } -); -if (escapeResult.status !== 0) { - console.error(escapeResult.stderr || escapeResult.stdout || 'lsif escape ingest failed'); - process.exit(escapeResult.status ?? 1); -} -const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -assert.equal(escapedLines.length, 1, 'expected out-of-repo lsif paths to be dropped'); -assert.equal(escapedLines[0].file, 'src/kept.ts'); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); -assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); - -const missingInputPath = path.join(tempRoot, 'missing.lsif'); -const missingResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'lsif', '--repo', repoRoot, '--input', missingInputPath, '--out', path.join(tempRoot, 'missing.jsonl'), '--json'], - { encoding: 'utf8' } -); -assert.notEqual(missingResult.status, 0, 'expected missing input to fail'); -const missingOutput = `${missingResult.stderr || ''}${missingResult.stdout || ''}`; -assert.equal( - missingOutput.includes("Unhandled 'error' event"), - false, - 'expected missing input failure to avoid unhandled stream error' -); - -console.log('lsif ingest test passed'); - diff --git a/tests/tooling/ingest/missing-input-helper.js b/tests/tooling/ingest/missing-input-helper.js new file mode 100644 index 000000000..c5b1d3bc5 --- /dev/null +++ b/tests/tooling/ingest/missing-input-helper.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { runNode } from '../../helpers/run-node.js'; + +export const assertMissingIngestInputFailsCleanly = ({ + cliPath, + kind, + repoRoot, + missingInputPath, + outPath +}) => { + const result = runNode( + [cliPath, 'ingest', kind, '--repo', repoRoot, '--input', missingInputPath, '--out', outPath, '--json'], + `missing ${kind} ingest input`, + process.cwd(), + process.env, + { stdio: 'pipe', allowFailure: true } + ); + assert.notEqual(result.status, 0, 'expected missing input to fail'); + const output = `${result.stderr || ''}${result.stdout || ''}`; + assert.equal( + output.includes("Unhandled 'error' event"), + false, + 'expected missing input failure to avoid unhandled stream error' + ); + return result; +}; diff --git a/tests/tooling/ingest/normalize-path.test.js b/tests/tooling/ingest/normalize-path.test.js new file mode 100644 index 000000000..e36778527 --- /dev/null +++ b/tests/tooling/ingest/normalize-path.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { normalizePathForPlatform } from '../../../src/shared/path-normalize.js'; +import { normalizeRepoRelativePath } from '../../../tools/ingest/shared.js'; + +const repoRoot = path.join(process.cwd(), 'tests', 'fixtures', 'sample'); +const nestedPath = path.join(repoRoot, 'src', 'sample.ts'); +const outsidePath = path.join(process.cwd(), 'outside.ts'); + +assert.equal(normalizeRepoRelativePath(repoRoot, 'src/sample.ts'), 'src/sample.ts'); +assert.equal(normalizeRepoRelativePath(repoRoot, './src/sample.ts'), 'src/sample.ts'); +assert.equal(normalizeRepoRelativePath(repoRoot, nestedPath), 'src/sample.ts'); +assert.equal(normalizeRepoRelativePath(repoRoot, '../outside.ts'), null); +assert.equal(normalizeRepoRelativePath(repoRoot, outsidePath), null); +assert.equal(normalizeRepoRelativePath(repoRoot, repoRoot), null); + +assert.equal( + normalizeRepoRelativePath(repoRoot, '/repo/src/sample.ts', { stripVirtualRepoRoot: true }), + 'src/sample.ts' +); +assert.equal( + normalizeRepoRelativePath(repoRoot, '/repo/..config/settings.json', { stripVirtualRepoRoot: true }), + '..config/settings.json' +); +assert.equal( + normalizeRepoRelativePath(repoRoot, '/repo/../outside.ts', { stripVirtualRepoRoot: true }), + null +); +assert.equal( + normalizeRepoRelativePath(repoRoot, '/repo', { stripVirtualRepoRoot: true }), + '' +); + +const winRepoRoot = normalizePathForPlatform('C:/repo-root', { platform: 'win32' }); +assert.equal( + normalizeRepoRelativePath(winRepoRoot, '/C:/repo-root/src/sample.ts', { stripVirtualRepoRoot: true }), + 'src/sample.ts' +); + +console.log('ingest path normalization test passed'); diff --git a/tests/tooling/ingest/scip/ingest.test.js b/tests/tooling/ingest/scip/ingest.test.js new file mode 100644 index 000000000..e15470af8 --- /dev/null +++ b/tests/tooling/ingest/scip/ingest.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; +import { runNode } from '../../../helpers/run-node.js'; +import { assertMissingIngestInputFailsCleanly } from '../missing-input-helper.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'scip-ingest'); +const cliPath = path.join(root, 'bin', 'pairofcleats.js'); +const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); +const inputPath = path.join(root, 'tests', 'fixtures', 'scip', 'index.json'); +const outPath = path.join(tempRoot, 'scip.jsonl'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + + +const result = runNode( + [cliPath, 'ingest', 'scip', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], + 'scip ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'scip-ingest failed'); + process.exit(result.status ?? 1); +} + +if (!fs.existsSync(outPath)) { + console.error('scip output not found'); + process.exit(1); +} + +const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); +assert.ok(lines.length >= 2, 'expected scip output lines'); + +const first = JSON.parse(lines[0]); +assert.equal(first.file, 'src/example.js'); +assert.equal(first.name, 'doThing'); +assert.equal(first.role, 'definition'); +assert.equal(first.startLine, 2); + +const metaPath = `${outPath}.meta.json`; +const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); +assert.equal(meta.stats.occurrences, lines.length); +assert.equal(meta.stats.definitions, 1); +assert.equal(meta.stats.references, 1); + +const escapeInputPath = path.join(tempRoot, 'escape-index.json'); +const escapeOutPath = path.join(tempRoot, 'escape-scip.jsonl'); +const outsidePath = path.join(root, 'outside.js'); +await fsPromises.writeFile(escapeInputPath, JSON.stringify({ + documents: [ + { + relativePath: 'src/kept.js', + language: 'javascript', + occurrences: [{ symbol: 'kept', range: [0, 0, 1], symbolRoles: 1 }] + }, + { + relativePath: '../outside.js', + language: 'javascript', + occurrences: [{ symbol: 'escaped', range: [0, 0, 1], symbolRoles: 1 }] + }, + { + path: outsidePath, + language: 'javascript', + occurrences: [{ symbol: 'abs', range: [0, 0, 1], symbolRoles: 1 }] + } + ] +}, null, 2)); +const escapeResult = runNode( + [cliPath, 'ingest', 'scip', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], + 'scip escape ingest', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (escapeResult.status !== 0) { + console.error(escapeResult.stderr || escapeResult.stdout || 'scip escape ingest failed'); + process.exit(escapeResult.status ?? 1); +} +const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +assert.equal(escapedLines.length, 1, 'expected out-of-repo scip paths to be dropped'); +assert.equal(escapedLines[0].file, 'src/kept.js'); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); +assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); +assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); + +const missingInputPath = path.join(tempRoot, 'missing-scip.json'); +assertMissingIngestInputFailsCleanly({ + cliPath, + kind: 'scip', + repoRoot, + missingInputPath, + outPath: path.join(tempRoot, 'missing.jsonl') +}); + +console.log('scip ingest test passed'); + diff --git a/tests/tooling/ingest/scip/scip-ingest.test.js b/tests/tooling/ingest/scip/scip-ingest.test.js deleted file mode 100644 index 3731c0c27..000000000 --- a/tests/tooling/ingest/scip/scip-ingest.test.js +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'scip-ingest'); -const cliPath = path.join(root, 'bin', 'pairofcleats.js'); -const repoRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const inputPath = path.join(root, 'tests', 'fixtures', 'scip', 'index.json'); -const outPath = path.join(tempRoot, 'scip.jsonl'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); - - -const result = spawnSync( - process.execPath, - [cliPath, 'ingest', 'scip', '--repo', repoRoot, '--input', inputPath, '--out', outPath, '--json'], - { encoding: 'utf8' } -); -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'scip-ingest failed'); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('scip output not found'); - process.exit(1); -} - -const lines = fs.readFileSync(outPath, 'utf8').trim().split(/\r?\n/).filter(Boolean); -assert.ok(lines.length >= 2, 'expected scip output lines'); - -const first = JSON.parse(lines[0]); -assert.equal(first.file, 'src/example.js'); -assert.equal(first.name, 'doThing'); -assert.equal(first.role, 'definition'); -assert.equal(first.startLine, 2); - -const metaPath = `${outPath}.meta.json`; -const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); -assert.equal(meta.stats.occurrences, lines.length); -assert.equal(meta.stats.definitions, 1); -assert.equal(meta.stats.references, 1); - -const escapeInputPath = path.join(tempRoot, 'escape-index.json'); -const escapeOutPath = path.join(tempRoot, 'escape-scip.jsonl'); -const outsidePath = path.join(root, 'outside.js'); -await fsPromises.writeFile(escapeInputPath, JSON.stringify({ - documents: [ - { - relativePath: 'src/kept.js', - language: 'javascript', - occurrences: [{ symbol: 'kept', range: [0, 0, 1], symbolRoles: 1 }] - }, - { - relativePath: '../outside.js', - language: 'javascript', - occurrences: [{ symbol: 'escaped', range: [0, 0, 1], symbolRoles: 1 }] - }, - { - path: outsidePath, - language: 'javascript', - occurrences: [{ symbol: 'abs', range: [0, 0, 1], symbolRoles: 1 }] - } - ] -}, null, 2)); -const escapeResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'scip', '--repo', repoRoot, '--input', escapeInputPath, '--out', escapeOutPath, '--json'], - { encoding: 'utf8' } -); -if (escapeResult.status !== 0) { - console.error(escapeResult.stderr || escapeResult.stdout || 'scip escape ingest failed'); - process.exit(escapeResult.status ?? 1); -} -const escapedLines = fs.readFileSync(escapeOutPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -assert.equal(escapedLines.length, 1, 'expected out-of-repo scip paths to be dropped'); -assert.equal(escapedLines[0].file, 'src/kept.js'); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('..'))); -assert.ok(escapedLines.every((entry) => !/^[A-Za-z]:\//.test(entry.file))); -assert.ok(escapedLines.every((entry) => !entry.file.startsWith('/'))); - -const missingInputPath = path.join(tempRoot, 'missing-scip.json'); -const missingResult = spawnSync( - process.execPath, - [cliPath, 'ingest', 'scip', '--repo', repoRoot, '--input', missingInputPath, '--out', path.join(tempRoot, 'missing.jsonl'), '--json'], - { encoding: 'utf8' } -); -assert.notEqual(missingResult.status, 0, 'expected missing input to fail'); -const missingOutput = `${missingResult.stderr || ''}${missingResult.stdout || ''}`; -assert.equal( - missingOutput.includes("Unhandled 'error' event"), - false, - 'expected missing input failure to avoid unhandled stream error' -); - -console.log('scip ingest test passed'); - diff --git a/tests/tooling/ingest/shared-runner-timeout-opt-in.test.js b/tests/tooling/ingest/shared-runner-timeout-opt-in.test.js new file mode 100644 index 000000000..873dba2b0 --- /dev/null +++ b/tests/tooling/ingest/shared-runner-timeout-opt-in.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runLineStreamingCommand } from '../../../tools/ingest/shared-runner.js'; + +ensureTestingEnv(process.env); + +const longRunningArgs = [ + '-e', + [ + 'let ticks = 0;', + 'const timer = setInterval(() => {', + ' ticks += 1;', + ' console.log(`tick-${ticks}`);', + ' if (ticks >= 3) {', + ' clearInterval(timer);', + ' process.exit(0);', + ' }', + '}, 450);' + ].join('\n') +]; + +const observedLines = []; +const startedAt = Date.now(); +await runLineStreamingCommand({ + command: process.execPath, + args: longRunningArgs, + timeoutMs: null, + onStdoutLine: async (line) => { + observedLines.push(String(line || '')); + } +}); +const elapsedMs = Date.now() - startedAt; +assert.equal(observedLines.length, 3, 'expected full stdout stream from long-running command'); +assert.equal(elapsedMs >= 1200, true, `expected no-timeout run to complete naturally (elapsed=${elapsedMs}ms)`); + +await assert.rejects( + () => runLineStreamingCommand({ + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000);'], + timeoutMs: 100 + }), + (error) => error?.code === 'ERR_INGEST_COMMAND_TIMEOUT' && Number(error?.timeoutMs) === 1000, + 'expected explicit timeout to terminate command via ERR_INGEST_COMMAND_TIMEOUT' +); + +console.log('ingest shared-runner timeout opt-in test passed'); diff --git a/tests/tooling/install/bootstrap-json-buffer-cap.test.js b/tests/tooling/install/bootstrap-json-buffer-cap.test.js index da494c5e2..7d20067c8 100644 --- a/tests/tooling/install/bootstrap-json-buffer-cap.test.js +++ b/tests/tooling/install/bootstrap-json-buffer-cap.test.js @@ -9,18 +9,13 @@ const source = fs.readFileSync(sourcePath, 'utf8'); assert.match( source, - /const\s+isWindowsNpm\s*=\s*process\.platform\s*===\s*'win32'/, - 'expected bootstrap JSON-mode npm path to detect Windows npm shim usage' + /import\s*\{\s*spawnResolvedSubprocess\s*\}\s*from\s*'..\/..\/src\/shared\/subprocess\/command-invocation\.js'/, + 'expected bootstrap JSON-mode child execution to use shared resolved subprocess helper' ); assert.match( source, - /const\s+commandArgs\s*=\s*isWindowsNpm\s*\?\s*\['\/d',\s*'\/s',\s*'\/c',\s*'npm',\s*\.\.\.args\]\s*:\s*args;/, - 'expected bootstrap JSON-mode npm path to run through cmd.exe shim args' -); -assert.match( - source, - /await\s+spawnSubprocess\(command,\s*commandArgs,/, - 'expected bootstrap JSON-mode child execution to stream via spawnSubprocess' + /await\s+spawnResolvedSubprocess\(cmd,\s*args,\s*\{/, + 'expected bootstrap JSON-mode child execution to stream via shared resolved subprocess wrapper' ); assert.doesNotMatch( source, diff --git a/tests/tooling/install/bootstrap-json-output.test.js b/tests/tooling/install/bootstrap-json-output.test.js index 4a852c7d5..f7ae55b15 100644 --- a/tests/tooling/install/bootstrap-json-output.test.js +++ b/tests/tooling/install/bootstrap-json-output.test.js @@ -1,9 +1,10 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); @@ -41,8 +42,15 @@ if (process.platform === 'win32') { await fsPromises.chmod(npmPath, 0o755); } -const result = spawnSync( - process.execPath, +const env = applyTestEnv({ + syncProcess: false, + cacheRoot, + extraEnv: { + PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}` + } +}); + +const result = runNode( [ path.join(root, 'tools', 'setup', 'bootstrap.js'), '--repo', @@ -53,29 +61,15 @@ const result = spawnSync( '--skip-artifacts', '--json' ], + 'bootstrap json output', + fixtureRoot, + env, { - cwd: fixtureRoot, - encoding: 'utf8', - maxBuffer: 8 * 1024 * 1024, - env: { - ...process.env, - PAIROFCLEATS_CACHE_ROOT: cacheRoot, - PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}` - } + stdio: 'pipe', + spawnOptions: { maxBuffer: 8 * 1024 * 1024 } } ); -if (result.status !== 0) { - console.error('bootstrap json-output test failed: bootstrap exited non-zero'); - if (result.error) console.error(result.error.message || String(result.error)); - if (result.stderr) { - const stderr = String(result.stderr); - const tail = stderr.slice(Math.max(0, stderr.length - 4000)); - console.error(tail.trim()); - } - process.exit(result.status ?? 1); -} - let payload; try { payload = JSON.parse(result.stdout || '{}'); diff --git a/tests/tooling/install/detect-and-plan-contract-matrix.test.js b/tests/tooling/install/detect-and-plan-contract-matrix.test.js new file mode 100644 index 000000000..ffde7d8de --- /dev/null +++ b/tests/tooling/install/detect-and-plan-contract-matrix.test.js @@ -0,0 +1,373 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path, { isAbsolute } from 'node:path'; + +import { detectTool, getToolingRegistry } from '../../../tools/tooling/utils.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); +const tempRoot = resolveTestCachePath(root, `tooling-install-detect-plan-matrix-${process.pid}-${Date.now()}`); + +const runCliJson = ({ scriptPath, args, env = process.env, cwd = root, label }) => { + const result = runNode([scriptPath, ...args], label, cwd, env, { stdio: 'pipe', allowFailure: true }); + assert.equal(result.status, 0, `${label} exited non-zero\n${result.stderr || result.stdout}`); + try { + return JSON.parse(String(result.stdout || '{}')); + } catch (error) { + assert.fail(`${label} did not return valid JSON: ${error.message}`); + } +}; + +const makeScript = async (targetPath, body, helperBody = '#!/usr/bin/env node\nprocess.exit(0);\n') => { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, body, 'utf8'); + if (process.platform !== 'win32') { + await fs.chmod(targetPath, 0o755); + return; + } + await fs.writeFile(path.join(path.dirname(targetPath), 'ok.js'), helperBody, 'utf8'); +}; + +const runDetectCases = () => { + const payload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'detect.js'), + args: ['--root', fixtureRoot, '--json'], + label: 'tooling-detect baseline' + }); + + const languages = payload.languages || {}; + for (const lang of ['python', 'rust', 'go', 'java', 'cpp', 'objc', 'swift']) { + assert.ok(languages[lang], `expected detected language ${lang}`); + } + + const toolIds = new Set((payload.tools || []).map((tool) => tool.id)); + for (const toolId of ['clangd', 'gopls', 'rust-analyzer', 'jdtls', 'sourcekit-lsp']) { + assert.ok(toolIds.has(toolId), `expected detected tool ${toolId}`); + } + for (const tool of payload.tools || []) { + assert.ok(tool?.probe && typeof tool.probe === 'object', `expected probe payload for ${tool?.id || 'unknown'}`); + assert.ok(String(tool.probe.outcome || ''), `expected probe outcome for ${tool?.id || 'unknown'}`); + } + + const genericPayload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'detect.js'), + args: ['--root', fixtureRoot, '--languages', 'go,rust,yaml,lua,zig', '--json'], + label: 'tooling-detect generic lsp' + }); + const genericIds = new Set((genericPayload.tools || []).map((tool) => tool.id)); + for (const toolId of ['gopls', 'rust-analyzer', 'yaml-language-server', 'lua-language-server', 'zls']) { + assert.ok(genericIds.has(toolId), `expected generic tool ${toolId}`); + } + + const dedicatedPayload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'detect.js'), + args: ['--root', fixtureRoot, '--languages', 'java,csharp,ruby,elixir,haskell,php,dart', '--json'], + label: 'tooling-detect dedicated lsp' + }); + const dedicatedIds = new Set((dedicatedPayload.tools || []).map((tool) => tool.id)); + for (const toolId of ['jdtls', 'csharp-ls', 'solargraph', 'elixir-ls', 'haskell-language-server', 'phpactor', 'dart']) { + assert.ok(dedicatedIds.has(toolId), `expected dedicated tool ${toolId}`); + } +}; + +const runGlobalFallbackCase = async () => { + const caseRoot = path.join(tempRoot, 'global-fallbacks'); + const homeDir = path.join(caseRoot, 'home'); + const appDataDir = path.join(caseRoot, 'appdata'); + const localAppDataDir = path.join(caseRoot, 'localappdata'); + const dotnetGlobalBin = path.join(homeDir, '.dotnet', 'tools'); + const phpactorGlobalBin = path.join(localAppDataDir, 'Programs', 'phpactor'); + const gemGlobalBin = path.join(homeDir, '.local', 'share', 'gem', 'ruby', '3.4.0', 'bin'); + + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(localAppDataDir, 'Microsoft', 'WindowsApps'), { recursive: true }); + + if (process.platform === 'win32') { + await fs.mkdir(dotnetGlobalBin, { recursive: true }); + await fs.mkdir(phpactorGlobalBin, { recursive: true }); + await fs.mkdir(gemGlobalBin, { recursive: true }); + await fs.writeFile(path.join(dotnetGlobalBin, 'csharp-ls.cmd'), '@echo off\r\nif "%1"=="--version" exit /b 1\r\nif "%1"=="--help" exit /b 0\r\nexit /b 0\r\n', 'utf8'); + await fs.writeFile(path.join(phpactorGlobalBin, 'phpactor.cmd'), '@echo off\r\nif "%1"=="--version" exit /b 0\r\nif "%1"=="--help" exit /b 0\r\nexit /b 0\r\n', 'utf8'); + await fs.writeFile(path.join(gemGlobalBin, 'solargraph.cmd'), '@echo off\r\nif "%1"=="--version" exit /b 0\r\nif "%1"=="--help" exit /b 0\r\nexit /b 0\r\n', 'utf8'); + } else { + await makeScript(path.join(dotnetGlobalBin, 'csharp-ls'), '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 1; fi\nif [ "$1" = "--help" ]; then exit 0; fi\nexit 0\n'); + await makeScript(path.join(phpactorGlobalBin, 'phpactor'), '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 0; fi\nif [ "$1" = "--help" ]; then exit 0; fi\nexit 0\n'); + await makeScript(path.join(gemGlobalBin, 'solargraph'), '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 0; fi\nif [ "$1" = "--help" ]; then exit 0; fi\nexit 0\n'); + } + + const baselinePath = path.dirname(process.execPath); + const payload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'detect.js'), + args: ['--root', fixtureRoot, '--languages', 'csharp,ruby,php', '--json'], + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: appDataDir, + LOCALAPPDATA: localAppDataDir, + PATH: baselinePath, + Path: baselinePath + }, + label: 'tooling-detect global bin fallbacks' + }); + + const byId = new Map((payload.tools || []).map((entry) => [entry?.id, entry])); + for (const toolId of ['csharp-ls', 'solargraph', 'phpactor']) { + assert.equal(byId.get(toolId)?.found, true, `expected ${toolId} to be detected from global fallback`); + } +}; + +const runRequirementCases = async () => { + const goBinDir = path.join(tempRoot, 'go-requirement', 'bin'); + await fs.rm(path.dirname(goBinDir), { recursive: true, force: true }); + await fs.mkdir(goBinDir, { recursive: true }); + if (process.platform === 'win32') { + await fs.writeFile(path.join(goBinDir, 'go.cmd'), '@echo off\r\nnode "%~dp0\\ok.js" %*\r\n', 'utf8'); + await fs.writeFile(path.join(goBinDir, 'ok.js'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8'); + } else { + await makeScript(path.join(goBinDir, 'go'), '#!/bin/sh\nif [ "$1" = "version" ]; then exit 0; fi\nif [ "$1" = "install" ]; then exit 0; fi\nexit 1\n'); + } + const goPayload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'install.js'), + args: ['--root', fixtureRoot, '--tools', 'gopls', '--dry-run', '--json'], + env: { ...process.env, PATH: goBinDir, Path: goBinDir }, + label: 'tooling-install gopls requirement probe' + }); + const goplsResult = (goPayload.results || []).find((entry) => entry?.id === 'gopls'); + const goplsAction = (goPayload.actions || []).find((entry) => entry?.id === 'gopls'); + assert.notEqual(goplsResult?.status, 'missing-requirement'); + assert.ok(goplsAction, 'expected gopls install action'); + assert.ok(isAbsolute(String(goplsAction?.env?.GOBIN || '')), 'expected absolute GOBIN'); + + const dotnetBinDir = path.join(tempRoot, 'dotnet-requirement', 'bin'); + await fs.rm(path.dirname(dotnetBinDir), { recursive: true, force: true }); + await fs.mkdir(dotnetBinDir, { recursive: true }); + if (process.platform === 'win32') { + await fs.writeFile(path.join(dotnetBinDir, 'dotnet.cmd'), '@echo off\r\nif "%1"=="--info" exit /b 0\r\nif "%1"=="--version" exit /b 1\r\nif "%1"=="tool" exit /b 0\r\nexit /b 1\r\n', 'utf8'); + } else { + await makeScript(path.join(dotnetBinDir, 'dotnet'), '#!/bin/sh\nif [ "$1" = "--info" ]; then exit 0; fi\nif [ "$1" = "--version" ]; then exit 1; fi\nif [ "$1" = "tool" ]; then exit 0; fi\nexit 1\n'); + } + const dotnetPayload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'install.js'), + args: ['--root', fixtureRoot, '--tools', 'csharp-ls', '--dry-run', '--json'], + env: { ...process.env, PATH: dotnetBinDir, Path: dotnetBinDir }, + label: 'tooling-install dotnet requirement probe' + }); + const csharpResult = (dotnetPayload.results || []).find((entry) => entry?.id === 'csharp-ls'); + const csharpAction = (dotnetPayload.actions || []).find((entry) => entry?.id === 'csharp-ls'); + assert.notEqual(csharpResult?.status, 'missing-requirement'); + if (csharpResult?.status !== 'already-installed') { + assert.ok(csharpAction, 'expected csharp-ls install action'); + } +}; + +const runPlanCase = () => { + const payload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'install.js'), + args: ['--root', fixtureRoot, '--tools', 'yaml-language-server,lua-language-server,zls', '--dry-run', '--json'], + label: 'tooling-install generic lsp plans' + }); + + const actions = Array.isArray(payload.actions) ? payload.actions : []; + const results = Array.isArray(payload.results) ? payload.results : []; + const yamlAction = actions.find((entry) => entry?.id === 'yaml-language-server'); + const yamlResult = results.find((entry) => entry?.id === 'yaml-language-server'); + assert.ok(yamlAction || yamlResult, 'expected yaml-language-server action or result'); + if (yamlAction) { + assert.equal(yamlAction.cmd, 'npm'); + } + if (yamlResult) { + assert.notEqual(yamlResult.status, 'manual'); + } + + const luaAction = actions.find((entry) => entry?.id === 'lua-language-server'); + const luaResult = results.find((entry) => entry?.id === 'lua-language-server'); + assert.ok(luaAction || luaResult, 'expected lua-language-server action or result'); + if (luaAction) { + assert.equal(String(luaAction.cmd || ''), process.execPath); + assert.equal( + Array.isArray(luaAction.args) && luaAction.args.some((entry) => String(entry).includes('install-lua-language-server.js')), + true, + 'expected lua-language-server plan to invoke the managed installer' + ); + } + if (luaResult) { + assert.notEqual(luaResult.status, 'manual'); + } + + const zlsAction = actions.find((entry) => entry?.id === 'zls'); + const zlsResult = results.find((entry) => entry?.id === 'zls'); + assert.equal(zlsAction, undefined, 'zls should not emit an auto-install action'); + assert.ok(zlsResult && ['manual', 'already-installed'].includes(zlsResult.status), 'expected zls manual/already-installed result'); +}; + +const runRegistryContractCases = () => { + const registry = getToolingRegistry(path.join(tempRoot, 'registry-cache-root'), root); + + const pyright = registry.find((tool) => tool?.id === 'pyright'); + assert.ok(pyright, 'expected pyright entry in tooling registry'); + assert.equal(pyright.detect?.cmd, 'pyright-langserver', 'pyright tooling detection must use pyright-langserver'); + assert.ok( + Array.isArray(pyright.detect?.args) && pyright.detect.args.includes('--help'), + 'pyright-langserver detection should include --help probe' + ); + + for (const toolId of ['gopls', 'sqls']) { + const tool = registry.find((entry) => entry?.id === toolId); + assert.ok(tool, `expected ${toolId} entry in tooling registry`); + assert.ok(isAbsolute(String(tool.install?.cache?.env?.GOBIN || '')), `${toolId} cache install must use an absolute GOBIN`); + } + + for (const toolId of ['omnisharp', 'csharp-ls']) { + const tool = registry.find((entry) => entry?.id === toolId); + assert.ok(tool, `expected ${toolId} entry in tooling registry`); + const args = Array.isArray(tool.install?.cache?.args) ? tool.install.cache.args : []; + const toolPathIndex = args.indexOf('--tool-path'); + assert.notEqual(toolPathIndex, -1, `${toolId} cache install must include --tool-path`); + assert.ok(isAbsolute(String(args[toolPathIndex + 1] || '')), `${toolId} cache install must use an absolute --tool-path`); + } + + for (const toolId of ['ruby-lsp', 'solargraph']) { + const tool = registry.find((entry) => entry?.id === toolId); + assert.ok(tool, `expected ${toolId} entry in tooling registry`); + const args = Array.isArray(tool.install?.cache?.args) ? tool.install.cache.args : []; + const installIndex = args.indexOf('-i'); + const binIndex = args.indexOf('-n'); + assert.notEqual(installIndex, -1, `${toolId} cache install must include -i`); + assert.notEqual(binIndex, -1, `${toolId} cache install must include -n`); + assert.ok(isAbsolute(String(args[installIndex + 1] || '')), `${toolId} cache install must use an absolute gem install dir`); + assert.ok(isAbsolute(String(args[binIndex + 1] || '')), `${toolId} cache install must use an absolute gem bin dir`); + } + + const phpactor = registry.find((entry) => entry?.id === 'phpactor'); + assert.ok(phpactor, 'expected phpactor entry in tooling registry'); + const phpactorArgs = Array.isArray(phpactor.install?.cache?.args) ? phpactor.install.cache.args : []; + const toolingRootIndex = phpactorArgs.indexOf('--tooling-root'); + assert.notEqual(toolingRootIndex, -1, 'phpactor cache install must include --tooling-root'); + assert.ok(isAbsolute(String(phpactorArgs[toolingRootIndex + 1] || '')), 'phpactor cache install must use an absolute tooling root'); + + const luaLanguageServer = registry.find((entry) => entry?.id === 'lua-language-server'); + assert.ok(luaLanguageServer, 'expected lua-language-server entry in tooling registry'); + const luaArgs = Array.isArray(luaLanguageServer.install?.cache?.args) ? luaLanguageServer.install.cache.args : []; + const luaToolingRootIndex = luaArgs.indexOf('--tooling-root'); + assert.notEqual(luaToolingRootIndex, -1, 'lua-language-server cache install must include --tooling-root'); + assert.ok(isAbsolute(String(luaArgs[luaToolingRootIndex + 1] || '')), 'lua-language-server cache install must use an absolute tooling root'); +}; + +const runPhpactorPlanCase = () => { + const payload = runCliJson({ + scriptPath: path.join(root, 'tools', 'tooling', 'install.js'), + args: ['--root', fixtureRoot, '--tools', 'phpactor', '--dry-run', '--json'], + label: 'tooling-install phpactor phar plan' + }); + + const phpactorResult = Array.isArray(payload?.results) + ? payload.results.find((entry) => entry?.id === 'phpactor') + : null; + if (['already-installed', 'missing-requirement', 'manual'].includes(String(phpactorResult?.status || ''))) { + return; + } + + const phpactorAction = Array.isArray(payload?.actions) + ? payload.actions.find((entry) => entry?.id === 'phpactor') + : null; + assert.ok(phpactorAction, 'expected phpactor install action'); + assert.equal(phpactorAction.cmd, process.execPath, 'expected phpactor plan to execute via node'); + const args = Array.isArray(phpactorAction.args) ? phpactorAction.args.map((value) => String(value)) : []; + assert.equal( + args.some((value) => value.endsWith(path.join('tools', 'tooling', 'install-phpactor-phar.js'))), + true, + 'expected phpactor phar installer script' + ); + assert.equal(args.includes('--scope') && args.includes('cache'), true, 'expected cache scope install args'); + const toolingRootIndex = args.indexOf('--tooling-root'); + assert.equal( + toolingRootIndex !== -1 && isAbsolute(String(args[toolingRootIndex + 1] || '')), + true, + 'expected absolute tooling root' + ); +}; + +const runPyrightPathFallbackCase = async () => { + const caseRoot = path.join(tempRoot, 'pyright-path-fallback'); + const binDir = path.join(caseRoot, 'bin'); + const toolingRoot = path.join(caseRoot, 'tooling-root'); + + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(toolingRoot, { recursive: true }); + + if (process.platform === 'win32') { + await fs.writeFile(path.join(binDir, 'pyright-langserver.cmd'), '@echo off\r\nif "%1"=="--help" exit /b 0\r\nexit /b 0\r\n', 'utf8'); + } else { + await makeScript(path.join(binDir, 'pyright-langserver'), '#!/bin/sh\nexit 0\n'); + } + + const registry = getToolingRegistry(toolingRoot, root); + const pyright = registry.find((entry) => entry?.id === 'pyright'); + assert.ok(pyright, 'expected pyright registry entry'); + const pyrightPathOnly = { + ...pyright, + detect: { + ...(pyright.detect || {}), + binDirs: [] + } + }; + + await withTemporaryEnv({ PATH: '', Path: binDir }, async () => { + const status = detectTool(pyrightPathOnly); + assert.equal(status?.found, true); + assert.equal(status?.source, 'path'); + assert.ok(String(status?.path || '').toLowerCase().includes('pyright-langserver')); + }); +}; + +const runLuaBrokenManagedLayoutCase = async () => { + const caseRoot = path.join(tempRoot, 'lua-broken-managed-layout'); + const toolingRoot = path.join(caseRoot, 'tooling-root'); + const binDir = path.join(toolingRoot, 'bin'); + + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(binDir, { recursive: true }); + + if (process.platform === 'win32') { + await fs.writeFile( + path.join(binDir, 'lua-language-server.cmd'), + '@echo off\r\nif "%1"=="-v" exit /b 0\r\nexit /b 0\r\n', + 'utf8' + ); + } else { + await makeScript(path.join(binDir, 'lua-language-server'), '#!/bin/sh\nexit 0\n'); + } + + const registry = getToolingRegistry(toolingRoot, root); + const luaLanguageServer = registry.find((entry) => entry?.id === 'lua-language-server'); + assert.ok(luaLanguageServer, 'expected lua-language-server registry entry'); + + await withTemporaryEnv({ PATH: path.dirname(process.execPath), Path: path.dirname(process.execPath) }, async () => { + const status = detectTool(luaLanguageServer); + assert.equal(status?.found, false, 'expected broken managed Lua layout to be rejected during detection'); + assert.equal(status?.probe?.validationFailure?.reasonCode, 'broken-layout'); + }); +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +try { + runDetectCases(); + await runGlobalFallbackCase(); + await runRequirementCases(); + runPlanCase(); + runRegistryContractCases(); + runPhpactorPlanCase(); + await runPyrightPathFallbackCase(); + await runLuaBrokenManagedLayoutCase(); + console.log('tooling install detect/plan contract matrix test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/install/download-dicts-partial-failure-exit-code.test.js b/tests/tooling/install/download-dicts-partial-failure-exit-code.test.js index 713512432..cc9cc60d5 100644 --- a/tests/tooling/install/download-dicts-partial-failure-exit-code.test.js +++ b/tests/tooling/install/download-dicts-partial-failure-exit-code.test.js @@ -1,84 +1,27 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import crypto from 'node:crypto'; -import http from 'node:http'; import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { attachSilentLogging } from '../../helpers/test-env.js'; +import * as helper from './download-dicts-test-helper.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixturesRoot = path.join(root, 'tests', 'fixtures', 'dicts'); -const tempRoot = resolveTestCachePath(root, 'download-dicts-partial-failure'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const sourceFile = path.join(fixturesRoot, 'words.txt'); -if (!fs.existsSync(sourceFile)) { - console.error(`Missing fixture: ${sourceFile}`); - process.exit(1); -} -const sourceHash = crypto.createHash('sha256') - .update(await fsPromises.readFile(sourceFile)) - .digest('hex'); - -const server = http.createServer((req, res) => { - if (req.url === '/words.txt') { - res.statusCode = 200; - fs.createReadStream(sourceFile).pipe(res); - return; - } - res.statusCode = 404; - res.end('not found'); -}); - -await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); -const address = server.address(); -const port = typeof address === 'object' && address ? address.port : 0; -const baseUrl = `http://127.0.0.1:${port}`; - -const run = (cmd, args, options = {}) => new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe'], - env: options.env || process.env - }); - attachSilentLogging(child, 'download-dicts-partial-failure'); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on('data', (chunk) => { - stderr += chunk.toString(); - }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); -}); - -const result = await run( - process.execPath, - [ - path.join(root, 'tools', 'download', 'dicts.js'), +const { tempRoot, sourceFile, sourceHash } = await helper.setupDownloadDictsTest('download-dicts-partial-failure'); +const server = await helper.startWordsServer(sourceFile); +let result; +try { + result = await helper.runDownloadDicts([ '--url', - `ok=${baseUrl}/words.txt`, + `ok=${server.url}`, '--sha256', `ok=${sourceHash}`, '--url', - `bad=${baseUrl}/missing.txt`, + `bad=${server.baseUrl}/missing.txt`, '--lang', 'test', '--dir', tempRoot, '--force' - ], - { cwd: root } -); - -server.close(); + ], { logName: 'download-dicts-partial-failure' }); +} finally { + await server.close(); +} if (result.code === 0) { console.error('download-dicts partial failure test failed: expected non-zero exit code.'); @@ -87,22 +30,13 @@ if (result.code === 0) { } const okPath = path.join(tempRoot, 'ok.txt'); -if (!fs.existsSync(okPath)) { - console.error(`download-dicts partial failure test failed: missing ${okPath}`); - process.exit(1); -} -const okContents = await fsPromises.readFile(okPath, 'utf8'); -if (!okContents.includes('alpha')) { - console.error('download-dicts partial failure test failed: downloaded content mismatch.'); - process.exit(1); -} +await helper.assertFileIncludes(okPath, 'alpha', { + missingMessage: `download-dicts partial failure test failed: missing ${okPath}`, + mismatchMessage: 'download-dicts partial failure test failed: downloaded content mismatch.' +}); const manifestPath = path.join(tempRoot, 'dictionaries.json'); -if (!fs.existsSync(manifestPath)) { - console.error('download-dicts partial failure test failed: manifest missing.'); - process.exit(1); -} -const manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); +const manifest = await helper.readRequiredJson(manifestPath, 'download-dicts partial failure test failed: manifest missing.'); if (!manifest.ok || manifest.bad) { console.error('download-dicts partial failure test failed: unexpected manifest entries.'); process.exit(1); diff --git a/tests/tooling/install/download-dicts-test-helper.js b/tests/tooling/install/download-dicts-test-helper.js new file mode 100644 index 000000000..fa38d4b4f --- /dev/null +++ b/tests/tooling/install/download-dicts-test-helper.js @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import http from 'node:http'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { attachSilentLogging } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const fixturesRoot = path.join(root, 'tests', 'fixtures', 'dicts'); + +export async function setupDownloadDictsTest(cacheName) { + const tempRoot = resolveTestCachePath(root, cacheName); + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.mkdir(tempRoot, { recursive: true }); + + const sourceFile = path.join(fixturesRoot, 'words.txt'); + if (!fs.existsSync(sourceFile)) { + console.error(`Missing fixture: ${sourceFile}`); + process.exit(1); + } + + const sourceHash = crypto.createHash('sha256') + .update(await fsPromises.readFile(sourceFile)) + .digest('hex'); + + return { tempRoot, sourceFile, sourceHash }; +} + +export async function startWordsServer(sourceFile) { + const server = http.createServer((req, res) => { + if (req.url !== '/words.txt' || !fs.existsSync(sourceFile)) { + res.statusCode = 404; + res.end('not found'); + return; + } + res.statusCode = 200; + fs.createReadStream(sourceFile).pipe(res); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const baseUrl = `http://127.0.0.1:${port}`; + + return { + baseUrl, + url: `${baseUrl}/words.txt`, + close: () => new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }) + }; +} + +export function runDownloadDicts(args, { logName }) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(root, 'tools', 'download', 'dicts.js'), ...args], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env + }); + attachSilentLogging(child, logName); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +export async function assertFileIncludes(filePath, expected, { missingMessage, mismatchMessage }) { + if (!fs.existsSync(filePath)) { + console.error(missingMessage); + process.exit(1); + } + const contents = await fsPromises.readFile(filePath, 'utf8'); + if (!contents.includes(expected)) { + console.error(mismatchMessage); + process.exit(1); + } +} + +export async function readRequiredJson(filePath, missingMessage) { + if (!fs.existsSync(filePath)) { + console.error(missingMessage); + process.exit(1); + } + return JSON.parse(await fsPromises.readFile(filePath, 'utf8')); +} diff --git a/tests/tooling/install/download-dicts.test.js b/tests/tooling/install/download-dicts.test.js index ea145cf24..64fcdb222 100644 --- a/tests/tooling/install/download-dicts.test.js +++ b/tests/tooling/install/download-dicts.test.js @@ -1,71 +1,13 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import crypto from 'node:crypto'; -import http from 'node:http'; import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { attachSilentLogging } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const fixturesRoot = path.join(root, 'tests', 'fixtures', 'dicts'); -const tempRoot = resolveTestCachePath(root, 'download-dicts'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(tempRoot, { recursive: true }); - -const sourceFile = path.join(fixturesRoot, 'words.txt'); -if (!fs.existsSync(sourceFile)) { - console.error(`Missing fixture: ${sourceFile}`); - process.exit(1); -} -const sourceHash = crypto.createHash('sha256') - .update(await fsPromises.readFile(sourceFile)) - .digest('hex'); - -const server = http.createServer((req, res) => { - const filePath = sourceFile; - if (!fs.existsSync(filePath)) { - res.statusCode = 404; - res.end('not found'); - return; - } - res.statusCode = 200; - fs.createReadStream(filePath).pipe(res); -}); - -await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); -const address = server.address(); -const port = typeof address === 'object' && address ? address.port : 0; -const url = `http://127.0.0.1:${port}/words.txt`; - -function run(cmd, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe'], - env: options.env || process.env - }); - attachSilentLogging(child, 'download-dicts'); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on('data', (chunk) => { - stderr += chunk.toString(); - }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - }); -} - -const result = await run( - process.execPath, - [ - path.join(root, 'tools', 'download', 'dicts.js'), +import * as helper from './download-dicts-test-helper.js'; + +const { tempRoot, sourceFile, sourceHash } = await helper.setupDownloadDictsTest('download-dicts'); +const server = await helper.startWordsServer(sourceFile); +const { url } = server; +let result; +try { + result = await helper.runDownloadDicts([ '--url', `test=${url}`, '--sha256', @@ -75,11 +17,10 @@ const result = await run( '--dir', tempRoot, '--force' - ], - { cwd: root } -); - -server.close(); + ], { logName: 'download-dicts' }); +} finally { + await server.close(); +} if (result.code !== 0) { console.error('download-dicts test failed: script error.'); @@ -88,22 +29,13 @@ if (result.code !== 0) { } const dictPath = path.join(tempRoot, 'test.txt'); -if (!fs.existsSync(dictPath)) { - console.error(`download-dicts test failed: missing ${dictPath}`); - process.exit(1); -} -const contents = await fsPromises.readFile(dictPath, 'utf8'); -if (!contents.includes('alpha')) { - console.error('download-dicts test failed: content mismatch.'); - process.exit(1); -} +await helper.assertFileIncludes(dictPath, 'alpha', { + missingMessage: `download-dicts test failed: missing ${dictPath}`, + mismatchMessage: 'download-dicts test failed: content mismatch.' +}); const manifestPath = path.join(tempRoot, 'dictionaries.json'); -if (!fs.existsSync(manifestPath)) { - console.error('download-dicts test failed: manifest missing.'); - process.exit(1); -} -const manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); +const manifest = await helper.readRequiredJson(manifestPath, 'download-dicts test failed: manifest missing.'); if (!manifest.test || manifest.test.url !== url || manifest.test.file !== 'test.txt') { console.error('download-dicts test failed: manifest entry mismatch.'); process.exit(1); diff --git a/tests/tooling/install/download-extensions.test.js b/tests/tooling/install/download-extensions.test.js index 1652cf570..27a5da028 100644 --- a/tests/tooling/install/download-extensions.test.js +++ b/tests/tooling/install/download-extensions.test.js @@ -4,13 +4,16 @@ import fsPromises from 'node:fs/promises'; import crypto from 'node:crypto'; import http from 'node:http'; import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const root = process.cwd(); const fixturesRoot = path.join(root, 'tests', 'fixtures', 'extensions'); const tempRoot = resolveTestCachePath(root, 'download-extensions'); +const verifyEnv = applyTestEnv({ syncProcess: false }); await fsPromises.rm(tempRoot, { recursive: true, force: true }); await fsPromises.mkdir(tempRoot, { recursive: true }); @@ -123,8 +126,7 @@ for (const entry of cases) { failures.push(`${entry.label} manifest hash verification missing`); } - const verify = spawnSync( - process.execPath, + const verify = runNode( [ path.join(root, 'tools', 'sqlite', 'verify-extensions.js'), '--dir', @@ -138,7 +140,10 @@ for (const entry of cases) { '--no-load', '--json' ], - { cwd: root, encoding: 'utf8' } + `verify extensions ${entry.label}`, + root, + verifyEnv, + { stdio: 'pipe' } ); if (verify.status !== 0) { failures.push(`${entry.label} verify-extensions failed`); diff --git a/tests/tooling/install/install-phpactor-phar-network-guards.test.js b/tests/tooling/install/install-phpactor-phar-network-guards.test.js new file mode 100644 index 000000000..14fc3d0a3 --- /dev/null +++ b/tests/tooling/install/install-phpactor-phar-network-guards.test.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'tooling', 'install-phpactor-phar.js'); +const tempRoot = path.join(root, '.testLogs', `install-phpactor-phar-network-${process.pid}-${Date.now()}`); +const fetchHarnessPath = path.join(tempRoot, 'fetch-harness.mjs'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +await fs.writeFile( + fetchHarnessPath, + [ + "import fs from 'node:fs/promises';", + "import { pathToFileURL } from 'node:url';", + "const [scenarioPath, scriptPath, ...scriptArgs] = process.argv.slice(2);", + "const scenario = JSON.parse(await fs.readFile(scenarioPath, 'utf8'));", + 'let callIndex = 0;', + "globalThis.fetch = async (_url, options = {}) => {", + ' const item = scenario.steps[Math.min(callIndex, scenario.steps.length - 1)] || null;', + ' callIndex += 1;', + " if (!item || item.type === 'network-error') throw new Error(item?.message || 'network error');", + " if (item.type === 'timeout') {", + " return await new Promise((_resolve, reject) => {", + ' const signal = options?.signal;', + ' if (!signal || typeof signal.addEventListener !== \"function\") return;', + " const onAbort = () => reject(new Error('AbortError'));", + " signal.addEventListener('abort', onAbort, { once: true });", + ' });', + ' }', + " const status = Number(item.status || 200);", + " const body = Buffer.from(String(item.body || ''), 'utf8');", + ' return {', + ' ok: status >= 200 && status < 300,', + ' status,', + " statusText: String(item.statusText || ''),", + " url: String(item.url || 'mock://phpactor'),", + ' arrayBuffer: async () => body', + ' };', + '};', + "process.argv = [process.execPath, scriptPath, ...scriptArgs];", + 'await import(pathToFileURL(scriptPath).href);' + ].join('\n'), + 'utf8' +); + +const runWithScenario = async ({ steps, args }) => { + const scenarioPath = path.join(tempRoot, `scenario-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + await fs.writeFile(scenarioPath, `${JSON.stringify({ steps }, null, 2)}\n`, 'utf8'); + const result = runNode( + [fetchHarnessPath, scenarioPath, scriptPath, ...args], + 'install phpactor phar network guard scenario', + root, + applyTestEnv({ syncProcess: false }), + { + stdio: 'pipe', + allowFailure: true + } + ); + return result; +}; + +try { + const timeoutBinDir = path.join(tempRoot, 'timeout-bin'); + const timeoutReportPath = path.join(tempRoot, 'timeout-report.json'); + const timeoutResult = await runWithScenario({ + steps: [{ type: 'timeout' }], + args: [ + '--bin-dir', + timeoutBinDir, + '--url', + 'https://example.invalid/timeout', + '--timeout-ms', + '100', + '--retries', + '0', + '--report', + timeoutReportPath + ] + }); + assert.equal(timeoutResult.status, 1, `expected timeout install exit code 1, received ${timeoutResult.status}`); + + const timeoutReport = JSON.parse(await fs.readFile(timeoutReportPath, 'utf8')); + assert.equal(timeoutReport?.status, 'error', 'expected timeout report status=error'); + assert.equal(timeoutReport?.reason, 'download_timeout', 'expected timeout failure reason code'); + const timeoutBinEntries = await fs.readdir(timeoutBinDir).catch(() => []); + assert.equal( + timeoutBinEntries.some((entry) => entry.includes('.tmp-')), + false, + 'expected timeout path to clean temporary phar files' + ); + + const retryBinDir = path.join(tempRoot, 'retry-bin'); + const retryReportPath = path.join(tempRoot, 'retry-report.json'); + const retryBody = 'synthetic-phpactor-phar-payload\n'; + const retryResult = await runWithScenario({ + steps: [ + { type: 'http', status: 503, statusText: 'Service Unavailable', body: 'retry please' }, + { type: 'http', status: 200, statusText: 'OK', body: retryBody } + ], + args: [ + '--bin-dir', + retryBinDir, + '--url', + 'https://example.invalid/flaky', + '--timeout-ms', + '10000', + '--retries', + '2', + '--report', + retryReportPath + ] + }); + if (retryResult.status !== 0) { + console.error('install-phpactor-phar network guard retry test failed'); + console.error(retryResult.stderr || retryResult.stdout || ''); + } + assert.equal(retryResult.status, 0, `expected retry install exit code 0, received ${retryResult.status}`); + + const retryReport = JSON.parse(await fs.readFile(retryReportPath, 'utf8')); + assert.equal(retryReport?.status, 'ok', 'expected retry report status=ok'); + assert.equal(Array.isArray(retryReport?.attempts), true, 'expected retry attempts in report'); + assert.equal(retryReport.attempts.length, 2, 'expected exactly two download attempts (503 then success)'); + assert.equal(retryReport.attempts[0]?.reason, 'download_http_error', 'expected first retry reason download_http_error'); + assert.equal(retryReport.attempts[1]?.status, 'ok', 'expected second attempt to succeed'); + assert.ok(typeof retryReport?.sha256 === 'string' && retryReport.sha256.length === 64, 'expected sha256 in success report'); + + const pharPath = path.join(retryBinDir, 'phpactor.phar'); + const pharBytes = await fs.readFile(pharPath); + assert.equal(Buffer.compare(pharBytes, Buffer.from(retryBody, 'utf8')), 0, 'expected downloaded phar payload to match fixture response payload'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('install phpactor phar network guard test passed'); diff --git a/tests/tooling/install/install-shared-primitives.test.js b/tests/tooling/install/install-shared-primitives.test.js new file mode 100644 index 000000000..62fa19d18 --- /dev/null +++ b/tests/tooling/install/install-shared-primitives.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + computeSha256, + createInstallError, + downloadToBuffer, + isRetryableHttpStatus, + jitterForAttempt, + normalizeChecksum, + toInt, + withTimeoutSignal, + writeInstallReport +} from '../../../tools/tooling/install-shared.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `install-shared-primitives-${process.pid}-${Date.now()}`); +const delay = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const originalFetch = globalThis.fetch; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +try { + assert.equal(toInt('12.9', 1, 0), 12); + assert.equal(toInt('bad', 7, 0), 7); + assert.equal(toInt('-3', 7, 2), 2); + assert.equal(jitterForAttempt(1, 200), 9); + assert.equal(jitterForAttempt(2, 200), 1); + assert.equal(jitterForAttempt(1, 0), 0); + + const clearedTimeout = withTimeoutSignal(10); + clearedTimeout.clear(); + await delay(25); + assert.equal(clearedTimeout.signal.aborted, false, 'expected cleared timeout signal to stay un-aborted'); + + const activeTimeout = withTimeoutSignal(5); + await delay(25); + assert.equal(activeTimeout.signal.aborted, true, 'expected active timeout signal to abort'); + activeTimeout.clear(); + + const installError = createInstallError('download_http_error', 'failed', { + retryable: true, + statusCode: 503, + cause: new Error('cause') + }); + assert.equal(installError.reason, 'download_http_error'); + assert.equal(installError.retryable, true); + assert.equal(installError.statusCode, 503); + assert.ok(installError.cause instanceof Error); + + assert.equal(normalizeChecksum(' ABCdef '), 'abcdef'); + assert.equal( + computeSha256(Buffer.from('abc', 'utf8')), + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + assert.equal(isRetryableHttpStatus(408), true); + assert.equal(isRetryableHttpStatus(429), true); + assert.equal(isRetryableHttpStatus(500), true); + assert.equal(isRetryableHttpStatus(404), false); + + const reportPath = path.join(tempRoot, 'reports', 'install.json'); + const writtenReportPath = await writeInstallReport(reportPath, { status: 'ok', attempts: [] }); + assert.equal(writtenReportPath, path.resolve(reportPath)); + assert.equal(await fs.readFile(reportPath, 'utf8'), '{\n "status": "ok",\n "attempts": []\n}\n'); + assert.equal(await writeInstallReport('', { status: 'ok' }), null); + + globalThis.fetch = async (url, options = {}) => ({ + ok: true, + status: 200, + statusText: 'OK', + url: `${url}/redirected`, + arrayBuffer: async () => Buffer.from(String(options.headers?.['X-Test'] || 'payload'), 'utf8') + }); + const downloaded = await downloadToBuffer({ + url: 'https://example.invalid/tool', + timeoutMs: 100, + label: 'test tool', + headers: { 'X-Test': 'body' } + }); + assert.equal(downloaded.sourceUrl, 'https://example.invalid/tool/redirected'); + assert.equal(downloaded.body.toString('utf8'), 'body'); + assert.equal(downloaded.sha256, computeSha256(Buffer.from('body', 'utf8'))); + + globalThis.fetch = async () => ({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + arrayBuffer: async () => Buffer.from('retry', 'utf8') + }); + await assert.rejects( + () => downloadToBuffer({ + url: 'https://example.invalid/tool', + timeoutMs: 100, + label: 'test tool', + drainErrorBody: true + }), + (error) => { + assert.equal(error.reason, 'download_http_error'); + assert.equal(error.retryable, true); + assert.equal(error.statusCode, 503); + return true; + } + ); +} finally { + globalThis.fetch = originalFetch; + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('install shared primitives test passed'); diff --git a/tests/tooling/install/lua-language-server-install.test.js b/tests/tooling/install/lua-language-server-install.test.js new file mode 100644 index 000000000..d46e8e1e1 --- /dev/null +++ b/tests/tooling/install/lua-language-server-install.test.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import AdmZip from 'adm-zip'; + +import { validateLuaLanguageServerPackageLayout } from '../../../tools/tooling/install-lua-language-server.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lua-language-server-install-${process.pid}-${Date.now()}`); +const env = applyTestEnv({ syncProcess: false }); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const executableName = process.platform === 'win32' ? 'lua-language-server.exe' : 'lua-language-server'; + +const createArchive = async ({ archivePath, includeMainLua }) => { + const zip = new AdmZip(); + zip.addFile(`bin/${executableName}`, Buffer.from('stub executable\n', 'utf8')); + if (includeMainLua) { + zip.addFile('bin/main.lua', Buffer.from('print("main")\n', 'utf8')); + } + zip.addFile('main.lua', Buffer.from('print("root main")\n', 'utf8')); + zip.addFile('script/init.lua', Buffer.from('return {}\n', 'utf8')); + zip.addFile('locale/en-us/script.lua', Buffer.from('return {}\n', 'utf8')); + await fs.writeFile(archivePath, zip.toBuffer()); +}; + +const runInstaller = ({ toolingRoot, archivePath, allowFailure = false }) => runNode( + [ + path.join(root, 'tools', 'tooling', 'install-lua-language-server.js'), + '--scope', + 'cache', + '--tooling-root', + toolingRoot, + '--url', + archivePath + ], + 'install lua language server', + root, + env, + { + stdio: 'pipe', + allowFailure + } +); + +try { + const goodArchivePath = path.join(tempRoot, 'lua-language-server-good.zip'); + const goodToolingRoot = path.join(tempRoot, 'tooling-good'); + await createArchive({ archivePath: goodArchivePath, includeMainLua: true }); + const goodResult = runInstaller({ toolingRoot: goodToolingRoot, archivePath: goodArchivePath }); + assert.equal(goodResult.status, 0, goodResult.stderr || goodResult.stdout); + const goodLayout = validateLuaLanguageServerPackageLayout(goodToolingRoot); + assert.equal(await fs.stat(goodLayout.executablePath).then(() => true).catch(() => false), true); + assert.equal(await fs.stat(goodLayout.mainLuaPath).then(() => true).catch(() => false), true); + + const brokenArchivePath = path.join(tempRoot, 'lua-language-server-broken.zip'); + const brokenToolingRoot = path.join(tempRoot, 'tooling-broken'); + await createArchive({ archivePath: brokenArchivePath, includeMainLua: false }); + const brokenResult = runInstaller({ + toolingRoot: brokenToolingRoot, + archivePath: brokenArchivePath, + allowFailure: true + }); + assert.notEqual(brokenResult.status, 0, 'expected installer to reject archive missing bin/main.lua'); + assert.match(String(brokenResult.stderr || brokenResult.stdout || ''), /expected bin\/main\.lua package layout/u); + + console.log('lua-language-server install test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/install/postinstall-patch-enforcement.test.js b/tests/tooling/install/postinstall-patch-enforcement.test.js index bc6311d46..8cf8d8cf6 100644 --- a/tests/tooling/install/postinstall-patch-enforcement.test.js +++ b/tests/tooling/install/postinstall-patch-enforcement.test.js @@ -3,10 +3,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; -applyTestEnv(); +const env = applyTestEnv(); const root = process.cwd(); const scriptPath = path.join(root, 'tools', 'setup', 'postinstall.js'); @@ -18,9 +18,9 @@ try { await fs.mkdir(path.join(withPatchDir, 'patches'), { recursive: true }); await fs.writeFile(path.join(withPatchDir, 'patches', 'sample+1.0.0.patch'), 'diff --git a/x b/x\n'); - const missingPatchPkgWithPatches = spawnSync(process.execPath, [scriptPath], { - cwd: withPatchDir, - encoding: 'utf8' + const missingPatchPkgWithPatches = runNode([scriptPath], 'postinstall patches without patch-package', withPatchDir, env, { + stdio: 'pipe', + allowFailure: true }); assert.equal( missingPatchPkgWithPatches.status, @@ -32,14 +32,19 @@ try { /patch-package is required/i ); - const missingPatchPkgWithPatchesOmittedDev = spawnSync(process.execPath, [scriptPath], { - cwd: withPatchDir, - encoding: 'utf8', - env: { - ...process.env, + const missingPatchPkgWithPatchesOmittedDev = runNode( + [scriptPath], + 'postinstall patches without patch-package omitted dev', + withPatchDir, + { + ...env, npm_config_omit: 'dev' + }, + { + stdio: 'pipe', + allowFailure: true } - }); + ); assert.equal( missingPatchPkgWithPatchesOmittedDev.status, 1, @@ -50,9 +55,8 @@ try { /required patches exist/i ); - const missingPatchPkgNoPatches = spawnSync(process.execPath, [scriptPath], { - cwd: withoutPatchDir, - encoding: 'utf8' + const missingPatchPkgNoPatches = runNode([scriptPath], 'postinstall no patches without patch-package', withoutPatchDir, env, { + stdio: 'pipe' }); assert.equal( missingPatchPkgNoPatches.status, diff --git a/tests/tooling/install/postinstall-rebuild-native.test.js b/tests/tooling/install/postinstall-rebuild-native.test.js index 46270b619..7f2bb9f62 100644 --- a/tests/tooling/install/postinstall-rebuild-native.test.js +++ b/tests/tooling/install/postinstall-rebuild-native.test.js @@ -3,47 +3,54 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; -applyTestEnv(); +const env = applyTestEnv(); const root = process.cwd(); const scriptPath = path.join(root, 'tools', 'setup', 'postinstall.js'); const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-postinstall-rebuild-')); -const markerPath = path.join(tempRoot, 'rebuild-ran.txt'); +const workingRoot = process.platform === 'win32' + ? path.join(tempRoot, 'patch%PATH%!runner&cwd') + : tempRoot; +const markerPath = path.join(workingRoot, 'rebuild-ran.txt'); try { - await fs.mkdir(path.join(tempRoot, 'patches'), { recursive: true }); - await fs.mkdir(path.join(tempRoot, 'node_modules', '.bin'), { recursive: true }); - await fs.mkdir(path.join(tempRoot, 'tools', 'setup'), { recursive: true }); - await fs.writeFile(path.join(tempRoot, 'patches', 'sample+1.0.0.patch'), 'diff --git a/x b/x\n'); + await fs.mkdir(path.join(workingRoot, 'patches'), { recursive: true }); + await fs.mkdir(path.join(workingRoot, 'node_modules', '.bin'), { recursive: true }); + await fs.mkdir(path.join(workingRoot, 'tools', 'setup'), { recursive: true }); + await fs.writeFile(path.join(workingRoot, 'patches', 'sample+1.0.0.patch'), 'diff --git a/x b/x\n'); const patchPackageBin = process.platform === 'win32' - ? path.join(tempRoot, 'node_modules', '.bin', 'patch-package.cmd') - : path.join(tempRoot, 'node_modules', '.bin', 'patch-package'); + ? path.join(workingRoot, 'node_modules', '.bin', 'patch-package.cmd') + : path.join(workingRoot, 'node_modules', '.bin', 'patch-package'); const patchPackageScript = process.platform === 'win32' - ? '@echo off\r\nexit /b 0\r\n' + ? '@echo off\r\nnode "%~dp0\\patch-package-runner.cjs" %*\r\n' : '#!/usr/bin/env sh\nexit 0\n'; await fs.writeFile(patchPackageBin, patchPackageScript, 'utf8'); if (process.platform !== 'win32') { await fs.chmod(patchPackageBin, 0o755); } + if (process.platform === 'win32') { + await fs.writeFile( + path.join(workingRoot, 'node_modules', '.bin', 'patch-package-runner.cjs'), + 'process.exit(0);\n', + 'utf8' + ); + } - const rebuildScriptPath = path.join(tempRoot, 'tools', 'setup', 'rebuild-native.js'); + const rebuildScriptPath = path.join(workingRoot, 'tools', 'setup', 'rebuild-native.js'); await fs.writeFile( rebuildScriptPath, `#!/usr/bin/env node -import fs from 'node:fs'; +const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(markerPath)}, 'ran', 'utf8'); `, 'utf8' ); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: tempRoot, - encoding: 'utf8' - }); + const result = runNode([scriptPath], 'postinstall rebuild native contract', workingRoot, env, { stdio: 'pipe' }); assert.equal(result.status, 0, `postinstall should succeed, got ${result.status}`); const marker = await fs.readFile(markerPath, 'utf8'); diff --git a/tests/tooling/install/rebuild-native-build-from-source-contract.test.js b/tests/tooling/install/rebuild-native-build-from-source-contract.test.js index 464f4e4f4..110d666b5 100644 --- a/tests/tooling/install/rebuild-native-build-from-source-contract.test.js +++ b/tests/tooling/install/rebuild-native-build-from-source-contract.test.js @@ -18,10 +18,30 @@ assert.match( 'expected buildNpmEnv to force npm_config_build_from_source when requested' ); -const buildFromSourceCallMatches = source.match(/buildNpmEnv\(\{\s*buildFromSource\s*\}\)/g) || []; -assert.ok( - buildFromSourceCallMatches.length >= 2, - 'expected rebuild and install-script paths to pass buildFromSource into buildNpmEnv' +assert.match( + source, + /const\s+runNpmCommand\s*=\s*\(\s*args,\s*\{\s*cwd\s*=\s*root,\s*buildFromSource\s*=\s*false\s*\}\s*=\s*\{\}\s*\)\s*=>/, + 'expected npm subprocess execution to flow through a shared local helper' +); +assert.match( + source, + /const\s+normalizePackageNameResult\s*=\s*\(\s*pkgName\s*\)\s*=>/, + 'expected package name validation to flow through a shared local helper' +); +assert.match( + source, + /spawnResolvedSubprocessSync\(\s*'npm',\s*args,/, + 'expected npm subprocess execution to use shared Windows command-shim resolution' +); +assert.match( + source, + /runNpmCommand\(args,\s*\{\s*buildFromSource\s*\}\)/, + 'expected rebuild path to pass buildFromSource into npm command helper' +); +assert.match( + source, + /runNpmCommand\(args,\s*\{\s*[\s\S]*cwd:\s*resolveNodeModulesPath\(packageNameResult\.pkgName\),\s*[\s\S]*buildFromSource\s*[\s\S]*\}\)/, + 'expected install-script path to pass buildFromSource into npm command helper' ); console.log('rebuild native build-from-source contract test passed'); diff --git a/tests/tooling/install/setup-index-detection.test.js b/tests/tooling/install/setup-index-detection.test.js index 5c0419444..08872175b 100644 --- a/tests/tooling/install/setup-index-detection.test.js +++ b/tests/tooling/install/setup-index-detection.test.js @@ -2,10 +2,10 @@ import { applyTestEnv, syncProcessEnv } from '../../helpers/test-env.js'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; import { toRealPathSync } from '../../../tools/shared/dict-utils.js'; import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; +import { runNode } from '../../helpers/run-node.js'; import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; const root = process.cwd(); @@ -26,8 +26,7 @@ async function resetIndexDir() { } function runSetup(label) { - const result = spawnSync( - process.execPath, + const result = runNode( [ path.join(root, 'tools', 'setup', 'setup.js'), '--repo', @@ -43,11 +42,10 @@ function runSetup(label) { '--skip-sqlite', '--skip-artifacts' ], - { - cwd: repoRoot, - encoding: 'utf8', - env: { ...testEnv, PAIROFCLEATS_CACHE_ROOT: cacheRoot } - } + `setup index detection ${label}`, + repoRoot, + { ...testEnv, PAIROFCLEATS_CACHE_ROOT: cacheRoot }, + { stdio: 'pipe' } ); if (result.status !== 0) { console.error(`setup index detection failed: ${label}`); @@ -72,14 +70,12 @@ const repoRoot = toRealPathSync(process.argv[1]); const userConfig = loadUserConfig(repoRoot); process.stdout.write(getIndexDir(repoRoot, process.argv[2], userConfig)); `; - const result = spawnSync( - process.execPath, + const result = runNode( ['--input-type=module', '-e', script, repoRoot, mode], - { - cwd: repoRoot, - encoding: 'utf8', - env: { ...testEnv, PAIROFCLEATS_CACHE_ROOT: cacheRoot } - } + `setup index dir ${mode}`, + repoRoot, + { ...testEnv, PAIROFCLEATS_CACHE_ROOT: cacheRoot }, + { stdio: 'pipe' } ); if (result.status !== 0) { console.error('setup index detection failed: unable to resolve setup index dir'); diff --git a/tests/tooling/install/setup-json-output.test.js b/tests/tooling/install/setup-json-output.test.js index 4395b4504..dd69dcab6 100644 --- a/tests/tooling/install/setup-json-output.test.js +++ b/tests/tooling/install/setup-json-output.test.js @@ -1,8 +1,9 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); @@ -12,8 +13,8 @@ const cacheRoot = resolveTestCachePath(root, 'setup-json-output'); await fsPromises.rm(cacheRoot, { recursive: true, force: true }); await fsPromises.mkdir(cacheRoot, { recursive: true }); -const result = spawnSync( - process.execPath, +const env = applyTestEnv({ syncProcess: false, cacheRoot }); +const result = runNode( [ path.join(root, 'tools', 'setup', 'setup.js'), '--non-interactive', @@ -27,19 +28,12 @@ const result = spawnSync( '--skip-artifacts', '--json' ], - { - cwd: fixtureRoot, - encoding: 'utf8', - env: { ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot } - } + 'setup json output', + fixtureRoot, + env, + { stdio: 'pipe' } ); -if (result.status !== 0) { - console.error('setup json-output test failed: setup exited non-zero'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - let payload; try { payload = JSON.parse(result.stdout || '{}'); diff --git a/tests/tooling/install/setup.test.js b/tests/tooling/install/setup.test.js index aec995e48..f22720c13 100644 --- a/tests/tooling/install/setup.test.js +++ b/tests/tooling/install/setup.test.js @@ -1,20 +1,21 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getCombinedOutput } from '../../helpers/stdio.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); const cacheRoot = resolveTestCachePath(root, 'setup'); +const env = applyTestEnv({ syncProcess: false, cacheRoot }); await fsPromises.rm(cacheRoot, { recursive: true, force: true }); await fsPromises.mkdir(cacheRoot, { recursive: true }); -const result = spawnSync( - process.execPath, +const result = runNode( [ path.join(root, 'tools', 'setup', 'setup.js'), '--non-interactive', @@ -27,11 +28,10 @@ const result = spawnSync( '--skip-sqlite', '--skip-artifacts' ], - { - cwd: fixtureRoot, - encoding: 'utf8', - env: { ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot } - } + 'setup non-interactive skip-all', + fixtureRoot, + env, + { stdio: 'pipe' } ); if (result.status !== 0) { @@ -46,8 +46,7 @@ if (!output.includes('Setup complete.')) { process.exit(1); } -const jsonResult = spawnSync( - process.execPath, +const jsonResult = runNode( [ path.join(root, 'tools', 'setup', 'setup.js'), '--non-interactive', @@ -61,11 +60,10 @@ const jsonResult = spawnSync( '--skip-artifacts', '--json' ], - { - cwd: fixtureRoot, - encoding: 'utf8', - env: { ...process.env, PAIROFCLEATS_CACHE_ROOT: cacheRoot } - } + 'setup non-interactive json skip-all', + fixtureRoot, + env, + { stdio: 'pipe' } ); if (jsonResult.status !== 0) { diff --git a/tests/tooling/install/tool-root.test.js b/tests/tooling/install/tool-root.test.js index a8ca81dde..81a353317 100644 --- a/tests/tooling/install/tool-root.test.js +++ b/tests/tooling/install/tool-root.test.js @@ -1,8 +1,8 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { applyTestEnv } from '../../helpers/test-env.js'; +import { createStage1CodeBuildEnv, runStage1CodeBuildOrExit } from '../../helpers/build-index-fixture.js'; +import { runNode } from '../../helpers/run-node.js'; import { setupToolingInstallWorkspace } from '../../helpers/tooling-install-fixture.js'; const { @@ -24,36 +24,23 @@ await fsPromises.writeFile( 'utf8' ); -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); +const env = createStage1CodeBuildEnv({ cacheRoot }); -const buildResult = spawnSync( - process.execPath, - [path.join(root, 'build_index.js'), '--stub-embeddings', '--mode', 'code', '--repo', repoRoot], - { cwd: outsideRoot, env, stdio: 'inherit' } -); -if (buildResult.status !== 0) { - console.error('Failed: build_index from outside repo root'); - process.exit(buildResult.status ?? 1); -} +await runStage1CodeBuildOrExit({ + root, + repoRoot, + cwd: outsideRoot, + env, + failureLabel: 'build_index from outside repo root' +}); -const searchResult = spawnSync( - process.execPath, +const searchResult = runNode( [path.join(root, 'search.js'), 'greet', '--json', '--mode', 'code', '--no-ann', '--repo', repoRoot], - { cwd: outsideRoot, env, encoding: 'utf8' } + 'search from outside repo root', + outsideRoot, + env, + { stdio: 'pipe' } ); -if (searchResult.status !== 0) { - console.error('Failed: search from outside repo root'); - console.error(searchResult.stderr || searchResult.stdout || ''); - process.exit(searchResult.status ?? 1); -} let payload = null; try { diff --git a/tests/tooling/install/tooling-detect.test.js b/tests/tooling/install/tooling-detect.test.js deleted file mode 100644 index 53b36476f..000000000 --- a/tests/tooling/install/tooling-detect.test.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); -const result = spawnSync(process.execPath, [ - path.join(root, 'tools', 'tooling', 'detect.js'), - '--root', fixtureRoot, - '--json' -], { encoding: 'utf8' }); - -if (result.status !== 0) { - console.error('tooling-detect failed'); - process.exit(result.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(result.stdout); -} catch { - console.error('tooling-detect did not return JSON'); - process.exit(1); -} - -const languages = payload.languages || {}; -const required = ['python', 'rust', 'go', 'java', 'cpp', 'objc', 'swift']; -for (const lang of required) { - if (!languages[lang]) { - console.error(`Missing detected language: ${lang}`); - process.exit(1); - } -} - -const toolIds = (payload.tools || []).map((tool) => tool.id); -const toolRequired = ['clangd', 'gopls', 'rust-analyzer', 'jdtls', 'sourcekit-lsp']; -for (const tool of toolRequired) { - if (!toolIds.includes(tool)) { - console.error(`Missing tooling entry: ${tool}`); - process.exit(1); - } -} - -console.log('tooling detect test passed'); diff --git a/tests/tooling/install/tooling-failure-exitcode.test.js b/tests/tooling/install/tooling-failure-exitcode.test.js new file mode 100644 index 000000000..9d5e7e728 --- /dev/null +++ b/tests/tooling/install/tooling-failure-exitcode.test.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { runToolingInstallWithEmptyPath } from './tooling-install-test-helper.js'; + +const { result, payload } = runToolingInstallWithEmptyPath('pyright'); + +if (result.status === 0) { + console.error('tooling-install failure exit code test failed: expected non-zero status on failed install'); + process.exit(1); +} + +const pyright = Array.isArray(payload?.results) + ? payload.results.find((entry) => entry?.id === 'pyright') + : null; +if (!pyright || pyright.status !== 'failed') { + console.error('tooling-install failure exit code test failed: expected pyright result with status=failed'); + process.exit(1); +} +if (!Number.isInteger(pyright.exitCode) || pyright.exitCode <= 0) { + console.error('tooling-install failure exit code test failed: expected numeric non-zero exitCode for failed install'); + process.exit(1); +} + +console.log('tooling-install failure exit code test passed'); diff --git a/tests/tooling/install/tooling-install-failure-exitcode.test.js b/tests/tooling/install/tooling-install-failure-exitcode.test.js deleted file mode 100644 index ad9f57af1..000000000 --- a/tests/tooling/install/tooling-install-failure-exitcode.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); -const scriptPath = path.join(root, 'tools', 'tooling', 'install.js'); - -const env = { - ...process.env, - PATH: '', - Path: '' -}; - -const result = spawnSync( - process.execPath, - [scriptPath, '--root', fixtureRoot, '--tools', 'pyright', '--json'], - { encoding: 'utf8', env } -); - -if (result.status === 0) { - console.error('tooling-install failure exit code test failed: expected non-zero status on failed install'); - process.exit(1); -} - -let payload = null; -try { - payload = JSON.parse(String(result.stdout || '{}')); -} catch { - console.error('tooling-install failure exit code test failed: stdout was not valid JSON'); - process.exit(1); -} - -const pyright = Array.isArray(payload?.results) - ? payload.results.find((entry) => entry?.id === 'pyright') - : null; -if (!pyright || pyright.status !== 'failed') { - console.error('tooling-install failure exit code test failed: expected pyright result with status=failed'); - process.exit(1); -} -if (!Number.isInteger(pyright.exitCode) || pyright.exitCode <= 0) { - console.error('tooling-install failure exit code test failed: expected numeric non-zero exitCode for failed install'); - process.exit(1); -} - -console.log('tooling-install failure exit code test passed'); diff --git a/tests/tooling/install/tooling-install-missing-requirement-exitcode.test.js b/tests/tooling/install/tooling-install-missing-requirement-exitcode.test.js deleted file mode 100644 index 4368b1f24..000000000 --- a/tests/tooling/install/tooling-install-missing-requirement-exitcode.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); -const scriptPath = path.join(root, 'tools', 'tooling', 'install.js'); - -const env = { - ...process.env, - PATH: '', - Path: '' -}; - -const result = spawnSync( - process.execPath, - [scriptPath, '--root', fixtureRoot, '--tools', 'gopls', '--json'], - { encoding: 'utf8', env } -); - -if (result.status === 0) { - console.error('tooling-install missing requirement test failed: expected non-zero status'); - process.exit(1); -} - -let payload = null; -try { - payload = JSON.parse(String(result.stdout || '{}')); -} catch { - console.error('tooling-install missing requirement test failed: stdout was not valid JSON'); - process.exit(1); -} - -const gopls = Array.isArray(payload?.results) - ? payload.results.find((entry) => entry?.id === 'gopls') - : null; -if (!gopls || gopls.status !== 'missing-requirement') { - console.error('tooling-install missing requirement test failed: expected gopls missing-requirement result'); - process.exit(1); -} - -console.log('tooling-install missing requirement exit code test passed'); diff --git a/tests/tooling/install/tooling-install-test-helper.js b/tests/tooling/install/tooling-install-test-helper.js new file mode 100644 index 000000000..aeaf91294 --- /dev/null +++ b/tests/tooling/install/tooling-install-test-helper.js @@ -0,0 +1,37 @@ +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +export const runToolingInstallWithEmptyPath = (toolId) => { + const root = process.cwd(); + const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); + const scriptPath = path.join(root, 'tools', 'tooling', 'install.js'); + const env = applyTestEnv({ + extraEnv: { + PATH: '', + Path: '' + }, + syncProcess: false + }); + + const result = runNode( + [scriptPath, '--root', fixtureRoot, '--tools', toolId, '--json'], + `tooling install empty PATH ${toolId}`, + root, + env, + { + stdio: 'pipe', + allowFailure: true + } + ); + + let payload = null; + try { + payload = JSON.parse(String(result.stdout || '{}')); + } catch { + throw new Error('tooling-install helper failed: stdout was not valid JSON'); + } + + return { result, payload }; +}; diff --git a/tests/tooling/install/tooling-install.test.js b/tests/tooling/install/tooling-install.test.js deleted file mode 100644 index 9ea7d48cd..000000000 --- a/tests/tooling/install/tooling-install.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -const root = process.cwd(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); -const result = spawnSync(process.execPath, [ - path.join(root, 'tools', 'tooling', 'install.js'), - '--root', fixtureRoot, - '--tools', 'clangd', - '--dry-run', - '--json' -], { encoding: 'utf8' }); - -if (result.status !== 0) { - console.error('tooling-install failed'); - process.exit(result.status ?? 1); -} - -let payload; -try { - payload = JSON.parse(result.stdout); -} catch { - console.error('tooling-install did not return JSON'); - process.exit(1); -} - -const results = payload.results || []; -const actions = payload.actions || []; - -if (actions.some((entry) => entry && entry.id === 'clangd')) { - console.error('Expected clangd to not be auto-installable in dry-run'); - process.exit(1); -} - -const clangdResult = results.find((entry) => entry.id === 'clangd'); -if (!clangdResult) { - console.error('Expected clangd result to be reported in dry-run'); - process.exit(1); -} - -if (!['manual', 'already-installed'].includes(clangdResult.status)) { - console.error(`Expected clangd to be manual or already-installed in dry-run (got ${clangdResult.status || 'unknown'})`); - process.exit(1); -} - -console.log('tooling install test passed'); diff --git a/tests/tooling/install/tooling-install-json-stdio-contract.test.js b/tests/tooling/install/tooling-json-stdio-contract.test.js similarity index 100% rename from tests/tooling/install/tooling-install-json-stdio-contract.test.js rename to tests/tooling/install/tooling-json-stdio-contract.test.js diff --git a/tests/tooling/install/tooling-missing-requirement-exitcode.test.js b/tests/tooling/install/tooling-missing-requirement-exitcode.test.js new file mode 100644 index 000000000..a992b7a3b --- /dev/null +++ b/tests/tooling/install/tooling-missing-requirement-exitcode.test.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { runToolingInstallWithEmptyPath } from './tooling-install-test-helper.js'; + +const { result, payload } = runToolingInstallWithEmptyPath('gopls'); + +if (result.status === 0) { + console.error('tooling-install missing requirement test failed: expected non-zero status'); + process.exit(1); +} + +const gopls = Array.isArray(payload?.results) + ? payload.results.find((entry) => entry?.id === 'gopls') + : null; +if (!gopls || gopls.status !== 'missing-requirement') { + console.error('tooling-install missing requirement test failed: expected gopls missing-requirement result'); + process.exit(1); +} +if (!Array.isArray(gopls.requirementChecks) || gopls.requirementChecks.length === 0) { + console.error('tooling-install missing requirement test failed: expected structured requirementChecks'); + process.exit(1); +} +if (!gopls.requirementChecks.every((entry) => typeof entry?.outcome === 'string' && entry.outcome.length > 0)) { + console.error('tooling-install missing requirement test failed: expected requirementChecks outcomes'); + process.exit(1); +} + +console.log('tooling-install missing requirement exit code test passed'); diff --git a/tests/tooling/install/tooling-registry-pyright-langserver.test.js b/tests/tooling/install/tooling-registry-pyright-langserver.test.js deleted file mode 100644 index 8a480df5d..000000000 --- a/tests/tooling/install/tooling-registry-pyright-langserver.test.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { getToolingRegistry } from '../../../tools/tooling/utils.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const toolingRoot = resolveTestCachePath(root, 'tooling-registry-pyright'); -const registry = getToolingRegistry(toolingRoot, root); -const pyright = registry.find((tool) => tool && tool.id === 'pyright'); - -assert.ok(pyright, 'expected pyright entry in tooling registry'); -assert.equal( - pyright.detect?.cmd, - 'pyright-langserver', - 'pyright tooling detection must use pyright-langserver' -); -assert.ok( - Array.isArray(pyright.detect?.args) && pyright.detect.args.includes('--help'), - 'pyright-langserver detection should include --help probe' -); - -console.log('tooling registry pyright detection test passed'); diff --git a/tests/tooling/install/tooling-resolve-preferred-enabled-fallback.test.js b/tests/tooling/install/tooling-resolve-preferred-enabled-fallback.test.js new file mode 100644 index 000000000..baa479f6b --- /dev/null +++ b/tests/tooling/install/tooling-resolve-preferred-enabled-fallback.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { resolveToolsForLanguages } from '../../../tools/tooling/utils.js'; + +const root = process.cwd(); +const toolingRoot = path.join(root, '.testCache', 'tooling-resolve-preferred-enabled-fallback'); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); + +const rubyTools = resolveToolsForLanguages( + ['ruby'], + toolingRoot, + fixtureRoot, + { + enabledTools: ['solargraph'], + disabledTools: ['ruby-lsp'] + } +); + +assert.deepEqual( + rubyTools.map((tool) => tool.id), + ['solargraph'], + 'expected enabled non-preferred tool to remain selectable when preferred tool is disabled' +); + +const kotlinTools = resolveToolsForLanguages( + ['kotlin'], + toolingRoot, + fixtureRoot, + { + enabledTools: ['kotlin-language-server'], + disabledTools: ['kotlin-lsp'] + } +); + +assert.deepEqual( + kotlinTools.map((tool) => tool.id), + ['kotlin-language-server'], + 'expected enabled fallback tool to remain selectable when preferred Kotlin tool is disabled' +); + +console.log('tooling resolve preferred enabled fallback test passed'); diff --git a/tests/tooling/install/tooling-signal-exit.test.js b/tests/tooling/install/tooling-signal-exit.test.js new file mode 100644 index 000000000..9ad143803 --- /dev/null +++ b/tests/tooling/install/tooling-signal-exit.test.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const source = fs.readFileSync(path.join(root, 'tools', 'tooling', 'install.js'), 'utf8'); + +assert.match( + source, + /import\s+\{\s*[^}]*exitLikeCommandResult[^}]*\}\s+from\s+'..\/shared\/cli-utils\.js';/, + 'expected tooling-install to import exitLikeCommandResult' +); +assert.match( + source, + /if\s*\(typeof\s+result\.signal\s*===\s*'string'\s*&&\s*result\.signal\.trim\(\)\)\s*\{\s*exitLikeCommandResult\(\{\s*status:\s*null,\s*signal:\s*result\.signal\s*\}\);/s, + 'expected tooling-install to exit like the interrupted child command when an install action is terminated by signal' +); + +console.log('tooling-install signal exit contract test passed'); diff --git a/tests/tooling/install/tooling.test.js b/tests/tooling/install/tooling.test.js new file mode 100644 index 000000000..ac32b353d --- /dev/null +++ b/tests/tooling/install/tooling.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const fixtureRoot = path.join(root, 'tests', 'fixtures', 'languages'); +const env = applyTestEnv({ syncProcess: false }); +const result = runNode([ + path.join(root, 'tools', 'tooling', 'install.js'), + '--root', fixtureRoot, + '--tools', 'clangd', + '--dry-run', + '--json' +], 'tooling install dry-run', root, env, { stdio: 'pipe' }); + +let payload; +try { + payload = JSON.parse(result.stdout); +} catch { + console.error('tooling-install did not return JSON'); + process.exit(1); +} + +const results = payload.results || []; +const actions = payload.actions || []; + +if (actions.some((entry) => entry && entry.id === 'clangd')) { + console.error('Expected clangd to not be auto-installable in dry-run'); + process.exit(1); +} + +const clangdResult = results.find((entry) => entry.id === 'clangd'); +if (!clangdResult) { + console.error('Expected clangd result to be reported in dry-run'); + process.exit(1); +} + +if (!['manual', 'already-installed'].includes(clangdResult.status)) { + console.error(`Expected clangd to be manual or already-installed in dry-run (got ${clangdResult.status || 'unknown'})`); + process.exit(1); +} + +console.log('tooling install test passed'); diff --git a/tests/tooling/install/uninstall.test.js b/tests/tooling/install/uninstall.test.js index 3511d49ad..fb11715fe 100644 --- a/tests/tooling/install/uninstall.test.js +++ b/tests/tooling/install/uninstall.test.js @@ -3,7 +3,7 @@ import { applyTestEnv } from '../../helpers/test-env.js'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; import { setupToolingInstallWorkspace } from '../../helpers/tooling-install-fixture.js'; applyTestEnv(); @@ -42,10 +42,12 @@ const env = applyTestEnv({ } }); -const result = spawnSync( - process.execPath, +const result = runNode( [path.join(root, 'tools', 'tooling', 'uninstall.js'), '--yes', '--repo', repoDir], - { env, stdio: 'inherit', cwd: repoDir } + 'tooling uninstall', + repoDir, + env, + { stdio: 'inherit' } ); if (result.status !== 0) { diff --git a/tests/tooling/lsp/adaptive-scope-plan.test.js b/tests/tooling/lsp/adaptive-scope-plan.test.js new file mode 100644 index 000000000..9458c05d5 --- /dev/null +++ b/tests/tooling/lsp/adaptive-scope-plan.test.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { __resolveAdaptiveLspScopePlanForTests } from '../../../src/integrations/tooling/providers/lsp.js'; + +const buildDoc = (index) => ({ + virtualPath: `.poc-vfs/src/doc-${String(index).padStart(4, '0')}.py#seg:doc-${index}.py`, + languageId: 'python', + text: `def fn_${index}():\n return ${index}\n` +}); + +const buildClangdDoc = (index) => ({ + virtualPath: `.poc-vfs/src/doc-${String(index).padStart(4, '0')}.cc#seg:doc-${index}.cc`, + languageId: 'cpp', + text: `int fn_${index}() {\n return ${index};\n}\n` +}); + +const docs = Array.from({ length: 500 }, (_, index) => buildDoc(index)); +const targetsByPath = new Map( + docs.map((doc, index) => [ + doc.virtualPath, + Array.from({ length: index < 32 ? 4 : 1 }, (_, targetIndex) => ({ id: `${index}:${targetIndex}` })) + ]) +); + +const cappedPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'pyright', + docs, + targetsByPath, + documentSymbolConcurrency: 4, + hoverMaxPerFile: null +}); +assert.equal(cappedPlan.docLimitApplied, true, 'expected pyright profile to cap large document sets'); +assert.equal(cappedPlan.selectedDocs, 192, 'expected pyright baseline cap to apply'); +assert.equal(cappedPlan.hoverMaxPerFile, 3, 'expected capped plan to tighten hover budget to degraded cap'); +assert.match(String(cappedPlan.reason || ''), /doc-cap/, 'expected capped scope reason to be recorded'); +const retainedHotDocs = cappedPlan.documents.filter((doc) => String(doc.virtualPath).includes('doc-000')); +assert.ok(retainedHotDocs.length >= 8, 'expected high-target documents to survive the initial cap'); + +const degradedPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'pyright', + docs, + targetsByPath, + documentSymbolConcurrency: 4, + clientMetrics: { + byMethod: { + 'textDocument/documentSymbol': { + timedOut: 3, + latencyMs: { p95: 3000 } + } + } + }, + hoverMaxPerFile: 20 +}); +assert.equal(degradedPlan.degraded, true, 'expected repeated documentSymbol timeout pressure to degrade scope'); +assert.equal(degradedPlan.selectedDocs, 96, 'expected degraded pyright cap to apply'); +assert.equal(degradedPlan.hoverMaxPerFile, 3, 'expected degraded plan to clamp hover max-per-file'); +assert.match(String(degradedPlan.reason || ''), /degraded-doc-cap/, 'expected degraded scope reason to be recorded'); + +const uncappedPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'clangd', + docs: Array.from({ length: 20 }, (_, index) => buildClangdDoc(index)), + targetsByPath: new Map( + Array.from({ length: 20 }, (_, index) => [ + buildClangdDoc(index).virtualPath, + [{ id: `${index}:0` }] + ]) + ), + hoverMaxPerFile: 3 +}); +assert.equal(uncappedPlan.docLimitApplied, false, 'expected small clangd inputs to avoid adaptive capping'); +assert.equal(uncappedPlan.selectedDocs, 20, 'expected uncapped provider to keep all docs'); +assert.equal(uncappedPlan.hoverMaxPerFile, 3, 'expected explicit hover max-per-file to pass through unchanged'); + +const targetHeavyDocs = Array.from({ length: 60 }, (_, index) => buildClangdDoc(index)); +const targetHeavyTargetsByPath = new Map( + targetHeavyDocs.map((doc, index) => [ + doc.virtualPath, + Array.from({ length: 20 }, (_, targetIndex) => ({ id: `${index}:${targetIndex}` })) + ]) +); +const targetCappedPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'clangd', + docs: targetHeavyDocs, + targetsByPath: targetHeavyTargetsByPath, + hoverMaxPerFile: null +}); +assert.equal(targetCappedPlan.docLimitApplied, false, 'expected target-heavy clangd input to avoid doc-count capping'); +assert.equal(targetCappedPlan.targetLimitApplied, true, 'expected clangd profile to cap by target count'); +assert.equal(targetCappedPlan.selectedTargets <= 256, true, 'expected clangd target cap to bound selected targets'); +assert.match(String(targetCappedPlan.reason || ''), /target-cap/, 'expected target cap reason to be recorded'); + +const goMixedDocs = [ + { + virtualPath: '.poc-vfs/go.mod', + languageId: 'go', + text: 'module example.com/demo\n' + }, + { + virtualPath: '.poc-vfs/src/main.go', + languageId: 'go', + text: 'package main\nfunc main() {}\n' + } +]; +const goMixedTargetsByPath = new Map([ + [goMixedDocs[0].virtualPath, [{ id: 'module-target' }]], + [goMixedDocs[1].virtualPath, [{ id: 'source-target' }]] +]); +const goMixedPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'gopls', + docs: goMixedDocs, + targetsByPath: goMixedTargetsByPath +}); +assert.equal(goMixedPlan.totalDocs, 1, 'expected path policy to drop non-source go documents before adaptive planning'); +assert.equal(goMixedPlan.selectedDocs, 1, 'expected only .go file to remain selectable'); + +const lowValueOnlyDocs = [ + { + virtualPath: '.poc-vfs/tests/unit/example_test.go', + languageId: 'go', + text: 'package unit\nfunc TestExample(t *testing.T) {}\n' + }, + { + virtualPath: '.poc-vfs/examples/demo/example.go', + languageId: 'go', + text: 'package main\nfunc main() {}\n' + } +]; +const lowValueOnlyTargetsByPath = new Map( + lowValueOnlyDocs.map((doc, index) => [doc.virtualPath, [{ id: `low:${index}` }]]) +); +const lowValueOnlyPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'gopls', + docs: lowValueOnlyDocs, + targetsByPath: lowValueOnlyTargetsByPath +}); +assert.equal(lowValueOnlyPlan.totalDocs, 0, 'expected low-value only Go docs to be skipped before documentSymbol work'); +assert.equal(lowValueOnlyPlan.selectedDocs, 0, 'expected no docs selected when only low-value paths remain'); +assert.equal( + lowValueOnlyPlan.skippedByDocumentSymbolPolicy, + 2, + 'expected low-value docs to be counted separately from extension/path skips' +); +assert.match(String(lowValueOnlyPlan.reason || ''), /document-symbol-path-policy/, 'expected no-work reason to reflect documentSymbol path policy'); + +const lowValueOnlyClangdDocs = [ + { + virtualPath: '.poc-vfs/third_party/fmt/test/format-test.cc', + languageId: 'cpp', + text: 'int main() { return 0; }\n' + }, + { + virtualPath: '.poc-vfs/vendor/demo/lib.cc', + languageId: 'cpp', + text: 'int lib() { return 1; }\n' + } +]; +const lowValueOnlyClangdTargetsByPath = new Map( + lowValueOnlyClangdDocs.map((doc, index) => [doc.virtualPath, [{ id: `clangd-low:${index}` }]]) +); +const lowValueOnlyClangdPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'clangd', + docs: lowValueOnlyClangdDocs, + targetsByPath: lowValueOnlyClangdTargetsByPath +}); +assert.equal(lowValueOnlyClangdPlan.selectedDocs, 0, 'expected low-value clangd docs to be skipped before documentSymbol work'); +assert.equal(lowValueOnlyClangdPlan.skippedByDocumentSymbolPolicy, 2, 'expected clangd low-value docs to be counted'); +assert.match(String(lowValueOnlyClangdPlan.reason || ''), /document-symbol-path-policy/, 'expected clangd no-work reason to reflect path policy'); + +const untargetedDocsPlan = __resolveAdaptiveLspScopePlanForTests({ + providerId: 'pyright', + docs: [{ + virtualPath: '.poc-vfs/src/no-target.py', + languageId: 'python', + text: 'def fn():\n return 1\n' + }], + targetsByPath: new Map() +}); +assert.equal(untargetedDocsPlan.selectedDocs, 0, 'expected untargeted docs to be dropped before documentSymbol work'); +assert.equal(untargetedDocsPlan.skippedByMissingTargets, 1, 'expected untargeted docs to be counted separately'); +assert.match(String(untargetedDocsPlan.reason || ''), /no-targets/, 'expected untargeted no-work reason to be recorded'); + +console.log('LSP adaptive scope plan test passed'); diff --git a/tests/tooling/lsp/adaptive-timeout-matrix.test.js b/tests/tooling/lsp/adaptive-timeout-matrix.test.js new file mode 100644 index 000000000..15cb46fe0 --- /dev/null +++ b/tests/tooling/lsp/adaptive-timeout-matrix.test.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseCppTwoIntParamSignature } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const parseSignature = (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: ['add'], + bareReturnType: 'unknown' +}); + +const cases = [ + { + name: 'signature-help', + mode: 'stall-signature-help', + metricKey: 'signatureHelp', + checkName: 'tooling_signature_help_timeout', + timeoutField: 'signatureHelpTimeoutMs', + timeoutMs: 1000, + docText: 'const sentinel = 1;\n', + range: (docText) => ({ start: 0, end: docText.length }), + featureFlags: { definitionEnabled: false, typeDefinitionEnabled: false, referencesEnabled: false }, + extraAssert(result) { + assert.equal(Number(result?.hoverMetrics?.timedOut || 0) >= 1, true, 'expected timeout metric increment'); + } + }, + { + name: 'definition', + mode: 'stall-definition', + metricKey: 'definition', + checkName: 'tooling_definition_timeout', + timeoutField: 'definitionTimeoutMs', + timeoutMs: 1000, + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + range: (docText) => { + const start = docText.indexOf('add'); + return { start, end: start + 3 }; + }, + featureFlags: { typeDefinitionEnabled: false, referencesEnabled: false } + }, + { + name: 'references', + mode: 'stall-references', + metricKey: 'references', + checkName: 'tooling_references_timeout', + timeoutField: 'referencesTimeoutMs', + timeoutMs: 1000, + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + range: (docText) => { + const start = docText.indexOf('add'); + return { start, end: start + 3 }; + }, + featureFlags: { definitionEnabled: false, typeDefinitionEnabled: false, referencesEnabled: true } + }, + { + name: 'type-definition', + mode: 'stall-type-definition', + metricKey: 'typeDefinition', + checkName: 'tooling_type_definition_timeout', + timeoutField: 'typeDefinitionTimeoutMs', + timeoutMs: 1000, + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + range: (docText) => { + const start = docText.indexOf('add'); + return { start, end: start + 3 }; + }, + featureFlags: { definitionEnabled: false, typeDefinitionEnabled: true, referencesEnabled: false } + } +]; + +for (const [index, testCase] of cases.entries()) { + const tempRoot = resolveTestCachePath(root, `lsp-adaptive-timeout-matrix-${index}-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + + try { + const virtualPath = `.poc-vfs/src/sample.cpp#seg:${testCase.mode}.cpp`; + const range = testCase.range(testCase.docText); + const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: testCase.docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:src/sample.cpp:${testCase.mode}`, + chunkId: `chunk_${testCase.mode.replace(/[^a-z0-9]+/gi, '_')}`, + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range + }, + virtualPath, + virtualRange: range, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', testCase.mode], + parseSignature, + hoverDisableAfterTimeouts: 1, + [testCase.timeoutField]: testCase.timeoutMs, + ...testCase.featureFlags + }); + + assert.equal(Number(result?.hoverMetrics?.[`${testCase.metricKey}Requested`] || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.[`${testCase.metricKey}Succeeded`] || 0), 0); + assert.equal(Number(result?.hoverMetrics?.[`${testCase.metricKey}TimedOut`] || 0) >= 1, true); + assert.equal(Array.isArray(result?.checks) && result.checks.some((entry) => entry?.name === testCase.checkName), true); + testCase.extraAssert?.(result); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + +console.log('LSP adaptive timeout matrix test passed'); diff --git a/tests/tooling/lsp/bychunkuid-keying.test.js b/tests/tooling/lsp/bychunkuid-keying.test.js new file mode 100644 index 000000000..72dfa2b24 --- /dev/null +++ b/tests/tooling/lsp/bychunkuid-keying.test.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createStubLspCollectFixture } from './helpers/stub-lsp-collect-fixture.js'; + +const { chunkUid, collect } = await createStubLspCollectFixture('lsp-bychunkuid'); +const result = await collect('clangd'); + +assert.ok(result.byChunkUid[chunkUid], 'expected LSP results keyed by chunkUid'); +assert.equal(result.byChunkUid[chunkUid].chunk.chunkUid, chunkUid); + +console.log('LSP byChunkUid keying test passed'); diff --git a/tests/tooling/lsp/capability-probe-gating.test.js b/tests/tooling/lsp/capability-probe-gating.test.js new file mode 100644 index 000000000..5eb7dcdb2 --- /dev/null +++ b/tests/tooling/lsp/capability-probe-gating.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createStubLspCollectFixture } from './helpers/stub-lsp-collect-fixture.js'; + +const { chunkUid, collect } = await createStubLspCollectFixture('lsp-capability-gating'); +const capabilityProbeOptions = { + semanticTokensEnabled: false, + inlayHintsEnabled: false +}; +const withNoHover = await collect('no-hover', capabilityProbeOptions); + +assert.ok(withNoHover.byChunkUid[chunkUid], 'expected enrichment to continue when hover capability is missing'); +assert.equal( + withNoHover.checks.some((check) => check?.name === 'tooling_capability_missing_hover'), + true, + 'expected hover capability warning check' +); +assert.deepEqual( + withNoHover.runtime?.capabilityGate?.missing, + ['definition', 'hover', 'references', 'signatureHelp', 'typeDefinition'], + 'expected missing-capability list in runtime gate envelope' +); + +const withoutDocumentSymbol = await collect('no-document-symbol', capabilityProbeOptions); + +assert.equal( + Object.keys(withoutDocumentSymbol.byChunkUid).length, + 0, + 'expected no enrichment when documentSymbol capability is absent' +); +assert.equal( + withoutDocumentSymbol.checks.some((check) => check?.name === 'tooling_capability_missing_document_symbol'), + true, + 'expected documentSymbol capability warning check' +); +assert.equal( + withoutDocumentSymbol.runtime?.capabilities?.documentSymbol, + false, + 'expected runtime capability mask to reflect missing documentSymbol support' +); +assert.equal( + withoutDocumentSymbol.runtime?.capabilityGate?.effective?.documentSymbol, + false, + 'expected capability gate to disable documentSymbol' +); + +console.log('LSP capability probe gating test passed'); diff --git a/tests/tooling/lsp/clangd-preflight-missing-compile-commands-inference-disabled.test.js b/tests/tooling/lsp/clangd-preflight-missing-compile-commands-inference-disabled.test.js new file mode 100644 index 000000000..7448f0176 --- /dev/null +++ b/tests/tooling/lsp/clangd-preflight-missing-compile-commands-inference-disabled.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runSingleSymbolDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const docText = 'int alpha(void) { return 1; }\n'; +await runSingleSymbolDegradedPreflightCase({ + root, + name: 'clangd-preflight-no-compile-commands-inference-disabled', + directories: ['src'], + files: [{ path: 'src/one.c', content: docText }], + providerId: 'clangd', + providerConfigKey: 'clangd', + fixtureCommand: 'clangd', + providerConfig: { + autoInferIncludeRoots: false + }, + input: { + scenarioName: 'clangd-preflight-no-compile-commands', + virtualPath: 'src/one.c', + text: docText, + languageId: 'c', + effectiveExt: '.c', + symbolName: 'alpha' + }, + expectedReasonCode: 'clangd_compile_commands_missing_inference_disabled', + expectedCheckName: 'clangd_compile_commands_missing_inference_disabled', + messages: { + enrichment: 'expected clangd output even with degraded preflight', + reasonCode: 'expected compile_commands/inference-disabled reason code', + check: 'expected compile_commands/inference-disabled warning check' + } +}); + +console.log('clangd preflight compile_commands missing with inference disabled test passed'); diff --git a/tests/tooling/lsp/clangd-provider-output-shape.test.js b/tests/tooling/lsp/clangd-provider-output-shape.test.js deleted file mode 100644 index 8d0a5b1ca..000000000 --- a/tests/tooling/lsp/clangd-provider-output-shape.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'clangd-provider-output-shape'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'src', 'one.c'), 'int alpha(void) { return 1; }\n', 'utf8'); - -registerDefaultToolingProviders(); -const provider = getToolingProvider('clangd'); -assert.ok(provider, 'expected clangd provider'); - -const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: () => {}, - strict: true -}; - -const document = { - virtualPath: 'src/one.c', - effectiveExt: '.c', - languageId: 'c', - text: 'int alpha(void) { return 1; }\n', - docHash: 'doc-1', - containerPath: 'src/one.c' -}; - -const target = { - virtualPath: 'src/one.c', - languageId: 'c', - chunkRef: { - chunkUid: 'ck:test:clangd:1', - file: 'src/one.c', - start: 0, - end: 10 - } -}; - -const output = await provider.run(ctx, { documents: [document], targets: [target, target] }); -assert.ok(output && typeof output === 'object', 'expected output object'); -assert.ok(output.byChunkUid && typeof output.byChunkUid === 'object', 'expected byChunkUid output'); -assert.ok(!('byFile' in output), 'unexpected byFile key in output'); -const checks = output.diagnostics?.checks || []; -const duplicate = checks.find((check) => check.name === 'duplicate_chunk_uid'); -assert.ok(duplicate, 'expected duplicate chunkUid warning'); -assert.ok( - Array.isArray(duplicate.samples) && duplicate.samples[0]?.startsWith('ck:'), - 'expected duplicate chunkUid samples to be chunk-style ids' -); - -console.log('clangd provider output shape test passed'); diff --git a/tests/tooling/lsp/clangd-weak-compile-context-planner.test.js b/tests/tooling/lsp/clangd-weak-compile-context-planner.test.js new file mode 100644 index 000000000..ee962a60d --- /dev/null +++ b/tests/tooling/lsp/clangd-weak-compile-context-planner.test.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const tempRoot = resolveTestCachePath(root, `clangd-weak-compile-context-planner-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'include'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'third_party'), { recursive: true }); + +registerDefaultToolingProviders(); +const provider = getToolingProvider('clangd'); +assert.ok(provider, 'expected clangd provider'); + +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'clangd.cmd' : 'clangd' +); +await fs.access(fixtureCmd); + +const ctx = { + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + clangd: { + cmd: fixtureCmd, + args: ['--background-index=false', '--log=error'], + maxDocsWithoutCompileCommands: 1, + autoInferIncludeRoots: false + } + }, + logger: () => {}, + strict: true +}; + +const sourceText = 'int alpha(void) { return 1; }\n'; +const headerText = '#pragma once\nint alpha(void);\n'; +const vendorHeaderText = '#pragma once\nint vendor_only(void);\n'; + +const sourceDoc = { + virtualPath: 'src/main.cc', + effectiveExt: '.cc', + languageId: 'cpp', + text: sourceText, + docHash: 'doc-source', + containerPath: 'src/main.cc' +}; +const headerDoc = { + virtualPath: 'include/main.h', + effectiveExt: '.h', + languageId: 'cpp', + text: headerText, + docHash: 'doc-header', + containerPath: 'include/main.h' +}; +const vendorHeaderDoc = { + virtualPath: 'third_party/vendor.h', + effectiveExt: '.h', + languageId: 'cpp', + text: vendorHeaderText, + docHash: 'doc-vendor-header', + containerPath: 'third_party/vendor.h' +}; + +const sourceChunkUid = 'ck:test:clangd-weak-compile-context:source'; +const headerChunkUid = 'ck:test:clangd-weak-compile-context:header'; +const vendorChunkUid = 'ck:test:clangd-weak-compile-context:vendor'; + +const createTarget = (virtualPath, file, chunkUid, chunkId, symbolName, text) => ({ + virtualPath, + languageId: 'cpp', + chunkRef: { + chunkUid, + chunkId, + file, + start: 0, + end: text.length + }, + virtualRange: { + start: 0, + end: text.length + }, + symbolHint: { + name: symbolName, + kind: 'function' + } +}); + +const output = await provider.run(ctx, { + documents: [headerDoc, vendorHeaderDoc, sourceDoc], + targets: [ + createTarget(headerDoc.virtualPath, 'include/main.h', headerChunkUid, 'chunk_header', 'alpha', headerText), + createTarget(vendorHeaderDoc.virtualPath, 'third_party/vendor.h', vendorChunkUid, 'chunk_vendor', 'vendor_only', vendorHeaderText), + createTarget(sourceDoc.virtualPath, 'src/main.cc', sourceChunkUid, 'chunk_source', 'alpha', sourceText) + ] +}); + +assert.equal(Boolean(output?.byChunkUid?.[sourceChunkUid]), true, 'expected actionable source file to survive weak compile-context reduction'); +assert.equal(Boolean(output?.byChunkUid?.[headerChunkUid]), false, 'expected header-only target to be dropped under weak compile context'); +assert.equal(Boolean(output?.byChunkUid?.[vendorChunkUid]), false, 'expected low-value third-party header to be dropped under weak compile context'); + +const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; +const scopeReductionCheck = checks.find((check) => check?.name === 'clangd_weak_compile_context_scope_reduced'); +assert.ok(scopeReductionCheck, 'expected weak compile-context scope reduction check'); +assert.equal(scopeReductionCheck.selectedDocs, 1, 'expected one selected document after weak compile-context reduction'); +assert.equal(scopeReductionCheck.totalDocs, 3, 'expected total document count in scope reduction check'); +assert.equal(scopeReductionCheck.droppedHeaders >= 1, true, 'expected at least one dropped header in scope reduction check'); + +console.log('clangd weak compile context planner test passed'); diff --git a/tests/tooling/lsp/lsp-collect-abort-signal.test.js b/tests/tooling/lsp/collect-abort-signal.test.js similarity index 100% rename from tests/tooling/lsp/lsp-collect-abort-signal.test.js rename to tests/tooling/lsp/collect-abort-signal.test.js diff --git a/tests/tooling/lsp/collect-shutdown-race-clean.test.js b/tests/tooling/lsp/collect-shutdown-race-clean.test.js new file mode 100644 index 000000000..1a27f40f4 --- /dev/null +++ b/tests/tooling/lsp/collect-shutdown-race-clean.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-collect-shutdown-race-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const logs = []; +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:shutdown-race.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:shutdown-race'; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-shutdown-race' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_shutdown_race', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd', '--exit-on-shutdown'], + log: (line) => logs.push(String(line || '')), + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }), + retries: 0, + timeoutMs: 1500 +}); + +assert.ok(result.byChunkUid[chunkUid], 'expected enrichment before shutdown sequence'); +assert.equal( + logs.some((line) => line.includes('ERR_STREAM_DESTROYED')), + false, + 'collectLspTypes shutdown race emitted ERR_STREAM_DESTROYED' +); +assert.equal( + logs.some((line) => /\[lsp\]\s+write error:/i.test(line)), + false, + 'collectLspTypes shutdown race emitted write-error log noise' +); +assert.equal( + logs.some((line) => /\bEPIPE\b/i.test(line)), + false, + 'collectLspTypes shutdown race emitted EPIPE log noise' +); + +console.log('LSP collect shutdown race clean test passed'); diff --git a/tests/tooling/lsp/configured-provider-generic-presets-matrix.test.js b/tests/tooling/lsp/configured-provider-generic-presets-matrix.test.js new file mode 100644 index 000000000..c6cb4aef4 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-generic-presets-matrix.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'configured-lsp-generic-presets-matrix'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +const presetMatrix = [ + { + preset: 'gopls', + providerId: 'lsp-gopls', + languageId: 'go', + ext: '.go', + text: 'package main\nfunc add(a int, b int) int { return a + b }\n', + symbol: 'add' + }, + { + preset: 'rust-analyzer', + providerId: 'lsp-rust-analyzer', + languageId: 'rust', + ext: '.rs', + text: 'fn add(a: i32, b: i32) -> i32 { a + b }\n', + symbol: 'add' + }, + { + preset: 'lua-language-server', + providerId: 'lsp-lua-language-server', + languageId: 'lua', + ext: '.lua', + text: 'local function add(a, b) return a + b end\n', + symbol: 'add' + }, + { + preset: 'zls', + providerId: 'lsp-zls', + languageId: 'zig', + ext: '.zig', + text: 'fn add(a: i32, b: i32) i32 { return a + b; }\n', + symbol: 'add' + } +]; + +try { + for (const [index, entry] of presetMatrix.entries()) { + const fileName = `sample-${entry.preset}-${index}${entry.ext}`; + const chunkUid = `ck64:v1:test:src/${fileName}`; + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + lsp: { + enabled: true, + servers: [{ + preset: entry.preset, + languages: [entry.languageId], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath: `.poc-vfs/src/${fileName}#seg:${fileName}`, + text: entry.text, + languageId: entry.languageId, + effectiveExt: entry.ext, + docHash: `hash-stub-${index}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: `chunk_${entry.preset}_${index}`, + file: `src/${fileName}`, + segmentUid: null, + segmentId: null, + range: { start: 0, end: entry.text.length } + }, + virtualPath: `.poc-vfs/src/${fileName}#seg:${fileName}`, + virtualRange: { start: 0, end: entry.text.length }, + symbolHint: { name: entry.symbol, kind: 'function' }, + languageId: entry.languageId + }], + kinds: ['types'] + }); + + assert.ok(result.byChunkUid instanceof Map, `expected map output for preset ${entry.preset}`); + const providerDiag = result.diagnostics?.[entry.providerId] || null; + if (providerDiag) { + assert.equal( + Boolean(providerDiag.runtime) || Array.isArray(providerDiag.checks), + true, + `expected diagnostic envelope shape for ${entry.providerId}` + ); + } + } + + console.log('configured LSP generic presets matrix test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/lsp/configured-provider-generic-presets-mixed-routing.test.js b/tests/tooling/lsp/configured-provider-generic-presets-mixed-routing.test.js new file mode 100644 index 000000000..d79e2526e --- /dev/null +++ b/tests/tooling/lsp/configured-provider-generic-presets-mixed-routing.test.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'configured-lsp-generic-presets-mixed-routing'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +const scenarios = [ + { + preset: 'gopls', + providerId: 'lsp-gopls', + languageId: 'go', + ext: '.go', + text: 'package main\nfunc add(a int, b int) int { return a + b }\n' + }, + { + preset: 'rust-analyzer', + providerId: 'lsp-rust-analyzer', + languageId: 'rust', + ext: '.rs', + text: 'fn add(a: i32, b: i32) -> i32 { a + b }\n' + }, + { + preset: 'lua-language-server', + providerId: 'lsp-lua-language-server', + languageId: 'lua', + ext: '.lua', + text: 'local function add(a, b) return a + b end\n' + }, + { + preset: 'zls', + providerId: 'lsp-zls', + languageId: 'zig', + ext: '.zig', + text: 'fn add(a: i32, b: i32) i32 { return a + b; }\n' + } +]; + +try { + const documents = []; + const targets = []; + for (const [index, scenario] of scenarios.entries()) { + const fileName = `sample-${scenario.languageId}-${index}${scenario.ext}`; + const virtualPath = `.poc-vfs/src/${fileName}#seg:${fileName}`; + const chunkUid = `ck64:v1:test:src/${fileName}`; + documents.push({ + virtualPath, + text: scenario.text, + languageId: scenario.languageId, + effectiveExt: scenario.ext, + docHash: `hash-${scenario.languageId}-${index}` + }); + targets.push({ + chunkRef: { + docId: index, + chunkUid, + chunkId: `chunk_${scenario.languageId}_${index}`, + file: `src/${fileName}`, + segmentUid: null, + segmentId: null, + range: { start: 0, end: scenario.text.length } + }, + virtualPath, + virtualRange: { start: 0, end: scenario.text.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: scenario.languageId + }); + } + + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + lsp: { + enabled: true, + servers: scenarios.map((scenario) => ({ + preset: scenario.preset, + uriScheme: 'poc-vfs' + })) + } + }, + cache: { + enabled: false + } + }, { + documents, + targets, + kinds: ['types'] + }); + + assert.ok(result.byChunkUid instanceof Map, 'expected merged byChunkUid map'); + + for (const [index, scenario] of scenarios.entries()) { + const fileName = `sample-${scenario.languageId}-${index}${scenario.ext}`; + const chunkUid = `ck64:v1:test:src/${fileName}`; + const entry = result.byChunkUid.get(chunkUid) || null; + const sourceSet = result.sourcesByChunkUid.get(chunkUid); + const providerDiag = result.diagnostics?.[scenario.providerId] || null; + if (providerDiag) { + assert.equal( + Boolean(providerDiag.runtime) || Array.isArray(providerDiag.checks), + true, + `expected diagnostic envelope shape for ${scenario.providerId}` + ); + } + if (entry && sourceSet instanceof Set) { + assert.equal(sourceSet.has(scenario.providerId), true, `expected ${scenario.providerId} source for ${chunkUid}`); + } + } + + console.log('configured LSP generic presets mixed routing test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/lsp/configured-provider-go-rust-signature-parsing.test.js b/tests/tooling/lsp/configured-provider-go-rust-signature-parsing.test.js new file mode 100644 index 000000000..dc09767d2 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-go-rust-signature-parsing.test.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +import { cleanupLspTestRuntime, withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('configured-lsp-go-rust-signatures', { root }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docsByLanguage = { + go: { + ext: '.go', + text: 'package main\nfunc Add(a int, b int) int { return a + b }\n' + }, + rust: { + ext: '.rs', + text: 'fn add(a: i32, b: i32) -> i32 { a + b }\n' + }, + lua: { + ext: '.lua', + text: 'function greet(name: string): string\n return name\nend\n' + }, + zig: { + ext: '.zig', + text: 'fn add(a: i32, b: i32) i32 { return a + b; }\n' + } +}; + +const runSingleLanguageCase = async ({ + languageId, + mode, + symbolName, + returnType, + paramTypes, + chunkUid +}) => { + await cleanupLspTestRuntime({ + reason: `configured_lsp_signature_case_${mode}_start`, + strict: true + }); + const caseRoot = path.join(tempRoot, mode); + const docConfig = docsByLanguage[languageId]; + if (!docConfig) throw new Error(`missing test doc config for ${languageId}`); + const fileName = `sample${docConfig.ext}`; + const virtualPath = `.poc-vfs/src/${fileName}#seg:${mode}.txt`; + const docText = docConfig.text; + const configuredServerId = `test-${mode}`; + const configuredProviderId = `lsp-${configuredServerId}`; + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(caseRoot, 'src'), { recursive: true }); + const serverConfig = { + id: configuredServerId, + cmd: process.execPath, + args: [serverPath, '--mode', mode], + languages: [languageId], + uriScheme: 'poc-vfs', + timeoutMs: 15000, + documentSymbolTimeoutMs: 8000, + hoverTimeoutMs: 8000, + signatureHelpTimeoutMs: 8000, + documentSymbolConcurrency: 1, + hoverConcurrency: 1, + signatureHelpConcurrency: 1, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false, + semanticTokensEnabled: false, + inlayHintsEnabled: false + }; + if (languageId === 'go') { + // Keep the test focused on signature parsing instead of host Go toolchain state. + serverConfig.goWorkspaceWarmup = false; + serverConfig.goWorkspaceModuleCmd = process.execPath; + const goProbeScriptPath = path.join(caseRoot, 'go-probe-ok.js'); + await fs.writeFile( + goProbeScriptPath, + "process.stdout.write('example.com/poc-signature-test\\n');\n", + 'utf8' + ); + serverConfig.goWorkspaceModuleArgs = [goProbeScriptPath]; + await fs.writeFile(path.join(caseRoot, 'go.mod'), 'module example.com/poc-signature-test\n\ngo 1.22\n'); + } + if (languageId === 'rust') { + const rustMetadataScriptPath = path.join(caseRoot, 'rust-metadata-ok.js'); + await fs.writeFile( + rustMetadataScriptPath, + "process.stdout.write('{\"packages\":[]}\\n');\n", + 'utf8' + ); + serverConfig.rustWorkspaceMetadataCmd = process.execPath; + serverConfig.rustWorkspaceMetadataArgs = [rustMetadataScriptPath]; + serverConfig.rustWorkspaceMetadataTimeoutMs = 5000; + await fs.writeFile( + path.join(caseRoot, 'Cargo.toml'), + '[package]\nname = "poc-signature-test"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\npath = "src/lib.rs"\n' + ); + } + await fs.writeFile(path.join(caseRoot, 'src', fileName), docText, 'utf8'); + if (languageId === 'rust') { + await fs.writeFile(path.join(caseRoot, 'src', 'lib.rs'), docText, 'utf8'); + } + let result = null; + let hit = null; + let hitDebug = ''; + for (let attempt = 0; attempt < 3 && !hit; attempt += 1) { + result = await runToolingProviders({ + strict: true, + repoRoot: caseRoot, + buildRoot: caseRoot, + toolingConfig: { + enabledTools: [configuredProviderId], + lsp: { + enabled: true, + servers: [serverConfig] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath, + text: docText, + languageId, + effectiveExt: docConfig.ext, + docHash: `hash-${mode}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: `chunk_${mode}`, + file: `src/${fileName}`, + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: symbolName, kind: 'function' }, + languageId + }], + kinds: ['types'] + }); + hit = result.byChunkUid.get(chunkUid); + if (!hit) { + const providerRuntime = result.metrics?.providerRuntime?.[configuredProviderId] || null; + const providerDiagnostics = result.diagnostics?.[configuredProviderId] || null; + hitDebug = JSON.stringify({ + providerRuntime, + preflightState: providerDiagnostics?.preflight?.state || null, + fidelityState: providerDiagnostics?.fidelity?.state || null, + checkNames: Array.isArray(providerDiagnostics?.checks) + ? providerDiagnostics.checks.map((check) => check?.name).filter(Boolean) + : [] + }); + } + if (!hit && attempt < 2) { + await cleanupLspTestRuntime({ + reason: `configured_lsp_signature_case_${mode}_retry`, + strict: true + }); + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); + } + } + assert.ok(hit, `expected LSP hit for ${languageId}${hitDebug ? `; diagnostics=${hitDebug}` : ''}`); + assert.equal(hit.payload?.returnType, returnType, `unexpected returnType for ${languageId}`); + assert.equal(result.metrics?.providersExecuted, 1, `expected one executed provider for ${languageId}`); + assert.equal( + Number(result.metrics?.providerRuntime?.[configuredProviderId]?.requests?.requests || 0) > 0, + true, + `expected request metrics for ${languageId}` + ); + assert.equal( + result.metrics?.providerRuntime?.[configuredProviderId]?.degraded?.active, + false, + `unexpected degraded mode for ${languageId}` + ); + for (const [name, expectedType] of Object.entries(paramTypes)) { + assert.equal( + hit.payload?.paramTypes?.[name]?.[0]?.type, + expectedType, + `unexpected param type ${name} for ${languageId}` + ); + } +}; + +await withLspTestPath({ repoRoot: root }, async () => { + await runSingleLanguageCase({ + languageId: 'go', + mode: 'go', + symbolName: 'Add', + returnType: 'int', + paramTypes: { a: 'int', b: 'int' }, + chunkUid: 'ck64:v1:test:src/sample.go:go-signature' + }); + + await runSingleLanguageCase({ + languageId: 'rust', + mode: 'rust', + symbolName: 'add', + returnType: 'i32', + paramTypes: { a: 'i32', b: 'i32' }, + chunkUid: 'ck64:v1:test:src/sample.rs:rust-signature' + }); + + await runSingleLanguageCase({ + languageId: 'lua', + mode: 'lua', + symbolName: 'greet', + returnType: 'string', + paramTypes: { name: 'string' }, + chunkUid: 'ck64:v1:test:src/sample.lua:lua-signature' + }); + + await runSingleLanguageCase({ + languageId: 'zig', + mode: 'zig', + symbolName: 'add', + returnType: 'i32', + paramTypes: { a: 'i32', b: 'i32' }, + chunkUid: 'ck64:v1:test:src/sample.zig:zig-signature' + }); +}); + +console.log('configured LSP go/rust/lua/zig signature parsing test passed'); diff --git a/tests/tooling/lsp/configured-provider-go-workspace-negative-cache-hit.test.js b/tests/tooling/lsp/configured-provider-go-workspace-negative-cache-hit.test.js new file mode 100644 index 000000000..f02d843c5 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-go-workspace-negative-cache-hit.test.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-go-workspace-negative-cache-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); + +const counterScriptPath = path.join(tempRoot, 'count-failure.js'); +await fs.writeFile( + counterScriptPath, + [ + "import fs from 'node:fs';", + "const countPath = process.argv[2];", + "let next = 1;", + 'try {', + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + '} catch {}', + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "process.stderr.write('forced go workspace module probe failure\\n');", + 'process.exit(17);' + ].join('\n'), + 'utf8' +); + +const moduleCountPath = path.join(tempRoot, 'module-count.txt'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/main.go:go-workspace-negative-cache-hit'; + +const createContext = () => ({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-go-workspace-negative-cache'], + lsp: { + enabled: true, + servers: [{ + id: 'go-workspace-negative-cache', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [counterScriptPath, moduleCountPath], + goWorkspaceWarmup: false + }] + } + }, + cache: { + enabled: true, + dir: toolingCacheDir + } +}); + +const providerInputs = { + documents: [{ + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-negative-cache-hit-a.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-negative-cache-a' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_go_workspace_negative_cache_hit_a', + file: 'src/main.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-negative-cache-hit-a.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}; + +const providerInputsCacheMiss = { + documents: [{ + ...providerInputs.documents[0], + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-negative-cache-hit-b.txt', + docHash: 'hash-go-workspace-negative-cache-b' + }], + targets: [{ + ...providerInputs.targets[0], + chunkRef: { + ...providerInputs.targets[0].chunkRef, + chunkUid: `${chunkUid}:b`, + chunkId: 'chunk_go_workspace_negative_cache_hit_b' + }, + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-negative-cache-hit-b.txt' + }], + kinds: ['types'] +}; + +const readCount = async (targetPath) => { + try { + return Number.parseInt(await fs.readFile(targetPath, 'utf8'), 10); + } catch { + return 0; + } +}; + +const first = await runToolingProviders(createContext(), providerInputs); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first negative preflight run to be uncached'); +assert.equal(await readCount(moduleCountPath), 1, 'expected first negative preflight run to execute once'); +assert.equal( + first.diagnostics?.['lsp-go-workspace-negative-cache']?.preflight?.reasonCode, + 'go_workspace_blocked_workspace_shape', + 'expected first negative preflight reason code' +); + +const second = await runToolingProviders(createContext(), providerInputsCacheMiss); +assert.equal(second.metrics?.preflights?.cached, 1, 'expected cache-miss provider rerun to reuse negative preflight cache'); +assert.equal(await readCount(moduleCountPath), 1, 'expected negative preflight cache to skip rerun'); +assert.equal( + second.diagnostics?.['lsp-go-workspace-negative-cache']?.preflight?.cached, + true, + 'expected negative preflight diagnostics to report cached marker reuse' +); +assert.equal( + second.diagnostics?.['lsp-go-workspace-negative-cache']?.preflight?.reasonCode, + 'go_workspace_blocked_workspace_shape', + 'expected cached negative preflight reason code to be preserved' +); + +console.log('configured LSP go workspace negative cache hit test passed'); diff --git a/tests/tooling/lsp/configured-provider-go-workspace-preflight-cache-hit.test.js b/tests/tooling/lsp/configured-provider-go-workspace-preflight-cache-hit.test.js new file mode 100644 index 000000000..912297997 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-go-workspace-preflight-cache-hit.test.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-go-workspace-preflight-cache-hit-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'src', 'main.go'), 'package main\nfunc Add(a int, b int) int { return a + b }\n', 'utf8'); + +const counterScriptPath = path.join(tempRoot, 'count-success.js'); +await fs.writeFile( + counterScriptPath, + [ + "import fs from 'node:fs';", + "const countPath = process.argv[2];", + "let next = 1;", + "try {", + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + "} catch {}", + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "process.stdout.write('ok\\n');" + ].join('\n'), + 'utf8' +); + +const moduleCountPath = path.join(tempRoot, 'module-count.txt'); +const warmupCountPath = path.join(tempRoot, 'warmup-count.txt'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/main.go:go-workspace-preflight-cache-hit'; + +const createContext = () => ({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-go-workspace-preflight-cache-hit'], + lsp: { + enabled: true, + servers: [{ + id: 'go-workspace-preflight-cache-hit', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [counterScriptPath, moduleCountPath], + goWorkspaceWarmup: true, + goWorkspaceWarmupMinGoFiles: 1, + goWorkspaceWarmupCmd: process.execPath, + goWorkspaceWarmupArgs: [counterScriptPath, warmupCountPath] + }] + } + }, + cache: { + enabled: true, + dir: toolingCacheDir + } +}); + +const providerInputs = { + documents: [{ + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-preflight-cache-hit.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-preflight-cache-hit' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_go_workspace_preflight_cache_hit', + file: 'src/main.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-preflight-cache-hit.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}; + +const providerInputsCacheMiss = { + documents: [{ + ...providerInputs.documents[0], + docHash: 'hash-go-workspace-preflight-cache-hit-b' + }], + targets: [{ + ...providerInputs.targets[0], + chunkRef: { + ...providerInputs.targets[0].chunkRef, + chunkUid: `${chunkUid}:b`, + chunkId: 'chunk_go_workspace_preflight_cache_hit_b' + } + }], + kinds: ['types'] +}; + +const readCount = async (targetPath) => { + try { + return Number.parseInt(await fs.readFile(targetPath, 'utf8'), 10); + } catch { + return 0; + } +}; + +const first = await runToolingProviders(createContext(), providerInputs); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first go preflight run to be uncached'); +assert.equal(await readCount(moduleCountPath), 1, 'expected first run to execute go module preflight once'); +assert.equal(await readCount(warmupCountPath), 1, 'expected first run to execute go warmup preflight once'); + +const second = await runToolingProviders(createContext(), providerInputs); +assert.equal(second.metrics?.preflights?.total, 0, 'expected provider output cache hit to skip preflight entirely'); +assert.equal(await readCount(moduleCountPath), 1, 'expected cached go module preflight to skip rerun'); +assert.equal(await readCount(warmupCountPath), 1, 'expected cached go warmup preflight to skip rerun'); + +const third = await runToolingProviders(createContext(), providerInputsCacheMiss); +assert.equal(third.metrics?.preflights?.cached, 1, 'expected cache-miss provider rerun to hit persistent go preflight cache'); +assert.equal( + third.diagnostics?.['lsp-go-workspace-preflight-cache-hit']?.preflight?.cached, + true, + 'expected go preflight diagnostics to report cached marker reuse' +); + +console.log('configured LSP go workspace preflight cache hit test passed'); diff --git a/tests/tooling/lsp/configured-provider-go-workspace-warmup-doc-count-gating.test.js b/tests/tooling/lsp/configured-provider-go-workspace-warmup-doc-count-gating.test.js new file mode 100644 index 000000000..f03e92911 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-go-workspace-warmup-doc-count-gating.test.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-go-workspace-warmup-doc-gating-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'src', 'main.go'), 'package main\nfunc Add(a int, b int) int { return a + b }\n', 'utf8'); + +const modulePassScriptPath = path.join(tempRoot, 'go-module-pass.js'); +await fs.writeFile(modulePassScriptPath, "process.stdout.write('ok\\n');\n", 'utf8'); + +const warmupFailScriptPath = path.join(tempRoot, 'go-warmup-fail.js'); +await fs.writeFile( + warmupFailScriptPath, + "process.stderr.write('warmup should not run\\n'); process.exit(19);\n", + 'utf8' +); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/main.go:go-workspace-warmup-doc-gating'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-go-workspace-warmup-doc-gating'], + lsp: { + enabled: true, + servers: [{ + id: 'go-workspace-warmup-doc-gating', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [modulePassScriptPath], + goWorkspaceWarmup: true, + goWorkspaceWarmupMinGoFiles: 5, + goWorkspaceWarmupCmd: process.execPath, + goWorkspaceWarmupArgs: [warmupFailScriptPath] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-warmup-doc-gating.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-warmup-doc-gating' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_go_workspace_warmup_doc_gating', + file: 'src/main.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-warmup-doc-gating.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}); + +const diagnostics = result.diagnostics?.['lsp-go-workspace-warmup-doc-gating'] || {}; +assert.equal(diagnostics?.preflight?.state, 'ready', 'expected warmup gating to keep gopls preflight ready'); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => String(check?.name || '').startsWith('go_workspace_warmup_probe_')), + false, + 'expected doc-count gate to skip go workspace warmup subprocess' +); + +console.log('configured LSP go workspace warmup doc-count gating test passed'); diff --git a/tests/tooling/lsp/configured-provider-go-workspace-warmup-preflight-failed.test.js b/tests/tooling/lsp/configured-provider-go-workspace-warmup-preflight-failed.test.js new file mode 100644 index 000000000..00ef1fd43 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-go-workspace-warmup-preflight-failed.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-go-workspace-warmup-failed-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'src', 'main.go'), 'package main\nfunc Add(a int, b int) int { return a + b }\n', 'utf8'); + +const goProbePassScriptPath = path.join(tempRoot, 'go-probe-pass.js'); +await fs.writeFile( + goProbePassScriptPath, + 'process.stdout.write("example.com/preflight\\n"); process.exit(0);\n', + 'utf8' +); + +const goWarmupFailScriptPath = path.join(tempRoot, 'go-warmup-fail.js'); +await fs.writeFile( + goWarmupFailScriptPath, + 'process.stderr.write("forced go workspace warmup failure\\n"); process.exit(19);\n', + 'utf8' +); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/main.go:go-workspace-warmup-preflight-failed'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-go-workspace-warmup-preflight'], + lsp: { + enabled: true, + servers: [{ + id: 'go-workspace-warmup-preflight', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [goProbePassScriptPath], + goWorkspaceWarmup: true, + goWorkspaceWarmupMinGoFiles: 1, + goWorkspaceWarmupCmd: process.execPath, + goWorkspaceWarmupArgs: [goWarmupFailScriptPath] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-warmup-preflight-failed.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-warmup-preflight-failed' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_go_workspace_warmup_preflight_failed', + file: 'src/main.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/main.go#seg:go-workspace-warmup-preflight-failed.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}); + +const diagnostics = result.diagnostics?.['lsp-go-workspace-warmup-preflight'] || {}; +assert.equal( + diagnostics?.preflight?.state, + 'blocked', + 'expected go workspace warmup preflight blocked state when no healthy partition remains' +); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'go_workspace_blocked_workspace_shape', + 'expected go workspace warmup preflight blocked workspace-shape reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => String(check?.name || '') === 'go_workspace_warmup_probe_failed'), + true, + 'expected go workspace warmup preflight warning check' +); + +console.log('configured LSP go workspace warmup preflight failed test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-go-work-multi-root.test.js b/tests/tooling/lsp/configured-provider-gopls-go-work-multi-root.test.js new file mode 100644 index 000000000..bc8b20498 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-go-work-multi-root.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-go-work-multi-root-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'svc-b', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'go.work'), 'go 1.22\n\nuse ./svc-a\nuse ./svc-b\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'svc-a', 'go.mod'), 'module example.com/svc-a\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'svc-b', 'go.mod'), 'module example.com/svc-b\n\ngo 1.22\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUidA = 'ck64:v1:test:svc-a/src/sample.go:gopls-go-work-multi-root:a'; +const chunkUidB = 'ck64:v1:test:svc-b/src/sample.go:gopls-go-work-multi-root:b'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-gopls'], + lsp: { + enabled: true, + servers: [{ + id: 'gopls', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [], + goWorkspaceWarmup: false + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [ + { + virtualPath: '.poc-vfs/svc-a/src/sample.go#seg:gopls-go-work-multi-root-a.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-go-work-multi-root-a' + }, + { + virtualPath: '.poc-vfs/svc-b/src/sample.go#seg:gopls-go-work-multi-root-b.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-go-work-multi-root-b' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: chunkUidA, + chunkId: 'chunk_gopls_go_work_multi_root_a', + file: 'svc-a/src/sample.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/svc-a/src/sample.go#seg:gopls-go-work-multi-root-a.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }, + { + chunkRef: { + docId: 1, + chunkUid: chunkUidB, + chunkId: 'chunk_gopls_go_work_multi_root_b', + file: 'svc-b/src/sample.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/svc-b/src/sample.go#seg:gopls-go-work-multi-root-b.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + } + ], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUidA), true, 'expected first go.work partition to contribute'); +assert.equal(result.byChunkUid.has(chunkUidB), true, 'expected second go.work partition to contribute'); +const diagnostics = result.diagnostics?.['lsp-gopls'] || {}; +assert.equal(diagnostics?.preflight?.state, 'ready', 'expected go.work multi-root preflight ready state'); +assert.equal( + diagnostics?.workspaceModel?.partitionCount, + 2, + 'expected go.work multi-root workspace summary to expose both partitions' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'go_workspace_module_root_partitioned'), + true, + 'expected go workspace partitioned preflight check for go.work repo' +); + +console.log('configured LSP gopls go.work multi-root test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache-expiry.test.js b/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache-expiry.test.js new file mode 100644 index 000000000..d4153a0fe --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache-expiry.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + buildGoplsWorkspaceContext, + buildGoplsWorkspaceInputs, + goplsSampleDocText +} from './helpers/gopls-workspace-case.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-partition-local-negative-cache-expiry-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-bad', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'svc-bad', 'go.mod'), 'module example.com/svc-bad\n\ngo 1.22\n', 'utf8'); + +const selectiveProbePath = path.join(tempRoot, 'go-probe-selective-expiry.js'); +await fs.writeFile( + selectiveProbePath, + [ + "import fs from 'node:fs';", + "import path from 'node:path';", + "const countPath = process.argv[2];", + "const cwdName = path.basename(process.cwd());", + "const counts = fs.existsSync(countPath) ? JSON.parse(fs.readFileSync(countPath, 'utf8')) : {};", + "counts[cwdName] = Number(counts[cwdName] || 0) + 1;", + "fs.writeFileSync(countPath, JSON.stringify(counts), 'utf8');", + "process.stderr.write('forced blocked workspace partition\\n');", + 'process.exit(19);' + ].join('\n'), + 'utf8' +); + +const moduleCountPath = path.join(tempRoot, 'module-counts.json'); + +const createContext = () => buildGoplsWorkspaceContext({ + root, + tempRoot, + providerId: 'lsp-gopls-partition-local-negative-cache-expiry', + serverId: 'gopls-partition-local-negative-cache-expiry', + probePath: selectiveProbePath, + probeArgs: [moduleCountPath], + cache: { + enabled: true, + dir: toolingCacheDir + }, + serverConfig: { + goWorkspaceNegativeCacheTtlMs: 1 + } +}); + +const createInputs = (suffix) => buildGoplsWorkspaceInputs({ + scenario: 'gopls-partition-local-negative-cache-expiry', + docText: goplsSampleDocText, + partitions: [{ service: 'svc-bad', suffix }] +}); + +const readCounts = async () => { + try { + return JSON.parse(await fs.readFile(moduleCountPath, 'utf8')); + } catch { + return {}; + } +}; + +const first = await runToolingProviders(createContext(), createInputs('first')); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first blocked partition run to be uncached'); +assert.deepEqual(await readCounts(), { 'svc-bad': 1 }, 'expected first blocked partition probe to run once'); + +await delay(25); + +const second = await runToolingProviders(createContext(), createInputs('second')); +assert.equal( + second.diagnostics?.['lsp-gopls-partition-local-negative-cache-expiry']?.preflight?.cached || false, + false, + 'expected expired blocked partition cache entry to force revalidation' +); +assert.deepEqual( + await readCounts(), + { 'svc-bad': 2 }, + 'expected expired blocked partition cache entry to rerun the failing probe' +); + +console.log('configured LSP gopls partition-local negative cache expiry test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache.test.js b/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache.test.js new file mode 100644 index 000000000..03ec91205 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-partition-local-negative-cache.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + buildGoplsWorkspaceContext, + buildGoplsWorkspaceInputs, + goplsSampleDocText +} from './helpers/gopls-workspace-case.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-partition-local-negative-cache-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-ok', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'svc-bad', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'svc-ok', 'go.mod'), 'module example.com/svc-ok\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'svc-bad', 'go.mod'), 'module example.com/svc-bad\n\ngo 1.22\n', 'utf8'); + +const selectiveProbePath = path.join(tempRoot, 'go-probe-selective-cache.js'); +await fs.writeFile( + selectiveProbePath, + [ + "import fs from 'node:fs';", + "import path from 'node:path';", + "const countPath = process.argv[2];", + "const cwdName = path.basename(process.cwd());", + "const counts = fs.existsSync(countPath) ? JSON.parse(fs.readFileSync(countPath, 'utf8')) : {};", + "counts[cwdName] = Number(counts[cwdName] || 0) + 1;", + "fs.writeFileSync(countPath, JSON.stringify(counts), 'utf8');", + "if (cwdName === 'svc-bad') {", + " process.stderr.write('forced blocked workspace partition\\n');", + ' process.exit(19);', + '}', + "process.stdout.write('ok\\n');" + ].join('\n'), + 'utf8' +); + +const moduleCountPath = path.join(tempRoot, 'module-counts.json'); + +const createContext = () => buildGoplsWorkspaceContext({ + root, + tempRoot, + providerId: 'lsp-gopls-partition-local-negative-cache', + serverId: 'gopls-partition-local-negative-cache', + probePath: selectiveProbePath, + probeArgs: [moduleCountPath], + cache: { + enabled: true, + dir: toolingCacheDir + } +}); + +const createInputs = ({ service, suffix }) => buildGoplsWorkspaceInputs({ + scenario: 'gopls-partition-local-negative-cache', + docText: goplsSampleDocText, + partitions: [{ service, suffix }] +}); + +const readCounts = async () => { + try { + return JSON.parse(await fs.readFile(moduleCountPath, 'utf8')); + } catch { + return {}; + } +}; + +const badFirst = await runToolingProviders(createContext(), createInputs({ + service: 'svc-bad', + suffix: 'bad-a' +})); +assert.equal(badFirst.metrics?.preflights?.cached || 0, 0, 'expected first blocked partition run to be uncached'); +assert.equal( + badFirst.diagnostics?.['lsp-gopls-partition-local-negative-cache']?.preflight?.reasonCode, + 'go_workspace_blocked_workspace_shape', + 'expected blocked partition reason code to be preserved' +); +assert.deepEqual( + await readCounts(), + { 'svc-bad': 1 }, + 'expected blocked partition probe to run once for svc-bad only' +); + +const okSecond = await runToolingProviders(createContext(), createInputs({ + service: 'svc-ok', + suffix: 'ok-a' +})); +assert.equal( + okSecond.byChunkUid.size, + 1, + 'expected healthy partition to contribute after unrelated negative cache entry' +); +assert.equal( + okSecond.diagnostics?.['lsp-gopls-partition-local-negative-cache']?.preflight?.cached || false, + false, + 'expected healthy partition run not to reuse unrelated blocked cache entry' +); +assert.deepEqual( + await readCounts(), + { 'svc-bad': 1, 'svc-ok': 1 }, + 'expected healthy partition probe to run independently of blocked partition cache' +); + +const badThird = await runToolingProviders(createContext(), createInputs({ + service: 'svc-bad', + suffix: 'bad-b' +})); +assert.equal( + badThird.diagnostics?.['lsp-gopls-partition-local-negative-cache']?.preflight?.cached, + true, + 'expected same blocked partition to reuse its cached negative result' +); +assert.deepEqual( + await readCounts(), + { 'svc-bad': 1, 'svc-ok': 1 }, + 'expected cached blocked partition rerun to avoid repeating the doomed probe' +); + +console.log('configured LSP gopls partition-local negative cache test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-preset-profile.test.js b/tests/tooling/lsp/configured-provider-gopls-preset-profile.test.js new file mode 100644 index 000000000..241173de2 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-preset-profile.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'configured-lsp-gopls-preset-profile'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; + const docText = 'int add(int a, int b) { return a + b; }\n'; + const chunkUid = 'ck64:v1:test:src/sample.cpp:gopls-preset-profile'; + const fixtureGoplsCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'gopls.cmd' : 'gopls' + ); + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + lsp: { + enabled: true, + servers: [{ + preset: 'gopls', + cmd: fixtureGoplsCmd, + languages: ['cpp'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-stub' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_gopls_preset_profile', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'cpp' + }], + kinds: ['types'] + }); + + assert.ok(result.byChunkUid instanceof Map, 'expected tooling map output'); + assert.equal(result.byChunkUid.has(chunkUid), true, 'expected configured gopls preset provider to enrich target'); + const providerDiag = result.diagnostics?.['lsp-gopls'] || null; + assert.ok(providerDiag && providerDiag.runtime, 'expected runtime diagnostics for configured gopls preset provider'); + + console.log('configured LSP gopls preset profile test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/lsp/configured-provider-gopls-profile.test.js b/tests/tooling/lsp/configured-provider-gopls-profile.test.js new file mode 100644 index 000000000..00b78645c --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-profile.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'configured-lsp-gopls-profile'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); + +try { + const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; + const docText = 'int add(int a, int b) { return a + b; }\n'; + const chunkUid = 'ck64:v1:test:src/sample.cpp:gopls-profile'; + const fixtureGoplsCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'gopls.cmd' : 'gopls' + ); + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + timeoutMs: 47000, + maxRetries: 4, + circuitBreakerThreshold: 6, + lifecycle: { + lifecycleRestartWindowMs: 65000 + }, + lsp: { + enabled: true, + lifecycle: { + lifecycleMaxRestartsPerWindow: 7, + lifecycleFdPressureBackoffMs: 900 + }, + servers: [{ + id: 'gopls', + cmd: fixtureGoplsCmd, + args: [], + languages: ['cpp'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-stub' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_gopls_profile', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'cpp' + }], + kinds: ['types'] + }); + + assert.ok(result.byChunkUid instanceof Map, 'expected tooling map output'); + assert.equal(result.byChunkUid.has(chunkUid), true, 'expected configured gopls provider to enrich target'); + const providerDiag = result.diagnostics?.['lsp-gopls'] || null; + assert.ok(providerDiag && providerDiag.runtime, 'expected runtime diagnostics for configured gopls provider'); + assert.equal(providerDiag.runtime?.guard?.breakerThreshold, 6, 'expected global breaker threshold'); + assert.equal(providerDiag.runtime?.lifecycle?.restartWindowMs, 65000, 'expected top-level lifecycle restart window'); + assert.equal(providerDiag.runtime?.lifecycle?.maxRestartsPerWindow, 7, 'expected lsp-scope lifecycle max restarts'); + assert.equal(providerDiag.runtime?.lifecycle?.fdPressureBackoffMs, 900, 'expected lsp-scope fd backoff'); + + console.log('configured LSP gopls profile test passed'); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/lsp/configured-provider-gopls-workspace-diagnostics.test.js b/tests/tooling/lsp/configured-provider-gopls-workspace-diagnostics.test.js new file mode 100644 index 000000000..4e6d30ec5 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-workspace-diagnostics.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-workspace-diags-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/sample.go:gopls-workspace-diagnostics'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-gopls'], + lsp: { + enabled: true, + servers: [{ + id: 'gopls', + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.go#seg:gopls-workspace-diags.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-workspace-diags' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_gopls_workspace_diags', + file: 'src/sample.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.go#seg:gopls-workspace-diags.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}); + +const checks = result.diagnostics?.['lsp-gopls']?.checks || []; +const fidelity = result.diagnostics?.['lsp-gopls']?.fidelity || null; +assert.ok(Array.isArray(checks), 'expected diagnostics checks for configured gopls provider'); +assert.equal( + checks.some((check) => check?.name === 'go_workspace_missing_root_fail_open'), + true, + 'expected fail-open missing-root warning when no Go workspace markers exist' +); +assert.equal( + checks.some((check) => check?.name === 'gopls_workspace_model_missing'), + true, + 'expected generic workspace-model-missing warning to remain visible' +); +assert.equal( + fidelity?.state, + 'degraded', + 'expected gopls missing-root repos to fail open as degraded rather than blocked' +); + +console.log('configured LSP gopls workspace diagnostics test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-workspace-partial-coverage.test.js b/tests/tooling/lsp/configured-provider-gopls-workspace-partial-coverage.test.js new file mode 100644 index 000000000..c48952bf2 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-workspace-partial-coverage.test.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + buildGoplsWorkspaceContext, + buildGoplsWorkspaceInputs, + goplsSampleDocText +} from './helpers/gopls-workspace-case.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-workspace-partial-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-ok', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'svc-bad', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'svc-ok', 'go.mod'), 'module example.com/svc-ok\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'svc-bad', 'go.mod'), 'module example.com/svc-bad\n\ngo 1.22\n', 'utf8'); + +const selectiveProbePath = path.join(tempRoot, 'go-probe-selective.js'); +await fs.writeFile( + selectiveProbePath, + [ + "import path from 'node:path';", + "const cwd = process.cwd();", + "if (path.basename(cwd) === 'svc-bad') {", + " process.stderr.write('forced blocked workspace partition\\n');", + ' process.exit(19);', + '}', + "process.stdout.write('ok\\n');" + ].join('\n'), + 'utf8' +); + +const inputs = buildGoplsWorkspaceInputs({ + scenario: 'gopls-workspace-partial', + docText: goplsSampleDocText, + partitions: [ + { key: 'ok', service: 'svc-ok', suffix: 'ok' }, + { key: 'bad', service: 'svc-bad', suffix: 'bad' } + ] +}); + +const result = await runToolingProviders( + buildGoplsWorkspaceContext({ root, tempRoot, probePath: selectiveProbePath }), + inputs +); + +assert.equal(result.byChunkUid.has(inputs.chunkUids.ok), true, 'expected healthy gopls partition to contribute'); +assert.equal(result.byChunkUid.has(inputs.chunkUids.bad), false, 'expected blocked gopls partition to be isolated'); +const diagnostics = result.diagnostics?.['lsp-gopls'] || {}; +assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected mixed partition preflight degraded state'); +assert.equal(diagnostics?.fidelity?.state, 'degraded', 'expected fidelity contract to classify partial workspace coverage as degraded'); +assert.equal(diagnostics?.fidelity?.qualityDelta?.partialSuccess, true, 'expected mixed partition coverage to report partial success'); +assert.equal(diagnostics?.fidelity?.semanticCoverage?.state, 'partial', 'expected mixed partition coverage semantic state'); +assert.equal(diagnostics?.fidelity?.requestSuppression?.active, true, 'expected partial workspace request suppression to be explicit'); +assert.equal(diagnostics?.fidelity?.blockedPartitions?.count, 1, 'expected blocked partition count to be surfaced'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.totalPartitions, 2, 'expected total partition count to be surfaced'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.readyPartitionCount, 1, 'expected ready partition count to be surfaced'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.blockedPartitionCount, 1, 'expected blocked partition count in workspace coverage'); +assert.equal(diagnostics?.fidelity?.contributes?.typeEnrichment, true, 'expected healthy partitions to remain contributory'); +assert.equal( + Array.isArray(diagnostics?.fidelity?.runtimeIssues) + && diagnostics.fidelity.runtimeIssues.includes('partial_workspace_coverage') + && diagnostics.fidelity.runtimeIssues.includes('blocked_workspace_partitions'), + true, + 'expected fidelity contract to expose shared workspace-partition runtime issue classes' +); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'go_workspace_partial_repo_coverage', + 'expected partial coverage reason code for mixed healthy and blocked partitions' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'go_workspace_partial_repo_coverage'), + true, + 'expected partial coverage check for mixed partition repo' +); +assert.equal( + checks.some((check) => check?.name === 'lsp-gopls_workspace_partition_blocked'), + true, + 'expected runtime blocked partition check' +); +assert.equal( + checks.some((check) => check?.name === 'lsp-gopls_workspace_partition_partial_success'), + true, + 'expected runtime partial-success check for mixed partition repo' +); +assert.equal( + Array.isArray(result.degradedProviders) + && result.degradedProviders.some((entry) => entry?.providerId === 'lsp-gopls' && entry?.partialSuccess === true), + true, + 'expected partial-success gopls run to remain visible in degraded provider rollups' +); +assert.equal(result.metrics?.degradedProviderCount, 1, 'expected degraded provider metrics to include partial-success gopls'); +assert.equal( + Array.isArray(result.observations) + && result.observations.some((entry) => entry?.code === 'tooling_provider_degraded_mode' && entry?.context?.providerId === 'lsp-gopls'), + true, + 'expected degraded provider observation for partial-success gopls coverage loss' +); + +console.log('configured LSP gopls workspace partial coverage test passed'); diff --git a/tests/tooling/lsp/configured-provider-gopls-workspace-unmatched-coverage.test.js b/tests/tooling/lsp/configured-provider-gopls-workspace-unmatched-coverage.test.js new file mode 100644 index 000000000..acdd3ef17 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-gopls-workspace-unmatched-coverage.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + buildGoplsWorkspaceContext, + buildGoplsWorkspaceInputs, + goplsSampleDocText +} from './helpers/gopls-workspace-case.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-gopls-workspace-unmatched-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-ok', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'rogue', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'svc-ok', 'go.mod'), 'module example.com/svc-ok\n\ngo 1.22\n', 'utf8'); + +const okProbePath = path.join(tempRoot, 'go-probe-ok.js'); +await fs.writeFile(okProbePath, "process.stdout.write('ok\\n');\n", 'utf8'); + +const inputs = buildGoplsWorkspaceInputs({ + scenario: 'gopls-workspace-unmatched', + docText: goplsSampleDocText, + partitions: [ + { key: 'ok', service: 'svc-ok', suffix: 'ok' }, + { key: 'rogue', service: 'rogue', suffix: 'rogue' } + ] +}); + +const result = await runToolingProviders( + buildGoplsWorkspaceContext({ root, tempRoot, probePath: okProbePath }), + inputs +); + +assert.equal(result.byChunkUid.has(inputs.chunkUids.ok), true, 'expected matched gopls partition to contribute'); +assert.equal(result.byChunkUid.has(inputs.chunkUids.rogue), false, 'expected unmatched Go workspace target to remain excluded'); + +const diagnostics = result.diagnostics?.['lsp-gopls'] || {}; +assert.equal(diagnostics?.fidelity?.state, 'degraded', 'expected unmatched mixed coverage to be degraded'); +assert.equal(diagnostics?.fidelity?.qualityDelta?.partialSuccess, true, 'expected matched partition to preserve partial success'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.readyPartitionCount, 1, 'expected one ready partition'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.blockedPartitionCount, 0, 'expected no blocked partitions'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.unmatchedDocumentCount, 1, 'expected unmatched document count'); +assert.equal(diagnostics?.fidelity?.workspaceCoverage?.unmatchedTargetCount, 1, 'expected unmatched target count'); +assert.equal( + Array.isArray(diagnostics?.fidelity?.runtimeIssues) + && diagnostics.fidelity.runtimeIssues.includes('partial_workspace_coverage') + && diagnostics.fidelity.runtimeIssues.includes('unmatched_workspace_documents') + && diagnostics.fidelity.runtimeIssues.includes('unmatched_workspace_targets'), + true, + 'expected fidelity runtime issues to expose unmatched workspace coverage loss' +); + +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'lsp-gopls_workspace_partition_incomplete'), + true, + 'expected explicit unmatched workspace partition check' +); + +console.log('configured LSP gopls workspace unmatched coverage test passed'); diff --git a/tests/tooling/lsp/configured-provider-lua-broken-layout-preflight.test.js b/tests/tooling/lsp/configured-provider-lua-broken-layout-preflight.test.js new file mode 100644 index 000000000..683a418dc --- /dev/null +++ b/tests/tooling/lsp/configured-provider-lua-broken-layout-preflight.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-provider-lua-broken-layout-${process.pid}-${Date.now()}`); +const toolingRoot = path.join(tempRoot, 'tooling-root'); +const binDir = path.join(toolingRoot, 'bin'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(binDir, { recursive: true }); + +if (process.platform === 'win32') { + await fs.writeFile( + path.join(binDir, 'lua-language-server.cmd'), + '@echo off\r\nif "%1"=="-v" exit /b 0\r\nif "%1"=="--version" exit /b 0\r\nexit /b 0\r\n', + 'utf8' + ); +} else { + await fs.writeFile(path.join(binDir, 'lua-language-server'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); +} + +try { + await withTemporaryEnv({ PATH: path.dirname(process.execPath), Path: path.dirname(process.execPath) }, async () => { + const docText = 'local function add(a, b) return a + b end\n'; + const chunkUid = 'ck64:v1:test:src/sample.lua:lua-broken-layout'; + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + dir: toolingRoot, + lsp: { + enabled: true, + servers: [{ + preset: 'lua-language-server', + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-broken-layout', + text: docText, + languageId: 'lua', + effectiveExt: '.lua', + docHash: 'hash-lua-broken-layout' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_lua_broken_layout', + file: 'src/sample.lua', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-broken-layout', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'lua' + }], + kinds: ['types'] + }); + + assert.equal(result.byChunkUid.has(chunkUid), false, 'expected broken managed Lua layout to block runtime execution'); + const diagnostics = result.diagnostics?.['lsp-lua-language-server'] || null; + assert.ok(diagnostics, 'expected Lua provider diagnostics'); + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.fidelity?.state, 'blocked'); + assert.equal(diagnostics?.fidelity?.reasonCode, 'preflight_command_invalid_layout'); + assert.equal( + Array.isArray(diagnostics?.checks) + && diagnostics.checks.some((check) => check?.name === 'lsp_command_unavailable'), + true, + 'expected invalid layout to surface through the command preflight check path' + ); + assert.match( + String(diagnostics?.checks?.find((check) => check?.name === 'lsp_command_unavailable')?.message || ''), + /missing runtime entry/u + ); + }); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('configured provider lua broken layout preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-lua-hover-timeout-quarantine.test.js b/tests/tooling/lsp/configured-provider-lua-hover-timeout-quarantine.test.js new file mode 100644 index 000000000..e138b76ac --- /dev/null +++ b/tests/tooling/lsp/configured-provider-lua-hover-timeout-quarantine.test.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-lua-hover-timeout-${process.pid}-${Date.now()}`); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'function greet(name)\n return name\nend\n'; +const chunkUid = 'ck64:v1:test:src/sample.lua:lua-hover-timeout'; + +const runProvider = () => runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-lua-hover-timeout'], + lsp: { + enabled: true, + servers: [{ + id: 'lua-hover-timeout', + preset: 'lua-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'lua-hover-timeout'], + languages: ['lua'], + uriScheme: 'poc-vfs', + timeoutMs: 500, + hoverTimeoutMs: 150, + retries: 0, + breakerThreshold: 1 + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-hover-timeout.txt', + text: docText, + languageId: 'lua', + effectiveExt: '.lua', + docHash: 'hash-lua-hover-timeout' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_lua_hover_timeout', + file: 'src/sample.lua', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-hover-timeout.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'lua' + }], + kinds: ['types'] +}); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + __testLspSessionPool.setQuarantineDurations({ shortMs: 120, extendedMs: 600 }); + + let escalated = null; + for (let attempt = 0; attempt < 4; attempt += 1) { + const current = await runProvider(); + const diagnostics = current.diagnostics?.['lsp-lua-hover-timeout'] || {}; + const timedOut = Array.isArray(diagnostics.checks) + && diagnostics.checks.some((check) => check?.name === 'tooling_hover_timeout'); + const quarantinedNow = Array.isArray(diagnostics.checks) + && diagnostics.checks.some((check) => check?.name === 'tooling_provider_quarantined'); + + assert.equal( + timedOut || quarantinedNow, + true, + `expected hover timeout degradation or quarantine on attempt ${attempt + 1}` + ); + + if (diagnostics.runtime?.lifecycle?.quarantine?.level === 'extended') { + escalated = current; + break; + } + } + + assert.ok(escalated, 'expected repeated Lua hover timeouts to escalate into extended quarantine'); + + const firstHit = escalated.byChunkUid.get(chunkUid); + if (firstHit) { + assert.equal(firstHit.payload?.paramTypes?.name?.[0]?.type, 'string', 'expected documentSymbol payload to survive hover timeout before quarantine'); + } + + const quarantined = await runProvider(); + const diagnostics = quarantined.diagnostics?.['lsp-lua-hover-timeout'] || {}; + assert.equal( + Array.isArray(diagnostics.checks) + && diagnostics.checks.some((check) => check?.name === 'tooling_provider_quarantined'), + true, + 'expected active Lua quarantine to fail open with explicit warning' + ); + assert.equal( + diagnostics.runtime?.lifecycle?.quarantine?.level, + 'extended', + 'expected Lua quarantine summary to retain extended level' + ); + assert.equal( + diagnostics.runtime?.requests?.byMethod?.['textDocument/hover']?.requests ?? 0, + 0, + 'expected active Lua quarantine to avoid replaying hover requests' + ); + + console.log('configured LSP lua hover timeout quarantine test passed'); +} finally { + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/configured-provider-lua-workspace-config-invalid-preflight.test.js b/tests/tooling/lsp/configured-provider-lua-workspace-config-invalid-preflight.test.js new file mode 100644 index 000000000..f6b22fb73 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-lua-workspace-config-invalid-preflight.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-lua-workspace-config-invalid-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +await fs.writeFile(path.join(tempRoot, '.luarc.json'), '{"Lua": {"runtime": ', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'local function greet(name) return name end\n'; +const chunkUid = 'ck64:v1:test:src/sample.lua:lua-workspace-config-invalid-preflight'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-lua-workspace-config-invalid'], + lsp: { + enabled: true, + servers: [{ + id: 'lua-workspace-config-invalid', + preset: 'lua-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'lua'], + languages: ['lua'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-config-invalid-preflight.txt', + text: docText, + languageId: 'lua', + effectiveExt: '.lua', + docHash: 'hash-lua-workspace-config-invalid-preflight' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_lua_workspace_config_invalid_preflight', + file: 'src/sample.lua', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-config-invalid-preflight.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'lua' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), true, 'expected configured lua provider to continue when .luarc.json is invalid'); +const diagnostics = result.diagnostics?.['lsp-lua-workspace-config-invalid'] || {}; +assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected configured lua preflight degraded state'); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'lua_workspace_config_invalid', + 'expected lua workspace config invalid reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'lua_workspace_config_invalid'), + true, + 'expected lua workspace config invalid warning check' +); + +console.log('configured LSP lua workspace config invalid preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-lua-workspace-library-missing-path-preflight.test.js b/tests/tooling/lsp/configured-provider-lua-workspace-library-missing-path-preflight.test.js new file mode 100644 index 000000000..dcf6471d4 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-lua-workspace-library-missing-path-preflight.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-lua-workspace-library-missing-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'function greet(name)\n return name\nend\n'; +const chunkUid = 'ck64:v1:test:src/sample.lua:lua-workspace-library-missing'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-lua-workspace-library-missing'], + lsp: { + enabled: true, + servers: [{ + id: 'lua-workspace-library-missing', + preset: 'lua-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'lua-requires-workspace-library'], + languages: ['lua'], + luaWorkspaceLibrary: ['deps/does-not-exist'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-library-missing.txt', + text: docText, + languageId: 'lua', + effectiveExt: '.lua', + docHash: 'hash-lua-workspace-library-missing' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_lua_workspace_library_missing', + file: 'src/sample.lua', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-library-missing.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'lua' + }], + kinds: ['types'] +}); + +const hit = result.byChunkUid.get(chunkUid); +assert.ok(hit, 'expected configured lua provider to continue enrichment when workspace library path is missing'); +assert.equal(hit.payload?.returnType, 'string', 'expected Lua return type from stub signature'); + +const checks = Array.isArray(result.diagnostics?.['lsp-lua-workspace-library-missing']?.checks) + ? result.diagnostics['lsp-lua-workspace-library-missing'].checks + : []; +assert.equal( + checks.some((check) => check?.name === 'lua_workspace_library_missing'), + true, + 'expected lua workspace library missing preflight warning check' +); + +console.log('configured LSP lua workspace library missing-path preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-lua-workspace-library.test.js b/tests/tooling/lsp/configured-provider-lua-workspace-library.test.js new file mode 100644 index 000000000..4be9125ea --- /dev/null +++ b/tests/tooling/lsp/configured-provider-lua-workspace-library.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-lua-workspace-library-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'deps', 'lua'), { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'function greet(name)\n return name\nend\n'; +const chunkUid = 'ck64:v1:test:src/sample.lua:lua-workspace-library'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-lua-workspace-library'], + lsp: { + enabled: true, + servers: [{ + id: 'lua-workspace-library', + preset: 'lua-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'lua-requires-workspace-library'], + languages: ['lua'], + luaWorkspaceLibrary: ['deps/lua'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-library.txt', + text: docText, + languageId: 'lua', + effectiveExt: '.lua', + docHash: 'hash-lua-workspace-library' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_lua_workspace_library', + file: 'src/sample.lua', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.lua#seg:lua-workspace-library.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'lua' + }], + kinds: ['types'] +}); + +const hit = result.byChunkUid.get(chunkUid); +assert.ok(hit, 'expected configured lua provider to enrich when workspace library settings are supplied'); +assert.equal(hit.payload?.returnType, 'string', 'expected Lua return type from stub signature'); +assert.equal(hit.payload?.paramTypes?.name?.[0]?.type, 'string', 'expected Lua param type from stub signature'); + +console.log('configured LSP lua workspace library test passed'); diff --git a/tests/tooling/lsp/configured-provider-normalize-merge.test.js b/tests/tooling/lsp/configured-provider-normalize-merge.test.js new file mode 100644 index 000000000..533eb378e --- /dev/null +++ b/tests/tooling/lsp/configured-provider-normalize-merge.test.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { normalizeServerConfig } from '../../../src/index/tooling/lsp-provider/index.js'; + +const normalized = normalizeServerConfig({ + preset: 'yaml-language-server', + id: 'yaml-custom', + initializationOptions: { + settings: { + yaml: { + schemas: { + 'https://example.invalid/custom.json': '.github/workflows/*.yaml' + } + } + } + }, + timeoutMs: 5000, + definition: false +}, 0); + +assert.ok(normalized, 'expected configured server to normalize'); +assert.equal(normalized.id, 'yaml-custom', 'expected explicit id to win over preset id'); +assert.equal(normalized.cmd, 'yaml-language-server', 'expected preset command to be retained'); +assert.equal(normalized.timeoutMs, 5000, 'expected explicit timeout to override preset'); +assert.equal(normalized.definitionEnabled, false, 'expected explicit definition toggle to survive normalization'); +assert.equal( + normalized.initializationOptions?.settings?.yaml?.schemas?.['https://example.invalid/custom.json'], + '.github/workflows/*.yaml', + 'expected explicit initializationOptions to deep-merge over preset defaults' +); + +console.log('configured provider normalize and merge test passed'); diff --git a/tests/tooling/lsp/configured-provider-preflight-matrix.test.js b/tests/tooling/lsp/configured-provider-preflight-matrix.test.js new file mode 100644 index 000000000..d74909dec --- /dev/null +++ b/tests/tooling/lsp/configured-provider-preflight-matrix.test.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; + +const createTarget = ({ docId, chunkUid, file, virtualPath }) => ({ + chunkRef: { + docId, + chunkUid, + chunkId: `chunk_${chunkUid.replace(/[^a-z0-9]+/gi, '_')}`, + file, + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' +}); + +const writeGoProbe = async (tempRoot, name, source) => { + const scriptPath = path.join(tempRoot, name); + await fs.writeFile(scriptPath, source, 'utf8'); + return scriptPath; +}; + +const cases = [ + { + name: 'gopls routes ambiguous multi-root workspaces by partition', + cacheName: 'configured-lsp-gopls-workspace-root-ambiguous', + async setup(tempRoot) { + await fs.mkdir(path.join(tempRoot, 'svc-a'), { recursive: true }); + await fs.mkdir(path.join(tempRoot, 'svc-b'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'svc-a', 'go.mod'), 'module example.com/svc-a\n\ngo 1.22\n', 'utf8'); + await fs.writeFile(path.join(tempRoot, 'svc-b', 'go.mod'), 'module example.com/svc-b\n\ngo 1.22\n', 'utf8'); + const documents = [ + { + virtualPath: '.poc-vfs/svc-a/src/sample.go#seg:gopls-workspace-root-ambiguous-a.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-workspace-root-ambiguous-a' + }, + { + virtualPath: '.poc-vfs/svc-b/src/sample.go#seg:gopls-workspace-root-ambiguous-b.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-workspace-root-ambiguous-b' + } + ]; + return { + toolId: 'lsp-gopls', + serverId: 'gopls', + serverConfig: { + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [] + }, + request: { + documents, + targets: [ + createTarget({ + docId: 0, + chunkUid: 'ck64:v1:test:svc-a/src/sample.go:gopls-workspace-root-ambiguous:a', + file: 'svc-a/src/sample.go', + virtualPath: documents[0].virtualPath + }), + createTarget({ + docId: 1, + chunkUid: 'ck64:v1:test:svc-b/src/sample.go:gopls-workspace-root-ambiguous:b', + file: 'svc-b/src/sample.go', + virtualPath: documents[1].virtualPath + }) + ], + kinds: ['types'] + }, + assertResult(result, diagnostics) { + assert.equal(result.byChunkUid.has('ck64:v1:test:svc-a/src/sample.go:gopls-workspace-root-ambiguous:a'), true); + assert.equal(result.byChunkUid.has('ck64:v1:test:svc-b/src/sample.go:gopls-workspace-root-ambiguous:b'), true); + assert.equal(diagnostics?.preflight?.state, 'ready'); + assert.equal(diagnostics?.workspaceModel?.partitionCount, 2); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal(checks.some((check) => check?.name === 'go_workspace_module_root_partitioned'), true); + assert.equal(checks.some((check) => check?.name === 'lsp-gopls_workspace_partition_multi_root'), true); + } + }; + } + }, + { + name: 'gopls accepts nested module roots when selected docs resolve cleanly', + cacheName: 'configured-lsp-gopls-workspace-root-nested', + async setup(tempRoot) { + await fs.mkdir(path.join(tempRoot, 'svc', 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'svc', 'go.mod'), 'module example.com/svc\n\ngo 1.22\n', 'utf8'); + const goProbeOkScriptPath = await writeGoProbe( + tempRoot, + 'go-probe-ok.js', + "process.stdout.write('example.com/svc\\n');\n" + ); + const virtualPath = '.poc-vfs/svc/src/sample.go#seg:gopls-workspace-root-nested.txt'; + return { + toolId: 'lsp-gopls', + serverId: 'gopls', + serverConfig: { + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [goProbeOkScriptPath], + goWorkspaceWarmup: false + }, + request: { + documents: [{ + virtualPath, + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-gopls-workspace-root-nested' + }], + targets: [createTarget({ + docId: 0, + chunkUid: 'ck64:v1:test:svc/src/sample.go:gopls-workspace-root-nested', + file: 'svc/src/sample.go', + virtualPath + })], + kinds: ['types'] + }, + assertResult(result, diagnostics) { + assert.equal(result.byChunkUid.has('ck64:v1:test:svc/src/sample.go:gopls-workspace-root-nested'), true); + assert.equal(diagnostics?.preflight?.state, 'ready'); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal(checks.some((check) => check?.name === 'go_workspace_module_root_nested'), false); + } + }; + } + }, + { + name: 'workspace-module probe failures block the provider with a typed diagnostic', + cacheName: 'configured-lsp-go-workspace-module-failed', + async setup(tempRoot) { + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); + const goProbeFailScriptPath = await writeGoProbe( + tempRoot, + 'go-probe-fail.js', + 'process.stderr.write("forced go workspace module probe failure\\n"); process.exit(17);\n' + ); + const virtualPath = '.poc-vfs/src/main.go#seg:go-workspace-module-preflight-failed.txt'; + return { + toolId: 'lsp-go-workspace-module-preflight', + serverId: 'go-workspace-module-preflight', + serverConfig: { + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [goProbeFailScriptPath] + }, + request: { + documents: [{ + virtualPath, + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-module-preflight-failed' + }], + targets: [createTarget({ + docId: 0, + chunkUid: 'ck64:v1:test:src/main.go:go-workspace-module-preflight-failed', + file: 'src/main.go', + virtualPath + })], + kinds: ['types'] + }, + assertResult(result, diagnostics) { + assert.equal(result.byChunkUid.has('ck64:v1:test:src/main.go:go-workspace-module-preflight-failed'), false); + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.preflight?.reasonCode, 'go_workspace_blocked_workspace_shape'); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal(checks.some((check) => String(check?.name || '') === 'go_workspace_module_probe_failed'), true); + } + }; + } + }, + { + name: 'workspace-module probe timeouts block fidelity and emit timeout diagnostics', + cacheName: 'configured-lsp-go-workspace-module-timeout', + async setup(tempRoot) { + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/preflight\n\ngo 1.21\n', 'utf8'); + const goProbeHangScriptPath = await writeGoProbe( + tempRoot, + 'go-probe-timeout.js', + 'setTimeout(() => process.exit(0), 5000);\n' + ); + const virtualPath = '.poc-vfs/src/main.go#seg:go-workspace-module-preflight-timeout.txt'; + return { + toolId: 'lsp-go-workspace-module-preflight-timeout', + serverId: 'go-workspace-module-preflight-timeout', + serverConfig: { + preset: 'gopls', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [goProbeHangScriptPath], + goWorkspaceModuleTimeoutMs: 500 + }, + request: { + documents: [{ + virtualPath, + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-go-workspace-module-preflight-timeout' + }], + targets: [createTarget({ + docId: 0, + chunkUid: 'ck64:v1:test:src/main.go:go-workspace-module-preflight-timeout', + file: 'src/main.go', + virtualPath + })], + kinds: ['types'] + }, + assertResult(result, diagnostics) { + assert.equal(result.byChunkUid.has('ck64:v1:test:src/main.go:go-workspace-module-preflight-timeout'), false); + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.fidelity?.state, 'blocked'); + assert.equal(diagnostics?.fidelity?.contributes?.typeEnrichment, false); + assert.equal(diagnostics?.fidelity?.qualityDelta?.partialSuccess, false); + assert.equal(diagnostics?.preflight?.reasonCode, 'go_workspace_blocked_workspace_shape'); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal(checks.some((check) => String(check?.name || '') === 'go_workspace_module_probe_timeout'), true); + } + }; + } + } +]; + +await withLspTestPath({ repoRoot: root }, async () => { + for (const [index, entry] of cases.entries()) { + const tempRoot = resolveTestCachePath(root, `${entry.cacheName}-${process.pid}-${Date.now()}-${index}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + const setup = await entry.setup(tempRoot); + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: [setup.toolId], + lsp: { + enabled: true, + servers: [{ + id: setup.serverId, + ...setup.serverConfig + }] + } + }, + cache: { + enabled: false + } + }, setup.request); + const diagnostics = result.diagnostics?.[setup.toolId] || {}; + setup.assertResult(result, diagnostics); + } +}); + +console.log('configured provider preflight matrix test passed'); diff --git a/tests/tooling/lsp/configured-provider-presets.test.js b/tests/tooling/lsp/configured-provider-presets.test.js new file mode 100644 index 000000000..3b02b875e --- /dev/null +++ b/tests/tooling/lsp/configured-provider-presets.test.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createConfiguredLspProviders } from '../../../src/index/tooling/lsp-provider.js'; + +const providers = createConfiguredLspProviders({ + lsp: { + enabled: true, + servers: [ + { preset: 'gopls' }, + { preset: 'yaml', id: 'yaml-fast', timeoutMs: 5000 }, + { id: 'rust' } + ] + } +}); + +assert.equal(providers.length, 3, 'expected preset entries to compile into providers'); + +const gopls = providers.find((provider) => provider.id === 'lsp-gopls'); +assert.ok(gopls, 'expected gopls preset provider'); +assert.equal(gopls.requires?.cmd, 'gopls', 'expected gopls command'); +assert.deepEqual(gopls.languages, ['go'], 'expected gopls language default'); + +const yamlFast = providers.find((provider) => provider.id === 'lsp-yaml-fast'); +assert.ok(yamlFast, 'expected yaml preset provider with custom id'); +assert.equal(yamlFast.requires?.cmd, 'yaml-language-server', 'expected yaml command default'); +assert.deepEqual(yamlFast.languages, ['yaml', 'yml'], 'expected yaml language defaults'); +assert.deepEqual(yamlFast.kinds, ['diagnostics'], 'expected yaml preset to default to diagnostics-only routing'); + +const rust = providers.find((provider) => provider.id === 'lsp-rust'); +assert.ok(rust, 'expected implicit rust preset provider'); +assert.equal(rust.requires?.cmd, 'rust-analyzer', 'expected rust-analyzer command default'); +assert.deepEqual(rust.languages, ['rust'], 'expected rust language default'); +assert.ok(gopls.preflightId?.includes('workspace-model'), 'expected gopls preset to retain workspace-model preflight'); + +const autoProviders = createConfiguredLspProviders({ + autoEnableOnDetect: true, + lsp: { + enabled: true, + servers: [] + } +}); +const autoIds = new Set(autoProviders.map((provider) => provider.id)); +for (const providerId of ['gopls', 'rust-analyzer', 'yaml-language-server', 'lua-language-server', 'zls']) { + assert.equal(autoIds.has(providerId), true, `expected auto preset provider ${providerId}`); +} + +console.log('configured LSP preset provider test passed'); diff --git a/tests/tooling/lsp/configured-provider-probe-fallback.test.js b/tests/tooling/lsp/configured-provider-probe-fallback.test.js new file mode 100644 index 000000000..9b084b8a5 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-probe-fallback.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { __resetToolingCommandProbeCacheForTests } from '../../../src/index/tooling/command-resolver.js'; +import { resolveLspFixtureCommand } from '../../helpers/lsp-provider-fixture.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; +import { rmDirRecursive } from '../../helpers/temp.js'; + +const root = process.cwd(); +const { dir: tempRoot } = await prepareIsolatedTestCacheDir('configured-provider-probe-fallback', { root }); +const fixtureCommand = resolveLspFixtureCommand('probe-fail-lsp', { repoRoot: root }); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; + +try { + __resetToolingCommandProbeCacheForTests(); + await fs.mkdir(tempRoot, { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'go.mod'), 'module example.com/probefallback\n\ngo 1.22\n', 'utf8'); + + const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-gopls'], + cache: { + enabled: false + }, + lsp: { + enabled: true, + servers: [{ + id: 'gopls', + preset: 'gopls', + cmd: fixtureCommand, + args: ['--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + requireWorkspaceModel: false + }] + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.go#seg:configured-provider-probe-fallback.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-configured-provider-probe-fallback' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.go:configured-provider-probe-fallback', + chunkId: 'chunk_configured_provider_probe_fallback', + file: 'src/sample.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.go#seg:configured-provider-probe-fallback.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] + }); + + const checks = result.diagnostics?.['lsp-gopls']?.checks || []; + assert.equal( + checks.some((check) => check?.name === 'tooling_initialize_failed'), + false, + 'expected configured provider initialization to proceed despite probe failure' + ); + assert.equal( + checks.some((check) => check?.name === 'lsp_command_unavailable'), + true, + 'expected probe failure warning check' + ); + assert.equal( + Number(result.metrics?.requests?.requests || 0) > 0, + true, + 'expected LSP request activity after probe failure' + ); + assert.equal( + Number(result.metrics?.providersContributed || 0) > 0, + true, + 'expected provider to contribute runtime work after probe failure' + ); + + console.log('configured provider probe fallback test passed'); +} finally { + __resetToolingCommandProbeCacheForTests(); + await rmDirRecursive(tempRoot, { retries: 8, delayMs: 150 }); +} diff --git a/tests/tooling/lsp/configured-provider-runtime-requirement-preflight-degraded.test.js b/tests/tooling/lsp/configured-provider-runtime-requirement-preflight-degraded.test.js new file mode 100644 index 000000000..52f92d36a --- /dev/null +++ b/tests/tooling/lsp/configured-provider-runtime-requirement-preflight-degraded.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-runtime-req-preflight-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const virtualPath = '.poc-vfs/src/sample.yaml#seg:runtime-req-preflight.txt'; +const docText = 'name: runtime\n'; +const chunkUid = 'ck64:v1:test:src/sample.yaml:runtime-req-preflight'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-runtime-req-preflight'], + lsp: { + enabled: true, + servers: [{ + id: 'runtime-req-preflight', + cmd: process.execPath, + args: [serverPath], + languages: ['yaml'], + preflightRuntimeRequirements: [{ + id: 'missing-runtime', + cmd: 'definitely-missing-runtime-requirement-command', + args: ['--version'], + label: 'Missing Runtime' + }] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath, + text: docText, + languageId: 'yaml', + effectiveExt: '.yaml', + docHash: 'hash-runtime-req-preflight' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_runtime_req_preflight', + file: 'src/sample.yaml', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'yaml' + }], + kinds: ['types'] +}); + +const diagnostics = result.diagnostics?.['lsp-runtime-req-preflight'] || {}; +assert.equal( + diagnostics?.preflight?.state, + 'degraded', + 'expected runtime requirement preflight degraded state' +); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'preflight_runtime_requirement_missing', + 'expected preflight reasonCode for missing runtime requirement' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => String(check?.name || '').includes('_runtime_missing-runtime_missing')), + true, + 'expected runtime requirement missing warning check' +); + +console.log('configured LSP runtime requirement preflight degraded test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-proc-macro-diagnostic-suppression.test.js b/tests/tooling/lsp/configured-provider-rust-proc-macro-diagnostic-suppression.test.js new file mode 100644 index 000000000..8a1508b87 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-proc-macro-diagnostic-suppression.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-proc-macro-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'Cargo.toml'), '[package]\nname = "poc-rust-proc-macro"\nversion = "0.1.0"\nedition = "2021"\n'); +await fs.writeFile(path.join(tempRoot, 'src', 'lib.rs'), 'fn add(a: i32, b: i32) -> i32 { a + b }\n'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/sample.rs:rust-proc-macro-diagnostics'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-analyzer'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust-diagnostics-proc-macro'], + languages: ['rust'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-proc-macro.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-proc-macro' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_proc_macro', + file: 'src/sample.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-proc-macro.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types', 'diagnostics'] +}); + +const diagnostics = result.diagnostics?.['lsp-rust-analyzer']; +assert.ok(diagnostics, 'expected configured rust provider diagnostics payload'); +assert.equal(diagnostics.diagnosticsCount, 1, 'expected non-fatal proc-macro warning to be suppressed'); +const chunkDiagnostics = diagnostics.diagnosticsByChunkUid?.[chunkUid] || []; +assert.equal(chunkDiagnostics.length, 1, 'expected one remaining diagnostic after suppression'); +assert.equal(chunkDiagnostics[0]?.severity, 1, 'expected fatal diagnostic to remain'); +assert.equal( + (diagnostics.checks || []).some((check) => check?.name === 'tooling_rust_proc_macro_diagnostics_suppressed'), + true, + 'expected suppression check in diagnostics payload' +); + +console.log('configured LSP rust proc-macro diagnostic suppression test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-proc-macro-suppression-policy-preflight.test.js b/tests/tooling/lsp/configured-provider-rust-proc-macro-suppression-policy-preflight.test.js new file mode 100644 index 000000000..244b61bd3 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-proc-macro-suppression-policy-preflight.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-proc-macro-policy-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'Cargo.toml'), '[package]\nname = "poc-rust-proc-macro-policy"\nversion = "0.1.0"\nedition = "2021"\n'); +await fs.writeFile(path.join(tempRoot, 'src', 'lib.rs'), 'fn add(a: i32, b: i32) -> i32 { a + b }\n'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/sample.rs:rust-proc-macro-policy-preflight'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-analyzer'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust'], + languages: ['rust'], + uriScheme: 'poc-vfs', + rustSuppressProcMacroDiagnostics: true, + preflightRuntimeRequirements: [] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-proc-macro-policy-preflight.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-proc-macro-policy-preflight' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_proc_macro_policy_preflight', + file: 'src/sample.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-proc-macro-policy-preflight.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), true, 'expected rust provider to continue with proc-macro suppression policy warning'); +const diagnostics = result.diagnostics?.['lsp-rust-analyzer'] || {}; +assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected rust preflight degraded state'); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'rust_workspace_proc_macro_suppression_active', + 'expected rust proc-macro suppression policy reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_proc_macro_suppression_active'), + true, + 'expected rust proc-macro suppression policy warning check' +); + +console.log('configured LSP rust proc-macro suppression policy preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-diagnostics.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-diagnostics.test.js new file mode 100644 index 000000000..bcd6b2b20 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-diagnostics.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-diags-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/sample.rs:rust-workspace-diagnostics'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-analyzer'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-analyzer', + preset: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust'], + languages: ['rust'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-workspace-diags.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-diags' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_workspace_diags', + file: 'src/sample.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.rs#seg:rust-workspace-diags.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const checks = result.diagnostics?.['lsp-rust-analyzer']?.checks || []; +assert.ok(Array.isArray(checks), 'expected diagnostics checks for configured rust-analyzer provider'); +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_model_missing'), + true, + 'expected rust-analyzer to block when Cargo workspace markers are absent near selected docs' +); +assert.equal((result.byChunkUid instanceof Map ? result.byChunkUid.size : Object.keys(result.byChunkUid || {}).length), 0, 'expected rust-analyzer provider to skip output when no workspace applies'); + +console.log('configured LSP rust workspace diagnostics test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-metadata-cache-hit.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-cache-hit.test.js new file mode 100644 index 000000000..f42740cfa --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-cache-hit.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-metadata-cache-hit-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'Cargo.toml'), '[package]\nname = "preflight"\nversion = "0.1.0"\nedition = "2021"\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'src', 'lib.rs'), 'fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); + +const counterScriptPath = path.join(tempRoot, 'cargo-metadata-count.js'); +const metadataCountPath = path.join(tempRoot, 'metadata-count.txt'); +await fs.writeFile( + counterScriptPath, + [ + "import fs from 'node:fs';", + "const countPath = process.argv[2];", + "let next = 1;", + "try {", + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + "} catch {}", + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "process.stdout.write('{\"packages\":[]}\\n');" + ].join('\n'), + 'utf8' +); + +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/lib.rs:rust-workspace-metadata-cache-hit'; + +const runRustWorkspace = (inputs) => runRustAnalyzerWorkspaceFixture({ + tempRoot, + providerId: 'lsp-rust-workspace-metadata-cache-hit', + serverId: 'rust-workspace-metadata-cache-hit', + metadataArgs: [counterScriptPath, metadataCountPath], + uriScheme: null, + serverConfig: { + preflightRuntimeRequirements: [] + }, + cache: { + enabled: true, + dir: toolingCacheDir + } +}, inputs); + +const providerInputs = { + documents: [{ + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-cache-hit.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-metadata-cache-hit' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_workspace_metadata_cache_hit', + file: 'src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-cache-hit.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}; + +const providerInputsCacheMiss = { + documents: [{ + ...providerInputs.documents[0], + docHash: 'hash-rust-workspace-metadata-cache-hit-b' + }], + targets: [{ + ...providerInputs.targets[0], + chunkRef: { + ...providerInputs.targets[0].chunkRef, + chunkUid: `${chunkUid}:b`, + chunkId: 'chunk_rust_workspace_metadata_cache_hit_b' + } + }], + kinds: ['types'] +}; + +const readCount = async (targetPath) => { + try { + return Number.parseInt(await fs.readFile(targetPath, 'utf8'), 10); + } catch { + return 0; + } +}; + +const first = await runRustWorkspace(providerInputs); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first rust metadata preflight run to be uncached'); +assert.equal(await readCount(metadataCountPath), 1, 'expected first rust metadata preflight to execute once'); + +const second = await runRustWorkspace(providerInputsCacheMiss); +assert.equal(second.metrics?.preflights?.cached, 1, 'expected second rust metadata preflight run to hit persistent cache'); +assert.equal(await readCount(metadataCountPath), 1, 'expected cached rust metadata preflight to skip rerun'); +assert.equal( + second.diagnostics?.['lsp-rust-workspace-metadata-cache-hit']?.preflight?.cached, + true, + 'expected rust preflight diagnostics to report cached marker reuse' +); + +console.log('configured LSP rust workspace metadata cache hit test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-degraded.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-degraded.test.js new file mode 100644 index 000000000..fea3c71b4 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-degraded.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-metadata-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +// Intentionally invalid Cargo.toml to force `cargo metadata` preflight failure. +await fs.writeFile(path.join(tempRoot, 'Cargo.toml'), '[package\nname = "broken"\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/lib.rs:rust-workspace-metadata-preflight'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-metadata-preflight'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-metadata-preflight', + preset: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust'], + languages: ['rust'], + // Keep this test focused on cargo-metadata preflight classification. + preflightRuntimeRequirements: [] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-preflight.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-metadata-preflight' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_workspace_metadata_preflight', + file: 'src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-preflight.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const diagnostics = result.diagnostics?.['lsp-rust-metadata-preflight'] || {}; +assert.equal( + diagnostics?.preflight?.state, + 'blocked', + 'expected rust workspace metadata preflight blocked state for an invalid manifest root' +); +assert.equal( + ['rust_workspace_broken_manifest', 'rust_workspace_blocked_all_partitions'] + .includes(String(diagnostics?.preflight?.reasonCode || '')), + true, + 'expected rust workspace metadata preflight reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => ['rust_workspace_broken_manifest', 'rust_workspace_blocked_all_partitions'].includes(String(check?.name || ''))), + true, + 'expected rust workspace metadata preflight warning check' +); + +console.log('configured LSP rust workspace metadata preflight degraded test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-timeout-override.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-timeout-override.test.js new file mode 100644 index 000000000..0bcc6b677 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-metadata-preflight-timeout-override.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-metadata-timeout-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'Cargo.toml'), + '[package]\nname = "sample"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'src', 'lib.rs'), 'fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); + +const rustProbeHangScriptPath = path.join(tempRoot, 'rust-metadata-timeout.js'); +await fs.writeFile(rustProbeHangScriptPath, 'setTimeout(() => process.exit(0), 5000);\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:src/lib.rs:rust-workspace-metadata-timeout-override'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-metadata-timeout'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-metadata-timeout', + preset: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust'], + languages: ['rust'], + preflightRuntimeRequirements: [], + rustWorkspaceMetadataCmd: process.execPath, + rustWorkspaceMetadataArgs: [rustProbeHangScriptPath], + rustWorkspaceMetadataTimeoutMs: 500 + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-timeout-override.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-metadata-timeout-override' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_workspace_metadata_timeout_override', + file: 'src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-metadata-timeout-override.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const diagnostics = result.diagnostics?.['lsp-rust-metadata-timeout'] || {}; +assert.equal( + diagnostics?.preflight?.state, + 'blocked', + 'expected rust metadata timeout override preflight blocked state when all partitions time out' +); +assert.equal( + ['rust_workspace_metadata_timeout', 'rust_workspace_blocked_all_partitions'] + .includes(String(diagnostics?.preflight?.reasonCode || '')), + true, + 'expected rust metadata timeout override reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => ['rust_workspace_metadata_timeout', 'rust_workspace_blocked_all_partitions'].includes(String(check?.name || ''))), + true, + 'expected rust metadata timeout override warning check' +); + +console.log('configured LSP rust workspace metadata timeout override test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-negative-cache-hit.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-negative-cache-hit.test.js new file mode 100644 index 000000000..f784632a0 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-negative-cache-hit.test.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-negative-cache-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'crate-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'examples', 'broken', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'crate-a', 'Cargo.toml'), + '[package]\nname = "crate-a"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'crate-a', 'src', 'lib.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); +await fs.writeFile( + path.join(tempRoot, 'examples', 'broken', 'Cargo.toml'), + '[package]\nname = "broken-example"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'examples', 'broken', 'src', 'main.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); +const metadataCounterPath = path.join(tempRoot, 'metadata-count.txt'); +const metadataScriptPath = path.join(tempRoot, 'cargo-metadata-partitioned.js'); +await fs.writeFile( + metadataScriptPath, + [ + "import fs from 'node:fs';", + "import path from 'node:path';", + "const countPath = process.argv[2];", + 'let next = 1;', + 'try {', + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + '} catch {}', + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "if (path.basename(process.cwd()) === 'broken') {", + " process.stderr.write('forced broken workspace partition\\n');", + ' process.exit(19);', + '}', + "process.stdout.write('{\"packages\":[]}\\n');" + ].join('\n'), + 'utf8' +); + +const docText = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; + +const runRustWorkspace = (inputs) => runRustAnalyzerWorkspaceFixture({ + tempRoot, + metadataArgs: [metadataScriptPath, metadataCounterPath], + cache: { + enabled: true, + dir: toolingCacheDir + } +}, inputs); + +const createInputs = (suffix) => ({ + documents: [ + { + virtualPath: `.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-negative-cache-good-${suffix}.txt`, + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: `hash-rust-workspace-negative-cache-good-${suffix}` + }, + { + virtualPath: `.poc-vfs/examples/broken/src/main.rs#seg:rust-workspace-negative-cache-bad-${suffix}.txt`, + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: `hash-rust-workspace-negative-cache-bad-${suffix}` + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:crate-a/src/lib.rs:rust-workspace-negative-cache-good:${suffix}`, + chunkId: `chunk_rust_workspace_negative_cache_good_${suffix}`, + file: 'crate-a/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: `.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-negative-cache-good-${suffix}.txt`, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }, + { + chunkRef: { + docId: 1, + chunkUid: `ck64:v1:test:examples/broken/src/main.rs:rust-workspace-negative-cache-bad:${suffix}`, + chunkId: `chunk_rust_workspace_negative_cache_bad_${suffix}`, + file: 'examples/broken/src/main.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: `.poc-vfs/examples/broken/src/main.rs#seg:rust-workspace-negative-cache-bad-${suffix}.txt`, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + } + ], + kinds: ['types'] +}); + +const first = await runRustWorkspace(createInputs('a')); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first rust workspace run to be uncached'); +assert.equal( + Number.parseInt(await fs.readFile(metadataCounterPath, 'utf8'), 10), + 2, + 'expected first rust workspace run to probe both partitions' +); + +const second = await runRustWorkspace(createInputs('b')); +assert.equal(second.metrics?.preflights?.cached, 1, 'expected second rust workspace run to hit persistent preflight cache'); +assert.equal( + Number.parseInt(await fs.readFile(metadataCounterPath, 'utf8'), 10), + 2, + 'expected cached negative rust partition result to avoid rerunning metadata probes' +); +assert.equal( + second.diagnostics?.['lsp-rust-analyzer']?.preflight?.cached, + true, + 'expected rust workspace diagnostics to report cached negative probe reuse' +); + +console.log('configured LSP rust workspace negative cache hit test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-nested-preflight.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-nested-preflight.test.js new file mode 100644 index 000000000..c3490802b --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-nested-preflight.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-nested-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'workspace', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'workspace', 'Cargo.toml'), + '[package]\nname = "nested-workspace"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +const preflightScriptPath = path.join(tempRoot, 'rust-workspace-preflight-ok.js'); +await fs.writeFile(preflightScriptPath, 'process.exit(0);\n', 'utf8'); + +const docText = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUid = 'ck64:v1:test:workspace/src/lib.rs:rust-workspace-nested'; +const virtualPath = '.poc-vfs/workspace/src/lib.rs#seg:rust-workspace-nested.txt'; + +const result = await runRustAnalyzerWorkspaceFixture({ + tempRoot, + metadataArgs: [preflightScriptPath] +}, { + documents: [{ + virtualPath, + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-nested' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_rust_workspace_nested', + file: 'workspace/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), true, 'expected rust-analyzer to run when a nested Cargo workspace matches the selected doc'); +const checks = result.diagnostics?.['lsp-rust-analyzer']?.checks || []; +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_model_missing'), + false, + 'expected nested Cargo workspace discovery to avoid missing-workspace warnings' +); + +console.log('configured LSP rust workspace nested preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-partial-coverage.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-partial-coverage.test.js new file mode 100644 index 000000000..5e8ec8a7b --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-partial-coverage.test.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-partial-${process.pid}-${Date.now()}`); +const docText = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'examples', 'broken', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'Cargo.toml'), + '[package]\nname = "root"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'src', 'lib.rs'), docText, 'utf8'); +await fs.writeFile( + path.join(tempRoot, 'examples', 'broken', 'Cargo.toml'), + '[package\nname = "broken-example"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'examples', 'broken', 'src', 'main.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); +const metadataCounterPath = path.join(tempRoot, 'metadata-count.txt'); +const metadataScriptPath = path.join(tempRoot, 'cargo-metadata-count.js'); +await fs.writeFile( + metadataScriptPath, + [ + "import fs from 'node:fs';", + "const countPath = process.argv[2];", + 'let next = 1;', + 'try {', + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + '} catch {}', + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "process.stdout.write('{\"packages\":[]}\\n');" + ].join('\n'), + 'utf8' +); + +const goodChunkUid = 'ck64:v1:test:src/lib.rs:rust-workspace-partial-coverage:good'; +const badChunkUid = 'ck64:v1:test:examples/broken/src/main.rs:rust-workspace-partial-coverage:bad'; + +const result = await runRustAnalyzerWorkspaceFixture({ + tempRoot, + metadataArgs: [metadataScriptPath, metadataCounterPath] +}, { + documents: [ + { + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-partial-coverage-good.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-partial-coverage-good' + }, + { + virtualPath: '.poc-vfs/examples/broken/src/main.rs#seg:rust-workspace-partial-coverage-bad.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-partial-coverage-bad' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: goodChunkUid, + chunkId: 'chunk_rust_workspace_partial_coverage_good', + file: 'src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-partial-coverage-good.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }, + { + chunkRef: { + docId: 1, + chunkUid: badChunkUid, + chunkId: 'chunk_rust_workspace_partial_coverage_bad', + file: 'examples/broken/src/main.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/examples/broken/src/main.rs#seg:rust-workspace-partial-coverage-bad.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + } + ], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(goodChunkUid), true, 'expected valid root partition to remain available'); +assert.equal(result.byChunkUid.has(badChunkUid), false, 'expected broken nested example partition to be quarantined'); +assert.equal( + Number.parseInt(await fs.readFile(metadataCounterPath, 'utf8'), 10), + 1, + 'expected cargo metadata probe to run only for the valid partition' +); +const diagnostics = result.diagnostics?.['lsp-rust-analyzer'] || {}; +assert.equal( + diagnostics?.preflight?.reasonCode, + 'rust_workspace_partial_repo_coverage', + 'expected partial coverage preflight reason code' +); +assert.equal( + diagnostics?.fidelity?.state, + 'degraded', + 'expected fidelity contract to classify partial coverage as degraded' +); +assert.equal( + diagnostics?.fidelity?.qualityDelta?.partialSuccess, + true, + 'expected mixed Rust partition coverage to report truthful partial success' +); +assert.equal( + diagnostics?.fidelity?.workspaceCoverage?.readyPartitionCount, + 1, + 'expected ready Rust partition count in fidelity contract' +); +assert.equal( + diagnostics?.fidelity?.workspaceCoverage?.blockedPartitionCount, + 1, + 'expected blocked Rust partition count in fidelity contract' +); +assert.equal( + Array.isArray(diagnostics?.fidelity?.runtimeIssues) + && diagnostics.fidelity.runtimeIssues.includes('partial_workspace_coverage') + && diagnostics.fidelity.runtimeIssues.includes('blocked_workspace_partitions') + && diagnostics.fidelity.runtimeIssues.includes('repo_workspace_invalidity'), + true, + 'expected fidelity contract to preserve partial-coverage and repo-invalidity runtime issue classes' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_broken_manifest'), + true, + 'expected broken nested manifest warning check' +); +assert.equal( + checks.some((check) => check?.name === 'lsp-rust-analyzer_workspace_partition_blocked'), + true, + 'expected blocked partition runtime check' +); + +console.log('configured LSP rust workspace partial coverage test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-root-ambiguous-partition.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-root-ambiguous-partition.test.js new file mode 100644 index 000000000..3f826019c --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-root-ambiguous-partition.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-root-ambiguous-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'crate-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'crate-b', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'crate-a', 'Cargo.toml'), + '[package]\nname = "crate-a"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'crate-b', 'Cargo.toml'), + '[package]\nname = "crate-b"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +const preflightScriptPath = path.join(tempRoot, 'rust-workspace-preflight-ok.js'); +await fs.writeFile(preflightScriptPath, 'process.exit(0);\n', 'utf8'); + +const docText = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const chunkUidA = 'ck64:v1:test:crate-a/src/lib.rs:rust-workspace-root-ambiguous:a'; +const chunkUidB = 'ck64:v1:test:crate-b/src/lib.rs:rust-workspace-root-ambiguous:b'; + +const result = await runRustAnalyzerWorkspaceFixture({ + tempRoot, + metadataArgs: [preflightScriptPath] +}, { + documents: [ + { + virtualPath: '.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-root-ambiguous-a.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-root-ambiguous-a' + }, + { + virtualPath: '.poc-vfs/crate-b/src/lib.rs#seg:rust-workspace-root-ambiguous-b.txt', + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-root-ambiguous-b' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: chunkUidA, + chunkId: 'chunk_rust_workspace_root_ambiguous_a', + file: 'crate-a/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-root-ambiguous-a.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }, + { + chunkRef: { + docId: 1, + chunkUid: chunkUidB, + chunkId: 'chunk_rust_workspace_root_ambiguous_b', + file: 'crate-b/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/crate-b/src/lib.rs#seg:rust-workspace-root-ambiguous-b.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + } + ], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUidA), true, 'expected rust-analyzer to route the first workspace root'); +assert.equal(result.byChunkUid.has(chunkUidB), true, 'expected rust-analyzer to route the second workspace root'); +const diagnostics = result.diagnostics?.['lsp-rust-analyzer'] || {}; +assert.equal(diagnostics?.workspaceModel?.partitionCount, 2, 'expected two workspace partitions in diagnostics summary'); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_root_partitioned'), + true, + 'expected rust workspace partitioned preflight check' +); +assert.equal( + checks.some((check) => check?.name === 'lsp-rust-analyzer_workspace_partition_multi_root'), + true, + 'expected runtime multi-root workspace routing check' +); + +console.log('configured LSP rust workspace root ambiguous partition test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-stderr-suppression.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-stderr-suppression.test.js new file mode 100644 index 000000000..0be4c8232 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-stderr-suppression.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-stderr-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'Cargo.toml'), + '[package]\nname = "root"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +const preflightScriptPath = path.join(tempRoot, 'rust-workspace-preflight-ok.js'); +await fs.writeFile(preflightScriptPath, 'process.exit(0);\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const logLines = []; +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + logger: (line) => { + logLines.push(String(line || '')); + }, + toolingConfig: { + enabledTools: ['lsp-rust-analyzer'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-analyzer', + preset: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust-workspace-noise'], + languages: ['rust'], + uriScheme: 'poc-vfs', + rustWorkspaceMetadataCmd: process.execPath, + rustWorkspaceMetadataArgs: [preflightScriptPath] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-stderr.txt', + text: 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', + languageId: 'rust', + effectiveExt: '.rs', + docHash: 'hash-rust-workspace-stderr' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/lib.rs:rust-workspace-stderr', + chunkId: 'chunk_rust_workspace_stderr', + file: 'src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 46 } + }, + virtualPath: '.poc-vfs/src/lib.rs#seg:rust-workspace-stderr.txt', + virtualRange: { start: 0, end: 46 }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const checks = result.diagnostics?.['lsp-rust-analyzer']?.checks || []; +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_repo_invalidity'), + true, + 'expected repo-invalidity stderr suppression check' +); +assert.equal( + checks.some((check) => check?.name === 'rust_workspace_toolchain_resolution_failed'), + true, + 'expected toolchain-noise stderr suppression check' +); +assert.equal( + Array.isArray(result.diagnostics?.['lsp-rust-analyzer']?.fidelity?.runtimeIssues) + && result.diagnostics['lsp-rust-analyzer'].fidelity.runtimeIssues.includes('repo_workspace_invalidity') + && result.diagnostics['lsp-rust-analyzer'].fidelity.runtimeIssues.includes('toolchain_resolution_failed'), + true, + 'expected fidelity contract to preserve both repo-invalidity and toolchain-noise runtime issue classes from stderr suppression' +); +assert.equal( + logLines.some((line) => line.includes('rust-analyzer suppressed 4 duplicate workspace stderr line(s)')), + true, + 'expected aggregated rust-analyzer stderr suppression log line' +); +assert.equal( + logLines.some((line) => line.includes('failed to find a workspace root for examples/broken/Cargo.toml')), + false, + 'expected raw duplicate workspace root stderr line to be suppressed from logs' +); + +console.log('configured LSP rust workspace stderr suppression test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-timeout-local-cache.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-timeout-local-cache.test.js new file mode 100644 index 000000000..46234d0bc --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-timeout-local-cache.test.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runRustAnalyzerWorkspaceFixture } from '../../helpers/lsp-provider-fixture.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-timeout-local-cache-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'crate-ok', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'examples', 'slow', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'crate-ok', 'Cargo.toml'), + '[package]\nname = "crate-ok"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'crate-ok', 'src', 'lib.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); +await fs.writeFile( + path.join(tempRoot, 'examples', 'slow', 'Cargo.toml'), + '[package]\nname = "slow-example"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'examples', 'slow', 'src', 'main.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); + +const metadataCountsPath = path.join(tempRoot, 'metadata-counts.json'); +const metadataScriptPath = path.join(tempRoot, 'cargo-metadata-timeout-local.js'); +await fs.writeFile( + metadataScriptPath, + [ + "import fs from 'node:fs';", + "import path from 'node:path';", + "const countPath = process.argv[2];", + "const cwdName = path.basename(process.cwd());", + "const counts = fs.existsSync(countPath) ? JSON.parse(fs.readFileSync(countPath, 'utf8')) : {};", + "counts[cwdName] = Number(counts[cwdName] || 0) + 1;", + "fs.writeFileSync(countPath, JSON.stringify(counts), 'utf8');", + "if (cwdName === 'slow') {", + " setTimeout(() => process.exit(0), 5000);", + '} else {', + " process.stdout.write('{\"packages\":[]}\\n');", + '}' + ].join('\n'), + 'utf8' +); + +const docText = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; + +const runRustWorkspace = (inputs) => runRustAnalyzerWorkspaceFixture({ + tempRoot, + providerId: 'lsp-rust-timeout-local-cache', + serverId: 'rust-timeout-local-cache', + metadataArgs: [metadataScriptPath, metadataCountsPath], + serverConfig: { + rustWorkspaceMetadataTimeoutMs: 500 + }, + cache: { + enabled: true, + dir: toolingCacheDir + } +}, inputs); + +const createInputs = ({ targetPath, suffix }) => ({ + documents: [{ + virtualPath: `.poc-vfs/${targetPath}#seg:rust-timeout-local-cache-${suffix}.txt`, + text: docText, + languageId: 'rust', + effectiveExt: '.rs', + docHash: `hash-rust-timeout-local-cache-${suffix}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:${targetPath}:rust-timeout-local-cache:${suffix}`, + chunkId: `chunk_rust_timeout_local_cache_${suffix}`, + file: targetPath, + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: `.poc-vfs/${targetPath}#seg:rust-timeout-local-cache-${suffix}.txt`, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const readCounts = async () => { + try { + return JSON.parse(await fs.readFile(metadataCountsPath, 'utf8')); + } catch { + return {}; + } +}; + +const slowFirst = await runRustWorkspace(createInputs({ + targetPath: 'examples/slow/src/main.rs', + suffix: 'slow-a' +})); +const slowDiagnostics = slowFirst.diagnostics?.['lsp-rust-timeout-local-cache'] || {}; +assert.equal(slowDiagnostics?.preflight?.state, 'blocked', 'expected timed out partition to block when it is the only selected root'); +assert.equal( + ['rust_workspace_metadata_timeout', 'rust_workspace_blocked_all_partitions'].includes(String(slowDiagnostics?.preflight?.reasonCode || '')), + true, + 'expected timeout-shaped rust preflight reason code' +); +assert.deepEqual( + await readCounts(), + { slow: 1 }, + 'expected timed out partition to probe once' +); + +const okSecond = await runRustWorkspace(createInputs({ + targetPath: 'crate-ok/src/lib.rs', + suffix: 'ok-a' +})); +assert.equal(okSecond.byChunkUid.size, 1, 'expected healthy Rust partition to contribute after unrelated timeout cache entry'); +assert.equal( + okSecond.diagnostics?.['lsp-rust-timeout-local-cache']?.preflight?.cached || false, + false, + 'expected healthy Rust partition not to reuse timed-out cache from a different root' +); +assert.deepEqual( + await readCounts(), + { slow: 1, 'crate-ok': 1 }, + 'expected healthy partition probe to run independently of timed-out partition cache' +); + +const slowThird = await runRustWorkspace(createInputs({ + targetPath: 'examples/slow/src/main.rs', + suffix: 'slow-b' +})); +assert.equal( + slowThird.diagnostics?.['lsp-rust-timeout-local-cache']?.preflight?.cached, + true, + 'expected repeated timed-out partition to reuse its cached negative result' +); +assert.deepEqual( + await readCounts(), + { slow: 1, 'crate-ok': 1 }, + 'expected cached timeout partition rerun not to repeat the slow metadata probe' +); + +console.log('configured LSP rust workspace timeout local cache test passed'); diff --git a/tests/tooling/lsp/configured-provider-rust-workspace-toolchain-resolution-failed.test.js b/tests/tooling/lsp/configured-provider-rust-workspace-toolchain-resolution-failed.test.js new file mode 100644 index 000000000..f8e38d8c0 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-rust-workspace-toolchain-resolution-failed.test.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-rust-workspace-toolchain-${process.pid}-${Date.now()}`); +const toolingCacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'crate-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'crate-b', 'src'), { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'crate-a', 'Cargo.toml'), + '[package]\nname = "crate-a"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'crate-b', 'Cargo.toml'), + '[package]\nname = "crate-b"\nversion = "0.1.0"\nedition = "2021"\n', + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'crate-a', 'src', 'lib.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'crate-b', 'src', 'lib.rs'), 'pub fn sub(a: i32, b: i32) -> i32 { a - b }\n', 'utf8'); + +const metadataCounterPath = path.join(tempRoot, 'metadata-count.txt'); +const metadataScriptPath = path.join(tempRoot, 'cargo-metadata-toolchain-fail.js'); +await fs.writeFile( + metadataScriptPath, + [ + "import fs from 'node:fs';", + "const countPath = process.argv[2];", + 'let next = 1;', + 'try {', + " next = Number(fs.readFileSync(countPath, 'utf8')) + 1;", + '} catch {}', + "fs.writeFileSync(countPath, `${next}\\n`, 'utf8');", + "process.stderr.write('cargo metadata failed for C:\\\\toolchains\\\\rustlib\\\\src\\\\rust\\\\library\\\\std\\\\Cargo.toml\\n');", + 'process.exit(19);' + ].join('\n'), + 'utf8' +); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docTextA = 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n'; +const docTextB = 'pub fn sub(a: i32, b: i32) -> i32 { a - b }\n'; + +const createContext = () => ({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-rust-toolchain-resolution'], + lsp: { + enabled: true, + servers: [{ + id: 'rust-toolchain-resolution', + preset: 'rust-analyzer', + cmd: process.execPath, + args: [serverPath, '--mode', 'rust'], + languages: ['rust'], + preflightRuntimeRequirements: [], + rustWorkspaceMetadataCmd: process.execPath, + rustWorkspaceMetadataArgs: [metadataScriptPath, metadataCounterPath] + }] + } + }, + cache: { + enabled: true, + dir: toolingCacheDir + } +}); + +const createInputs = (suffix) => ({ + documents: [{ + virtualPath: `.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-toolchain-a-${suffix}.txt`, + text: docTextA, + languageId: 'rust', + effectiveExt: '.rs', + docHash: `hash-rust-workspace-toolchain-a-${suffix}` + }, { + virtualPath: `.poc-vfs/crate-b/src/lib.rs#seg:rust-workspace-toolchain-b-${suffix}.txt`, + text: docTextB, + languageId: 'rust', + effectiveExt: '.rs', + docHash: `hash-rust-workspace-toolchain-b-${suffix}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:crate-a/src/lib.rs:rust-workspace-toolchain-a:${suffix}`, + chunkId: `chunk_rust_workspace_toolchain_a_${suffix}`, + file: 'crate-a/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docTextA.length } + }, + virtualPath: `.poc-vfs/crate-a/src/lib.rs#seg:rust-workspace-toolchain-a-${suffix}.txt`, + virtualRange: { start: 0, end: docTextA.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'rust' + }, { + chunkRef: { + docId: 1, + chunkUid: `ck64:v1:test:crate-b/src/lib.rs:rust-workspace-toolchain-b:${suffix}`, + chunkId: `chunk_rust_workspace_toolchain_b_${suffix}`, + file: 'crate-b/src/lib.rs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docTextB.length } + }, + virtualPath: `.poc-vfs/crate-b/src/lib.rs#seg:rust-workspace-toolchain-b-${suffix}.txt`, + virtualRange: { start: 0, end: docTextB.length }, + symbolHint: { name: 'sub', kind: 'function' }, + languageId: 'rust' + }], + kinds: ['types'] +}); + +const readCount = async () => { + try { + return Number.parseInt(await fs.readFile(metadataCounterPath, 'utf8'), 10); + } catch { + return 0; + } +}; + +const first = await runToolingProviders(createContext(), createInputs('a')); +assert.equal(first.metrics?.preflights?.cached || 0, 0, 'expected first toolchain-resolution run to be uncached'); +assert.equal(await readCount(), 2, 'expected first toolchain-resolution run to execute metadata probes for both partitions'); +assert.equal( + first.byChunkUid.size, + 2, + 'expected toolchain-only metadata noise not to block repo-local Rust coverage' +); +assert.equal( + first.diagnostics?.['lsp-rust-toolchain-resolution']?.preflight?.reasonCode, + 'rust_workspace_toolchain_resolution_failed', + 'expected explicit toolchain-resolution preflight reason' +); +assert.equal( + first.diagnostics?.['lsp-rust-toolchain-resolution']?.preflight?.state, + 'degraded', + 'expected toolchain-only metadata noise to degrade rather than block provider startup' +); +assert.equal( + first.diagnostics?.['lsp-rust-toolchain-resolution']?.fidelity?.state, + 'degraded', + 'expected fidelity contract to classify toolchain-only metadata noise as degraded' +); +assert.equal( + Array.isArray(first.diagnostics?.['lsp-rust-toolchain-resolution']?.fidelity?.runtimeIssues) + && first.diagnostics['lsp-rust-toolchain-resolution'].fidelity.runtimeIssues.includes('toolchain_resolution_failed'), + true, + 'expected fidelity contract to surface toolchain-resolution runtime issue class' +); +const firstChecks = first.diagnostics?.['lsp-rust-toolchain-resolution']?.checks || []; +assert.equal( + firstChecks.some((check) => check?.name === 'rust_workspace_toolchain_resolution_failed'), + true, + 'expected explicit toolchain-resolution warning check' +); + +const second = await runToolingProviders(createContext(), createInputs('b')); +assert.equal(second.metrics?.preflights?.cached, 1, 'expected cached negative toolchain-resolution marker on second run'); +assert.equal(await readCount(), 2, 'expected cached negative toolchain-resolution result to skip rerun'); +assert.equal( + second.byChunkUid.size, + 2, + 'expected cached toolchain-noise degradation to preserve repo-local Rust coverage' +); +assert.equal( + second.diagnostics?.['lsp-rust-toolchain-resolution']?.preflight?.cached, + true, + 'expected toolchain-resolution diagnostics to report cached negative reuse' +); +assert.equal( + Array.isArray(second.diagnostics?.['lsp-rust-toolchain-resolution']?.fidelity?.runtimeIssues) + && second.diagnostics['lsp-rust-toolchain-resolution'].fidelity.runtimeIssues.includes('toolchain_resolution_failed'), + true, + 'expected cached negative reuse to retain the toolchain-resolution runtime issue class' +); + +console.log('configured LSP rust workspace toolchain resolution failed test passed'); diff --git a/tests/tooling/lsp/configured-provider-workspace-inputs.test.js b/tests/tooling/lsp/configured-provider-workspace-inputs.test.js new file mode 100644 index 000000000..999a201e3 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-workspace-inputs.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { prepareConfiguredProviderInputs } from '../../../src/index/tooling/lsp-provider/index.js'; + +const prepared = prepareConfiguredProviderInputs({ + providerId: 'lsp-gopls', + inputs: { + documents: [ + { virtualPath: '.poc-vfs/pkg/main.go#seg:main', languageId: 'go' }, + { virtualPath: '.poc-vfs/pkg/util.go#seg:util', languageId: 'go' } + ], + targets: [ + { virtualPath: '.poc-vfs/pkg/main.go#seg:main', chunkRef: { chunkUid: 'chunk-main' } }, + { virtualPath: '.poc-vfs/pkg/util.go#seg:util', chunkRef: { chunkUid: 'chunk-util' } } + ], + kinds: ['types'] + } +}); + +assert.equal(Array.isArray(prepared.documents), true, 'expected startup document selection to return documents'); +assert.equal(Array.isArray(prepared.targets), true, 'expected startup document selection to return targets'); +assert.ok(prepared.documents.length >= 1, 'expected at least one startup document'); +assert.equal( + prepared.targets.every((target) => prepared.documents.some((doc) => doc.virtualPath === target.virtualPath)), + true, + 'expected startup targets to remain aligned with startup documents' +); + +console.log('configured provider workspace input shaping test passed'); diff --git a/tests/tooling/lsp/configured-provider-workspace-preflight-block.test.js b/tests/tooling/lsp/configured-provider-workspace-preflight-block.test.js new file mode 100644 index 000000000..146abbeae --- /dev/null +++ b/tests/tooling/lsp/configured-provider-workspace-preflight-block.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-workspace-preflight-block-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; +const chunkUid = 'ck64:v1:test:src/sample.go:workspace-preflight-block'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-custom-ws'], + lsp: { + enabled: true, + servers: [{ + id: 'custom-ws', + cmd: process.execPath, + args: [serverPath, '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + workspaceMarkerOptions: { + exactNames: ['go.mod'] + }, + workspaceModelPolicy: 'block', + workspaceModelMissingMessage: 'custom workspace marker missing.' + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.go#seg:workspace-preflight-block.txt', + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: 'hash-workspace-preflight-block' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_workspace_preflight_block', + file: 'src/sample.go', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.go#seg:workspace-preflight-block.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), false, 'expected provider to be blocked by workspace preflight'); +const checks = result.diagnostics?.['lsp-custom-ws']?.checks || []; +assert.ok(Array.isArray(checks), 'expected diagnostics checks for configured provider'); +assert.equal( + checks.some((check) => check?.name === 'custom-ws_workspace_model_missing'), + true, + 'expected custom workspace-model missing preflight check' +); +assert.equal( + checks.some((check) => check?.name === 'lsp_command_unavailable'), + false, + 'expected command profile probe checks to be skipped when preflight blocks provider' +); + +console.log('configured LSP workspace preflight block test passed'); diff --git a/tests/tooling/lsp/configured-provider-yaml-schema-override-merge.test.js b/tests/tooling/lsp/configured-provider-yaml-schema-override-merge.test.js new file mode 100644 index 000000000..395ff27d4 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-yaml-schema-override-merge.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-yaml-schema-merge-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const virtualPath = '.poc-vfs/.github/workflows/ci.yaml#seg:yaml-schema-merge.txt'; +const docText = 'name: CI\non: [push]\n'; +const chunkUid = 'ck64:v1:test:.github/workflows/ci.yaml:yaml-schema-merge'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-yaml-schema-merge'], + lsp: { + enabled: true, + servers: [{ + id: 'yaml-schema-merge', + preset: 'yaml-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'yaml-requires-schemastore-and-schema-map'], + languages: ['yaml'], + initializationOptions: { + settings: { + yaml: { + schemas: { + 'https://json.schemastore.org/github-workflow.json': '.github/workflows/*.yaml' + } + } + } + } + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath, + text: docText, + languageId: 'yaml', + effectiveExt: '.yaml', + docHash: 'hash-yaml-schema-merge' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_yaml_schema_merge', + file: '.github/workflows/ci.yaml', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'name', kind: 'property' }, + languageId: 'yaml' + }], + kinds: ['diagnostics'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), true, 'expected yaml provider to enrich with merged init options'); +const diagnostics = result.diagnostics?.['lsp-yaml-schema-merge'] || null; +assert.ok(diagnostics && diagnostics.runtime, 'expected runtime diagnostics for yaml provider'); + +console.log('configured LSP yaml schema override merge test passed'); diff --git a/tests/tooling/lsp/configured-provider-yaml-schema-store-remote-warning-preflight.test.js b/tests/tooling/lsp/configured-provider-yaml-schema-store-remote-warning-preflight.test.js new file mode 100644 index 000000000..1ca5eea5a --- /dev/null +++ b/tests/tooling/lsp/configured-provider-yaml-schema-store-remote-warning-preflight.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-yaml-remote-schema-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const virtualPath = '.poc-vfs/.github/workflows/ci.yaml#seg:yaml-remote-schema.txt'; +const docText = 'name: CI\non: [push]\n'; +const chunkUid = 'ck64:v1:test:.github/workflows/ci.yaml:yaml-remote-schema'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-yaml-remote-schema'], + lsp: { + enabled: true, + servers: [{ + id: 'yaml-remote-schema', + preset: 'yaml-language-server', + cmd: process.execPath, + args: [serverPath, '--mode', 'yaml-requires-schemastore-off'], + languages: ['yaml'], + initializationOptions: { + settings: { + yaml: { + schemaStore: { + enable: true + } + } + } + } + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath, + text: docText, + languageId: 'yaml', + effectiveExt: '.yaml', + docHash: 'hash-yaml-remote-schema' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_yaml_remote_schema', + file: '.github/workflows/ci.yaml', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'name', kind: 'property' }, + languageId: 'yaml' + }], + kinds: ['diagnostics'] +}); + +const checks = Array.isArray(result.diagnostics?.['lsp-yaml-remote-schema']?.checks) + ? result.diagnostics['lsp-yaml-remote-schema'].checks + : []; +assert.equal( + checks.some((check) => check?.name === 'yaml_schema_store_remote_enabled'), + true, + 'expected yaml schema store remote-enabled preflight warning check' +); +assert.equal( + result.diagnostics?.['lsp-yaml-remote-schema']?.preflight?.reasonCode, + 'yaml_schema_store_remote_enabled', + 'expected preflight reasonCode for yaml schema store remote mode' +); + +console.log('configured LSP yaml schema-store remote warning preflight test passed'); diff --git a/tests/tooling/lsp/configured-provider-zls-preflight-timeout.test.js b/tests/tooling/lsp/configured-provider-zls-preflight-timeout.test.js new file mode 100644 index 000000000..54e6db35b --- /dev/null +++ b/tests/tooling/lsp/configured-provider-zls-preflight-timeout.test.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createConfiguredLspProviders } from '../../../src/index/tooling/lsp-provider.js'; + +const providers = createConfiguredLspProviders({ + lsp: { + enabled: true, + servers: [ + { id: 'zls-default', preset: 'zls' }, + { id: 'zls-override', preset: 'zls', preflightTimeoutMs: 45000 } + ] + } +}); + +const zlsDefault = providers.find((provider) => provider.id === 'lsp-zls-default'); +assert.ok(zlsDefault, 'expected zls default preset provider'); +assert.equal(zlsDefault.preflightClass, 'workspace', 'expected zls preflight class to remain workspace'); +assert.equal(zlsDefault.preflightTimeoutMs, 30000, 'expected zls default preflight timeout from preset'); + +const zlsOverride = providers.find((provider) => provider.id === 'lsp-zls-override'); +assert.ok(zlsOverride, 'expected zls override preset provider'); +assert.equal(zlsOverride.preflightTimeoutMs, 45000, 'expected zls preflight timeout override to be honored'); + +console.log('configured LSP zls preflight timeout test passed'); diff --git a/tests/tooling/lsp/configured-provider-zls-workspace-nested-root-preflight.test.js b/tests/tooling/lsp/configured-provider-zls-workspace-nested-root-preflight.test.js new file mode 100644 index 000000000..213c5fad4 --- /dev/null +++ b/tests/tooling/lsp/configured-provider-zls-workspace-nested-root-preflight.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `configured-lsp-zls-workspace-nested-root-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'nested'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'nested', 'build.zig'), 'pub fn build() void {}\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'pub fn add(a: i32, b: i32) i32 { return a + b; }\n'; +const chunkUid = 'ck64:v1:test:src/sample.zig:zls-workspace-nested-root'; + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-zls'], + lsp: { + enabled: true, + servers: [{ + id: 'zls', + preset: 'zls', + cmd: process.execPath, + args: [serverPath, '--mode', 'zig'], + languages: ['zig'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [] + }] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.zig#seg:zls-workspace-nested-root.txt', + text: docText, + languageId: 'zig', + effectiveExt: '.zig', + docHash: 'hash-zls-workspace-nested-root' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_zls_workspace_nested_root', + file: 'src/sample.zig', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: '.poc-vfs/src/sample.zig#seg:zls-workspace-nested-root.txt', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'zig' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), true, 'expected zls provider to continue with nested workspace marker preflight warning'); +const diagnostics = result.diagnostics?.['lsp-zls'] || {}; +assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected zls preflight degraded state'); +assert.equal( + diagnostics?.preflight?.reasonCode, + 'zls_workspace_nested_root', + 'expected zls nested workspace root reason code' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'zls_workspace_nested_root'), + true, + 'expected zls nested workspace root warning check' +); + +console.log('configured LSP zls workspace nested-root preflight test passed'); diff --git a/tests/tooling/lsp/csharp-provider-ambiguous-project-preflight.test.js b/tests/tooling/lsp/csharp-provider-ambiguous-project-preflight.test.js new file mode 100644 index 000000000..bbf2e31a0 --- /dev/null +++ b/tests/tooling/lsp/csharp-provider-ambiguous-project-preflight.test.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { + buildSingleSymbolInputs, + createLspProviderTempRepo +} from '../../helpers/lsp-provider-fixture.js'; +import { runDedicatedProviderDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'csharp-provider-ambiguous-project-preflight', + directories: ['src'], + files: [ + { path: 'AppA.csproj', content: '' }, + { path: 'AppB.csproj', content: '' } + ] +}); +const docText = 'class App { string Greet(string name) => name; }\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'csharp-ambiguous-project-preflight', + virtualPath: 'src/App.cs', + text: docText, + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet' +}); + +await runDedicatedProviderDegradedPreflightCase({ + root, + repo: tempRoot, + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + fixtureCommand: 'csharp-ls', + inputs, + expectedEnrichment: true, + expectedReasonCode: 'csharp_workspace_ambiguous_project', + expectedCheckName: 'csharp_workspace_ambiguous_project', + messages: { + enrichment: 'expected csharp provider to continue with ambiguous project roots', + reasonCode: 'expected csharp ambiguous project reason code', + check: 'expected csharp ambiguous project warning check' + } +}); + +console.log('csharp provider ambiguous project preflight test passed'); diff --git a/tests/tooling/lsp/csharp-provider-ambiguous-solution-preflight.test.js b/tests/tooling/lsp/csharp-provider-ambiguous-solution-preflight.test.js new file mode 100644 index 000000000..90d92ba6f --- /dev/null +++ b/tests/tooling/lsp/csharp-provider-ambiguous-solution-preflight.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { + buildSingleSymbolInputs, + createLspProviderTempRepo +} from '../../helpers/lsp-provider-fixture.js'; +import { runDedicatedProviderDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'csharp-provider-ambiguous-solution-preflight', + directories: ['src'], + files: [ + { path: 'App.csproj', content: '' }, + { path: 'AppA.sln', content: 'Microsoft Visual Studio Solution File, Format Version 12.00\n' }, + { path: 'AppB.sln', content: 'Microsoft Visual Studio Solution File, Format Version 12.00\n' } + ] +}); +const docText = 'class App { string Greet(string name) => name; }\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'csharp-ambiguous-solution-preflight', + virtualPath: 'src/App.cs', + text: docText, + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet' +}); + +await runDedicatedProviderDegradedPreflightCase({ + root, + repo: tempRoot, + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + fixtureCommand: 'csharp-ls', + inputs, + expectedEnrichment: true, + expectedReasonCode: 'csharp_workspace_ambiguous_solution', + expectedCheckName: 'csharp_workspace_ambiguous_solution', + messages: { + enrichment: 'expected csharp provider to fail-open when workspace solution is ambiguous', + reasonCode: 'expected csharp ambiguous solution reason code', + check: 'expected csharp ambiguous solution warning check' + } +}); + +console.log('csharp provider ambiguous solution preflight test passed'); diff --git a/tests/tooling/lsp/csharp-provider-overload-signature.test.js b/tests/tooling/lsp/csharp-provider-overload-signature.test.js new file mode 100644 index 000000000..4acd4330c --- /dev/null +++ b/tests/tooling/lsp/csharp-provider-overload-signature.test.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `csharp-provider-overload-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'sample.sln'), 'Microsoft Visual Studio Solution File\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +registerDefaultToolingProviders(); +const docText = 'public class App { string Greet(string name, int count = 1) => name; }\n'; +const chunkUid = 'ck64:v1:test:src/App.cs:csharp-overload'; +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['csharp-ls'], + csharp: { + enabled: true, + cmd: process.execPath, + args: [serverPath, '--mode', 'csharp-overload'] + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: 'src/App.cs', + text: docText, + languageId: 'csharp', + effectiveExt: '.cs', + docHash: 'hash-csharp-overload' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_csharp_overload', + file: 'src/App.cs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: 'src/App.cs', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Greet', kind: 'function' }, + languageId: 'csharp' + }], + kinds: ['types'] +}); + +const hit = result.byChunkUid.get(chunkUid); +assert.ok(hit, 'expected csharp dedicated provider hit'); +assert.equal(hit.payload?.returnType, 'string', 'expected C# return type from overload signature'); +assert.equal(hit.payload?.paramTypes?.name?.[0]?.type, 'string', 'expected C# first param type'); +assert.equal(hit.payload?.paramTypes?.count?.[0]?.type, 'int', 'expected C# overload/default param type'); + +console.log('csharp provider overload signature test passed'); diff --git a/tests/tooling/lsp/csharp-provider-workspace-partition.test.js b/tests/tooling/lsp/csharp-provider-workspace-partition.test.js new file mode 100644 index 000000000..f6f66c287 --- /dev/null +++ b/tests/tooling/lsp/csharp-provider-workspace-partition.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createLspProviderTempRepo, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'csharp-provider-workspace-partition', + directories: ['svc-a/src', 'svc-b/src'], + files: [ + { path: 'svc-a/app.csproj', content: '\n' }, + { path: 'svc-b/app.csproj', content: '\n' } + ] +}); +const docText = 'class App { string Greet(string name) => name; }\n'; +const inputs = { + kinds: ['types'], + documents: [ + { + virtualPath: 'svc-a/src/App.cs', + text: docText, + languageId: 'csharp', + effectiveExt: '.cs', + docHash: 'hash-csharp-partition-a' + }, + { + virtualPath: 'svc-b/src/App.cs', + text: docText, + languageId: 'csharp', + effectiveExt: '.cs', + docHash: 'hash-csharp-partition-b' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:svc-a/src/App.cs:csharp-partition-a', + chunkId: 'chunk_csharp_partition_a', + file: 'svc-a/src/App.cs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: 'svc-a/src/App.cs', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Greet', kind: 'function' }, + languageId: 'csharp' + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck64:v1:test:svc-b/src/App.cs:csharp-partition-b', + chunkId: 'chunk_csharp_partition_b', + file: 'svc-b/src/App.cs', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: 'svc-b/src/App.cs', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Greet', kind: 'function' }, + languageId: 'csharp' + } + ] +}; + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.targets[0].chunkRef.chunkUid), true, 'expected first C# workspace partition to contribute'); + assert.equal(result.byChunkUid.has(inputs.targets[1].chunkRef.chunkUid), true, 'expected second C# workspace partition to contribute'); + assert.equal( + result.diagnostics?.['csharp-ls']?.workspaceModel?.partitionCount, + 2, + 'expected dedicated provider workspace summary to expose both partitions' + ); + const checks = Array.isArray(result.diagnostics?.['csharp-ls']?.checks) ? result.diagnostics['csharp-ls'].checks : []; + assert.equal( + checks.some((check) => check?.name === 'csharp-ls_workspace_partition_multi_root'), + true, + 'expected dedicated provider multi-root routing check' + ); + + console.log('csharp provider workspace partition test passed'); +}); diff --git a/tests/tooling/lsp/dart-provider-command-fallback.test.js b/tests/tooling/lsp/dart-provider-command-fallback.test.js new file mode 100644 index 000000000..d04e4a4df --- /dev/null +++ b/tests/tooling/lsp/dart-provider-command-fallback.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'dart-provider-command-fallback', + directories: ['lib'], + files: [{ path: 'pubspec.yaml', content: 'name: dart_fixture\n' }] +}); +const docText = 'String greet(String name) { return name; }\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'dart-command-fallback', + virtualPath: 'lib/app.dart', + text: docText, + languageId: 'dart', + effectiveExt: '.dart', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'dart', + providerConfigKey: 'dart', + providerConfig: { + cmd: 'dart-not-found' + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected fail-open fallback when dart command is unavailable'); + const checks = result.diagnostics?.dart?.checks || []; + assert.equal( + checks.some((check) => check?.name === 'dart_command_unavailable'), + true, + 'expected command unavailable warning' + ); + assert.equal( + Array.isArray(result.degradedProviders) + && result.degradedProviders.some((entry) => entry?.providerId === 'dart'), + true, + 'expected degraded provider summary entry for dart' + ); + assert.equal( + Array.isArray(result.observations) + && result.observations.some((entry) => entry?.code === 'tooling_provider_degraded_mode' && entry?.context?.providerId === 'dart'), + true, + 'expected degraded mode observation for dart' + ); + assert.equal(result.metrics?.degradedProviderCount, 1, 'expected degraded provider metrics count'); + assert.equal(result.metrics?.degradedWarningChecks >= 1, true, 'expected degraded warning metrics'); + assert.equal(result.metrics?.providersContributed, 0, 'expected no chunk contribution in degraded fail-open mode'); + assert.equal(result.metrics?.providerRuntime?.dart?.degraded?.active, true, 'expected per-provider degraded runtime flag'); + assert.equal( + checks.some((check) => check?.name === 'tooling_initialize_failed'), + true, + 'expected initialize failure after command probe warning when command truly cannot launch' + ); + assert.equal(result.metrics?.requests?.requests >= 1, true, 'expected runtime initialization attempt after probe warning'); +}); + +console.log('dart provider command fallback test passed'); diff --git a/tests/tooling/lsp/dart-provider-package-config-missing-preflight.test.js b/tests/tooling/lsp/dart-provider-package-config-missing-preflight.test.js new file mode 100644 index 000000000..e021fd6a9 --- /dev/null +++ b/tests/tooling/lsp/dart-provider-package-config-missing-preflight.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'dart-provider-package-config-missing-preflight', + directories: ['lib'], + files: [{ path: 'pubspec.yaml', content: 'name: dart_fixture\n' }] +}); +const fixtureDartCmd = resolveLspFixtureCommand('dart', { repoRoot: root }); +const docText = 'String greet(String name) { return name; }\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'dart-package-config-missing-preflight', + virtualPath: 'lib/app.dart', + text: docText, + languageId: 'dart', + effectiveExt: '.dart', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'dart', + providerConfigKey: 'dart', + providerConfig: { + cmd: fixtureDartCmd + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, 'expected dart provider to fail-open on missing package_config'); + const diagnostics = result.diagnostics?.dart || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected dart preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'dart_workspace_package_config_missing', + 'expected dart package-config missing reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'dart_workspace_package_config_missing'), + true, + 'expected dart package-config missing warning check' + ); +}); + +console.log('dart provider package config missing preflight test passed'); diff --git a/tests/tooling/lsp/dart-provider-process-reuse.test.js b/tests/tooling/lsp/dart-provider-process-reuse.test.js new file mode 100644 index 000000000..49a87338b --- /dev/null +++ b/tests/tooling/lsp/dart-provider-process-reuse.test.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; + +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { cleanupLspTestRuntime, prependLspTestPath } from '../../helpers/lsp-runtime.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `dart-provider-process-reuse-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'lib'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, '.dart_tool'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'pubspec.yaml'), 'name: dart_fixture\n', 'utf8'); +await fs.writeFile( + path.join(tempRoot, '.dart_tool', 'package_config.json'), + JSON.stringify({ + configVersion: 2, + packages: [] + }, null, 2), + 'utf8' +); + +const counterPath = path.join(tempRoot, 'dart-lsp.counter'); +const restorePath = prependLspTestPath({ repoRoot: root }); +const fixtureDartCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'dart.cmd' : 'dart' +); + +try { + await cleanupLspTestRuntime({ reason: 'dart_provider_process_reuse_start', strict: true }); + await withTemporaryEnv({ POC_LSP_COUNTER: counterPath }, async () => { + registerDefaultToolingProviders(); + const docOne = 'String greet(String name) { return name; }\n'; + const docTwo = 'String hello(String name) { return name; }\n'; + const runDartPass = async (suffix) => runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['dart'], + dart: { + enabled: true, + cmd: fixtureDartCmd, + sessionIdleTimeoutMs: 60_000 + } + }, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath: 'lib/one.dart', + text: docOne, + languageId: 'dart', + effectiveExt: '.dart', + docHash: `hash-dart-one-${suffix}` + }, { + virtualPath: 'lib/two.dart', + text: docTwo, + languageId: 'dart', + effectiveExt: '.dart', + docHash: `hash-dart-two-${suffix}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:lib/one.dart:dart-reuse-one-${suffix}`, + chunkId: 'chunk_dart_reuse_one', + file: 'lib/one.dart', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docOne.length } + }, + virtualPath: 'lib/one.dart', + virtualRange: { start: 0, end: docOne.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'dart' + }, { + chunkRef: { + docId: 1, + chunkUid: `ck64:v1:test:lib/two.dart:dart-reuse-two-${suffix}`, + chunkId: 'chunk_dart_reuse_two', + file: 'lib/two.dart', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docTwo.length } + }, + virtualPath: 'lib/two.dart', + virtualRange: { start: 0, end: docTwo.length }, + symbolHint: { name: 'hello', kind: 'function' }, + languageId: 'dart' + }], + kinds: ['types'] + }); + + const runReuseScenario = async (attemptLabel) => { + const firstPass = await runDartPass(`${attemptLabel}-first`); + const secondPass = await runDartPass(`${attemptLabel}-second`); + return { + firstPass, + secondPass, + spawnCount: await countNonEmptyLines(counterPath) + }; + }; + + let scenario = await runReuseScenario('attempt-one'); + const reusedSecondPass = scenario.secondPass.diagnostics?.dart?.runtime?.pooling?.reused === true; + if (scenario.spawnCount !== 1 || !reusedSecondPass) { + await cleanupLspTestRuntime({ reason: 'dart_provider_process_reuse_retry', strict: true }); + await fs.writeFile(counterPath, '', 'utf8'); + scenario = await runReuseScenario('attempt-two'); + } + + const { firstPass, secondPass, spawnCount } = scenario; + assert.equal(spawnCount, 1, 'expected one dart language-server process spawn across reused provider runs'); + assert.equal(firstPass.byChunkUid.size, 2, 'expected both Dart chunks enriched (first pass)'); + assert.equal(secondPass.byChunkUid.size, 2, 'expected both Dart chunks enriched (second pass)'); + assert.equal(firstPass.diagnostics?.dart?.runtime?.pooling?.reused, false, 'expected first pass to create the pooled dart session'); + assert.equal(secondPass.diagnostics?.dart?.runtime?.pooling?.reused, true, 'expected second pass to reuse the pooled dart session'); + assert.equal( + Number(firstPass.diagnostics?.dart?.runtime?.requests?.byMethod?.initialize?.requests || 0), + 1, + 'expected one initialize request for the shared dart session (first pass)' + ); + assert.equal( + Number(secondPass.diagnostics?.dart?.runtime?.requests?.byMethod?.initialize?.requests || 0), + 1, + 'expected reused pooled session to avoid duplicate initialize requests (second pass)' + ); + + console.log('dart provider process reuse test passed'); + }); +} finally { + await restorePath(); +} + diff --git a/tests/tooling/lsp/dedicated-provider-bootstrap-matrix.test.js b/tests/tooling/lsp/dedicated-provider-bootstrap-matrix.test.js new file mode 100644 index 000000000..649a36251 --- /dev/null +++ b/tests/tooling/lsp/dedicated-provider-bootstrap-matrix.test.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { runDedicatedProviderMatrixCase } from './helpers/dedicated-provider-matrix-case.js'; + +const root = process.cwd(); + +const cases = [ + { + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + fixtureName: 'csharp-provider-bootstrap-matrix', + fixtureCommand: 'csharp-ls', + directories: ['src'], + files: [{ path: 'App.csproj', content: '' }], + docText: 'class App { string Greet(string name) => name; }\n', + virtualPath: 'src/App.cs', + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet', + assertPayload: (payload) => { + assert.equal(payload?.returnType, 'string', 'expected parsed C# return type'); + assert.equal(payload?.paramTypes?.name?.[0]?.type, 'string', 'expected parsed C# param type'); + } + }, + { + providerId: 'dart', + providerConfigKey: 'dart', + fixtureName: 'dart-provider-bootstrap-matrix', + fixtureCommand: 'dart', + directories: ['lib'], + files: [{ path: 'pubspec.yaml', content: 'name: dart_fixture\n' }], + docText: 'String greet(String name) { return name; }\n', + virtualPath: 'lib/app.dart', + languageId: 'dart', + effectiveExt: '.dart', + symbolName: 'greet', + assertPayload: (payload) => { + assert.equal(payload?.returnType, 'String', 'expected parsed Dart return type'); + assert.equal(payload?.paramTypes?.name?.[0]?.type, 'String', 'expected parsed Dart param type'); + } + }, + { + providerId: 'elixir-ls', + providerConfigKey: 'elixir', + fixtureName: 'elixir-provider-bootstrap-matrix', + fixtureCommand: 'elixir-ls', + directories: ['lib'], + files: [{ path: 'mix.exs', content: 'defmodule Sample.MixProject do\nend\n' }], + docText: 'defmodule Sample do\n def greet(name), do: name\nend\n', + virtualPath: 'lib/sample.ex', + languageId: 'elixir', + effectiveExt: '.ex', + symbolName: 'greet', + assertPayload: (payload) => { + assert.equal(payload?.returnType, 'String.t()', 'expected parsed Elixir return type'); + assert.equal(payload?.paramTypes?.name?.[0]?.type, 'String.t()', 'expected parsed Elixir param type'); + } + }, + { + providerId: 'haskell-language-server', + providerConfigKey: 'haskell', + fixtureName: 'haskell-provider-bootstrap-matrix', + fixtureCommand: 'haskell-language-server', + directories: ['src'], + files: [{ path: 'stack.yaml', content: 'resolver: lts-22.0\n' }], + docText: 'greet :: Text -> Text\ngreet name = name\n', + virtualPath: 'src/Main.hs', + languageId: 'haskell', + effectiveExt: '.hs', + symbolName: 'greet', + assertPayload: (payload) => { + assert.equal(payload?.returnType, 'Text', 'expected parsed Haskell return type'); + assert.equal(payload?.paramTypes?.arg1?.[0]?.type, 'Text', 'expected parsed Haskell param type'); + } + }, + { + providerId: 'phpactor', + providerConfigKey: 'phpactor', + fixtureName: 'phpactor-provider-bootstrap-matrix', + fixtureCommand: 'phpactor', + directories: ['src'], + files: [{ path: 'composer.json', content: '{"name":"fixture/php"}\n' }], + docText: ' { + assert.equal(payload?.returnType, 'string', 'expected parsed PHP return type'); + assert.equal(payload?.paramTypes?.name?.[0]?.type, 'string', 'expected parsed PHP param type'); + } + } +]; + +await withLspTestPath({ repoRoot: root }, async () => { + for (const entry of cases) { + const { result, inputs } = await runDedicatedProviderMatrixCase({ root, entry }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, `expected ${entry.providerId} to enrich its symbol`); + const hit = result.byChunkUid.get(inputs.chunkUid); + entry.assertPayload(hit?.payload); + const providerDiag = result.diagnostics?.[entry.providerId] || null; + assert.ok(providerDiag && providerDiag.runtime, `expected runtime diagnostics for ${entry.providerId}`); + } +}); + +console.log('dedicated provider bootstrap matrix test passed'); diff --git a/tests/tooling/lsp/dedicated-provider-command-fallback-matrix.test.js b/tests/tooling/lsp/dedicated-provider-command-fallback-matrix.test.js new file mode 100644 index 000000000..58fa5129d --- /dev/null +++ b/tests/tooling/lsp/dedicated-provider-command-fallback-matrix.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { runDedicatedProviderMatrixCase } from './helpers/dedicated-provider-matrix-case.js'; + +const root = process.cwd(); + +const cases = [ + { + providerId: 'elixir-ls', + providerConfigKey: 'elixir', + fixtureName: 'elixir-provider-command-fallback-matrix', + directories: ['lib'], + files: [{ path: 'mix.exs', content: 'defmodule Sample.MixProject do\nend\n' }], + docText: 'defmodule Sample do\n def greet(name), do: name\nend\n', + virtualPath: 'lib/sample.ex', + languageId: 'elixir', + effectiveExt: '.ex', + symbolName: 'greet', + unavailableCommand: 'elixir-ls-not-found', + expectedCheckName: 'elixir_command_unavailable' + }, + { + providerId: 'haskell-language-server', + providerConfigKey: 'haskell', + fixtureName: 'haskell-provider-command-fallback-matrix', + directories: ['src'], + files: [{ path: 'stack.yaml', content: 'resolver: lts-22.0\n' }], + docText: 'greet :: Text -> Text\ngreet name = name\n', + virtualPath: 'src/Main.hs', + languageId: 'haskell', + effectiveExt: '.hs', + symbolName: 'greet', + unavailableCommand: 'haskell-language-server-not-found', + expectedCheckName: 'haskell_command_unavailable' + }, + { + providerId: 'phpactor', + providerConfigKey: 'phpactor', + fixtureName: 'phpactor-provider-command-fallback-matrix', + directories: ['src'], + files: [{ path: 'composer.json', content: '{"name":"fixture/php"}\n' }], + docText: ' { + for (const entry of cases) { + const { result, inputs } = await runDedicatedProviderMatrixCase({ + root, + entry, + providerConfig: { + cmd: entry.unavailableCommand + } + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, `expected fail-open fallback for ${entry.providerId}`); + const checks = result.diagnostics?.[entry.providerId]?.checks || []; + assert.equal( + checks.some((check) => check?.name === entry.expectedCheckName), + true, + `expected command unavailable warning for ${entry.providerId}` + ); + } +}); + +console.log('dedicated provider command fallback matrix test passed'); diff --git a/tests/tooling/lsp/dedicated-provider-launch-guard-matrix.test.js b/tests/tooling/lsp/dedicated-provider-launch-guard-matrix.test.js new file mode 100644 index 000000000..e8173cd86 --- /dev/null +++ b/tests/tooling/lsp/dedicated-provider-launch-guard-matrix.test.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); + +const makeCase = (config) => config; + +const cases = [ + makeCase({ + name: 'jdtls blocks invalid launch contracts', + repo: { directories: ['src'], files: [{ path: 'pom.xml', content: '' }] }, + providerId: 'jdtls', + providerConfigKey: 'jdtls', + docText: 'class App { String greet(String name) { return name; } }\n', + virtualPath: 'src/App.java', + languageId: 'java', + effectiveExt: '.java', + symbolName: 'greet', + providerConfig(tempRoot) { + return { + cmd: resolveLspFixtureCommand('jdtls', { repoRoot: root }), + args: ['-configuration'] + }; + }, + assertDiagnostics(result, inputs) { + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected jdtls provider to be blocked by invalid launch contract'); + const diagnostics = result.diagnostics?.jdtls || {}; + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.preflight?.reasonCode, 'jdtls_launch_contract_invalid'); + assert.equal((diagnostics?.checks || []).some((check) => check?.name === 'jdtls_launch_contract_invalid'), true); + } + }), + makeCase({ + name: 'csharp blocks invalid dotnet launch contracts', + repo: { directories: ['src'], files: [{ path: 'sample.sln', content: 'Microsoft Visual Studio Solution File\n' }] }, + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + docText: 'class App { string Greet(string name) => name; }\n', + virtualPath: 'src/App.cs', + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet', + providerConfig() { + return { cmd: 'dotnet', args: [] }; + }, + assertDiagnostics(result, inputs) { + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected csharp provider to be blocked by invalid dotnet launch contract'); + const diagnostics = result.diagnostics?.['csharp-ls'] || {}; + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.preflight?.reasonCode, 'csharp_launch_contract_invalid'); + assert.equal((diagnostics?.checks || []).some((check) => check?.name === 'csharp_launch_contract_invalid'), true); + } + }), + makeCase({ + name: 'csharp blocks missing launcher assemblies', + repo: { directories: ['src'], files: [{ path: 'sample.sln', content: 'Microsoft Visual Studio Solution File\n' }] }, + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + docText: 'class App { string Greet(string name) => name; }\n', + virtualPath: 'src/App.cs', + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet', + providerConfig() { + return { cmd: 'dotnet', args: ['tools/csharp-ls.dll'] }; + }, + assertDiagnostics(result, inputs) { + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected csharp provider to be blocked when dotnet launcher assembly is missing'); + const diagnostics = result.diagnostics?.['csharp-ls'] || {}; + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.preflight?.reasonCode, 'csharp_launch_bootstrap_missing'); + assert.equal((diagnostics?.checks || []).some((check) => check?.name === 'csharp_launch_bootstrap_missing'), true); + } + }), + makeCase({ + name: 'jdtls blocks launch script mismatches', + repo: { directories: ['src'], files: [{ path: 'pom.xml', content: '' }] }, + providerId: 'jdtls', + providerConfigKey: 'jdtls', + docText: 'class App { String greet(String name) { return name; } }\n', + virtualPath: 'src/App.java', + languageId: 'java', + effectiveExt: '.java', + symbolName: 'greet', + providerConfig() { + return { cmd: 'java', args: ['-Xmx512m'] }; + }, + assertDiagnostics(result, inputs) { + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected jdtls provider to be blocked by launch script mismatch'); + const diagnostics = result.diagnostics?.jdtls || {}; + assert.equal(diagnostics?.preflight?.state, 'blocked'); + assert.equal(diagnostics?.preflight?.reasonCode, 'jdtls_launch_script_mismatch'); + assert.equal((diagnostics?.checks || []).some((check) => check?.name === 'jdtls_launch_script_mismatch'), true); + } + }), + makeCase({ + name: 'jdtls blocks unavailable workspace locks', + repo: { directories: ['src'], files: [{ path: 'pom.xml', content: '' }] }, + providerId: 'jdtls', + providerConfigKey: 'jdtls', + docText: 'class App { int add(int a, int b) { return a + b; } }\n', + virtualPath: 'src/App.java', + languageId: 'java', + effectiveExt: '.java', + symbolName: 'add', + async providerConfig(tempRoot) { + const workspaceDataDir = path.join(tempRoot, '.jdtls-workspace'); + const workspaceLockPath = path.join(workspaceDataDir, '.workspace.runtime.lock.json'); + const lock = await acquireFileLock({ + lockPath: workspaceLockPath, + waitMs: 0, + pollMs: 25, + staleMs: 60 * 1000, + metadata: { scope: 'test-jdtls-lock-holder' }, + forceStaleCleanup: true + }); + assert.ok(lock, 'expected to acquire workspace lock fixture'); + return { + config: { + cmd: resolveLspFixtureCommand('jdtls', { repoRoot: root }), + workspaceDataDir + }, + cleanup: async () => lock.release() + }; + }, + assertDiagnostics(result, inputs) { + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, 'expected jdtls provider to be blocked by workspace lock'); + const diagnostics = result.diagnostics?.jdtls || {}; + assert.equal((diagnostics?.checks || []).some((check) => check?.name === 'jdtls_workspace_lock_unavailable'), true); + } + }) +]; + +await withLspTestPath({ repoRoot: root }, async () => { + for (const [index, testCase] of cases.entries()) { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: `dedicated-provider-launch-guard-${index}`, + directories: testCase.repo.directories, + files: testCase.repo.files + }); + const inputs = buildSingleSymbolInputs({ + scenarioName: `dedicated-provider-launch-guard-${index}`, + virtualPath: testCase.virtualPath, + text: testCase.docText, + languageId: testCase.languageId, + effectiveExt: testCase.effectiveExt, + symbolName: testCase.symbolName + }); + + let cleanup = async () => {}; + try { + const configResult = await testCase.providerConfig(tempRoot); + const providerConfig = configResult?.config || configResult; + cleanup = configResult?.cleanup || cleanup; + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: testCase.providerId, + providerConfigKey: testCase.providerConfigKey, + providerConfig, + inputs + }); + testCase.assertDiagnostics(result, inputs); + } finally { + await cleanup(); + } + } +}); + +console.log('Dedicated provider launch guard matrix test passed'); diff --git a/tests/tooling/lsp/dedicated-provider-preflight-blocking.test.js b/tests/tooling/lsp/dedicated-provider-preflight-blocking.test.js new file mode 100644 index 000000000..4825f965c --- /dev/null +++ b/tests/tooling/lsp/dedicated-provider-preflight-blocking.test.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createDedicatedLspProvider } from '../../../src/index/tooling/dedicated-lsp-provider.js'; + +const baseDescriptor = { + id: 'fixture-dedicated', + label: 'fixture dedicated provider', + priority: 1, + languages: ['fixture'], + configKey: 'fixtureDedicated', + docExtensions: ['.fixture'], + command: { + defaultCmd: 'missing-fixture-cmd' + }, + parseSignature: () => null +}; + +const providerWithoutPreflight = createDedicatedLspProvider(baseDescriptor); +assert.equal( + typeof providerWithoutPreflight.preflight, + 'undefined', + 'expected dedicated provider without descriptor.preflight to omit preflight hook' +); + +const providerWithWorkspacePreflight = createDedicatedLspProvider({ + ...baseDescriptor, + id: 'fixture-dedicated-workspace', + workspace: { + markerOptions: { + exactNames: ['fixture.workspace'] + }, + missingCheck: { + name: 'fixture_workspace_model_missing', + message: 'fixture workspace markers missing.' + } + } +}); +assert.equal( + typeof providerWithWorkspacePreflight.preflight, + 'function', + 'expected dedicated provider with workspace model to expose preflight hook' +); + +const providerWithRuntimeRequirementPreflight = createDedicatedLspProvider({ + ...baseDescriptor, + id: 'fixture-dedicated-runtime-req', + command: { + defaultCmd: process.execPath + }, + preflightRuntimeRequirements: [{ + id: 'missing-runtime', + cmd: 'definitely-missing-runtime-preflight-command', + args: ['--version'], + label: 'Missing Runtime' + }] +}); +assert.equal( + typeof providerWithRuntimeRequirementPreflight.preflight, + 'function', + 'expected dedicated provider with runtime requirements to expose preflight hook' +); + +let preflightCalls = 0; +const provider = createDedicatedLspProvider({ + ...baseDescriptor, + id: 'fixture-dedicated-preflight', + preflightId: 'fixture-dedicated.workspace-bootstrap', + preflight: async () => { + preflightCalls += 1; + return { + state: 'blocked', + reasonCode: 'fixture_preflight_blocked', + blockProvider: true, + check: { + name: 'fixture_preflight_blocked', + status: 'warn', + message: 'fixture preflight blocked provider.' + } + }; + } +}); + +const ctx = { + repoRoot: process.cwd(), + buildRoot: process.cwd(), + toolingConfig: { + fixtureDedicated: { + enabled: true + } + }, + logger: () => {} +}; + +const result = await provider.run(ctx, { + documents: [{ + virtualPath: 'src/app.fixture', + languageId: 'fixture', + docHash: 'hash-1' + }], + targets: [{ + virtualPath: 'src/app.fixture', + chunkRef: { + chunkUid: 'chunk-1', + chunkId: 'chunk-1', + file: 'src/app.fixture' + } + }], + toolingPreflightWaveToken: 'fixture-wave' +}); + +assert.equal(preflightCalls, 1, 'expected preflight to run once'); +assert.deepEqual(result.byChunkUid, {}, 'expected blocked preflight to return base empty output'); +const checks = Array.isArray(result?.diagnostics?.checks) ? result.diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'fixture_preflight_blocked'), + true, + 'expected blocked preflight check to surface in diagnostics' +); +assert.equal( + checks.some((check) => check?.name === 'fixture-dedicated-preflight_command_unavailable'), + false, + 'expected command probe checks to be skipped when preflight blocks provider early' +); + +const workspaceBlocked = await providerWithWorkspacePreflight.run({ + ...ctx, + toolingConfig: { + fixtureDedicated: { + enabled: true + } + } +}, { + documents: [{ + virtualPath: 'src/ws.fixture', + languageId: 'fixture', + docHash: 'hash-ws-1' + }], + targets: [{ + virtualPath: 'src/ws.fixture', + chunkRef: { + chunkUid: 'chunk-ws-1', + chunkId: 'chunk-ws-1', + file: 'src/ws.fixture' + } + }], + toolingPreflightWaveToken: 'workspace-wave' +}); +assert.deepEqual(workspaceBlocked.byChunkUid, {}, 'expected workspace preflight to block provider output'); +const workspaceChecks = Array.isArray(workspaceBlocked?.diagnostics?.checks) + ? workspaceBlocked.diagnostics.checks + : []; +assert.equal( + workspaceChecks.some((check) => check?.name === 'fixture_workspace_model_missing'), + true, + 'expected workspace preflight missing marker check' +); +assert.equal( + workspaceChecks.some((check) => check?.name === 'fixture-dedicated-workspace_command_unavailable'), + false, + 'expected command checks to be skipped when workspace preflight blocks provider' +); + +const runtimeRequirementPreflight = await providerWithRuntimeRequirementPreflight.preflight({ + ...ctx, + toolingConfig: { + fixtureDedicated: { + enabled: true + } + } +}, {}); +assert.equal(runtimeRequirementPreflight?.state, 'degraded', 'expected runtime requirement preflight to degrade'); +const runtimeRequirementChecks = Array.isArray(runtimeRequirementPreflight?.checks) + ? runtimeRequirementPreflight.checks + : []; +assert.equal( + runtimeRequirementChecks.some((check) => String(check?.name || '').includes('_runtime_missing-runtime_missing')), + true, + 'expected runtime requirement missing check from dedicated preflight' +); +assert.equal( + runtimeRequirementPreflight?.reasonCode, + 'preflight_runtime_requirement_missing', + 'expected runtime requirement missing reason code from dedicated preflight' +); + +console.log('dedicated provider preflight blocking test passed'); diff --git a/tests/tooling/lsp/dedicated-provider-workspace-guard-matrix.test.js b/tests/tooling/lsp/dedicated-provider-workspace-guard-matrix.test.js new file mode 100644 index 000000000..d3be60e95 --- /dev/null +++ b/tests/tooling/lsp/dedicated-provider-workspace-guard-matrix.test.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); + +const cases = [ + { + providerId: 'csharp-ls', + providerConfigKey: 'csharp', + fixtureName: 'csharp-provider-guard-matrix', + directories: ['src'], + docText: 'class App { string Greet(string name) => name; }\n', + virtualPath: 'src/App.cs', + languageId: 'csharp', + effectiveExt: '.cs', + symbolName: 'Greet', + expectedCheckName: 'csharp_workspace_model_missing' + }, + { + providerId: 'dart', + providerConfigKey: 'dart', + fixtureName: 'dart-provider-guard-matrix', + directories: ['lib'], + docText: 'String greet(String name) { return name; }\n', + virtualPath: 'lib/app.dart', + languageId: 'dart', + effectiveExt: '.dart', + symbolName: 'greet', + expectedCheckName: 'dart_workspace_model_missing' + }, + { + providerId: 'elixir-ls', + providerConfigKey: 'elixir', + fixtureName: 'elixir-provider-guard-matrix', + directories: ['lib'], + docText: 'defmodule Sample do\n def greet(name), do: name\nend\n', + virtualPath: 'lib/sample.ex', + languageId: 'elixir', + effectiveExt: '.ex', + symbolName: 'greet', + expectedCheckName: 'elixir_workspace_model_missing' + }, + { + providerId: 'haskell-language-server', + providerConfigKey: 'haskell', + fixtureName: 'haskell-provider-guard-matrix', + directories: ['src'], + docText: 'greet :: Text -> Text\ngreet name = name\n', + virtualPath: 'src/Main.hs', + languageId: 'haskell', + effectiveExt: '.hs', + symbolName: 'greet', + expectedCheckName: 'haskell_workspace_model_missing' + }, + { + providerId: 'jdtls', + providerConfigKey: 'jdtls', + fixtureName: 'jdtls-provider-guard-matrix', + directories: ['src'], + docText: 'class App { int add(int a, int b) { return a + b; } }\n', + virtualPath: 'src/App.java', + languageId: 'java', + effectiveExt: '.java', + symbolName: 'add', + expectedCheckName: 'jdtls_workspace_model_missing' + }, + { + providerId: 'phpactor', + providerConfigKey: 'phpactor', + fixtureName: 'phpactor-provider-guard-matrix', + directories: ['src'], + docText: ' { + for (const entry of cases) { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: entry.fixtureName, + directories: entry.directories + }); + const inputs = buildSingleSymbolInputs({ + scenarioName: entry.fixtureName, + virtualPath: entry.virtualPath, + text: entry.docText, + languageId: entry.languageId, + effectiveExt: entry.effectiveExt, + symbolName: entry.symbolName + }); + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: entry.providerId, + providerConfigKey: entry.providerConfigKey, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), false, `expected workspace guard to skip ${entry.providerId}`); + const checks = Array.isArray(result.diagnostics?.[entry.providerId]?.checks) + ? result.diagnostics[entry.providerId].checks + : []; + assert.equal( + checks.some((check) => check?.name === entry.expectedCheckName), + true, + `expected workspace model guard check for ${entry.providerId}` + ); + } +}); + +console.log('dedicated provider workspace guard matrix test passed'); diff --git a/tests/tooling/lsp/dedicated-providers-abrupt-failure-cleanup.test.js b/tests/tooling/lsp/dedicated-providers-abrupt-failure-cleanup.test.js new file mode 100644 index 000000000..fb7b7e839 --- /dev/null +++ b/tests/tooling/lsp/dedicated-providers-abrupt-failure-cleanup.test.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { resetTrackedSubprocessEvents, snapshotTrackedSubprocessEvents } from '../../../src/shared/subprocess/tracking.js'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { cleanupLspTestRuntime } from '../../helpers/lsp-runtime.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const providerCases = [ + { + providerId: 'jdtls', + configKey: 'jdtls', + languageId: 'java', + ext: '.java', + markerPath: 'pom.xml', + markerContent: '\n', + symbolName: 'add', + text: 'class Example { int add(int a, int b) { return a + b; } }\n' + }, + { + providerId: 'csharp-ls', + configKey: 'csharp', + languageId: 'csharp', + ext: '.cs', + markerPath: 'sample.sln', + markerContent: 'Microsoft Visual Studio Solution File\n', + symbolName: 'add', + text: 'class Example { int Add(int a, int b) { return a + b; } }\n' + }, + { + providerId: 'elixir-ls', + configKey: 'elixir', + languageId: 'elixir', + ext: '.ex', + markerPath: 'mix.exs', + markerContent: 'defmodule Sample.MixProject do\nend\n', + symbolName: 'greet', + text: 'defmodule Example do\n def greet(name), do: name\nend\n' + }, + { + providerId: 'solargraph', + configKey: 'solargraph', + languageId: 'ruby', + ext: '.rb', + markerPath: 'Gemfile', + markerContent: "source 'https://rubygems.org'\n", + symbolName: 'greet', + text: 'def greet(name)\n name\nend\n' + }, + { + providerId: 'phpactor', + configKey: 'phpactor', + languageId: 'php', + ext: '.php', + markerPath: 'composer.json', + markerContent: '{"name":"fixture/php"}\n', + symbolName: 'greet', + text: ' new Set( + (result?.diagnostics?.[providerId]?.checks || []) + .map((check) => String(check?.name || '').trim()) + .filter(Boolean) +); + +const waitForNoTrackedSubprocesses = async (timeoutMs = 2000) => { + const startedAt = Date.now(); + while ((Date.now() - startedAt) < timeoutMs) { + if (getTrackedSubprocessCount() === 0) return true; + await sleep(50); + } + return getTrackedSubprocessCount() === 0; +}; + +const runTimeoutScenario = async (providerCase) => { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: `dedicated-provider-timeout-cleanup-${providerCase.providerId}-${Date.now()}`, + directories: ['src'], + files: [ + { + path: providerCase.markerPath, + content: providerCase.markerContent + } + ] + }); + const virtualPath = `src/main${providerCase.ext}`; + const inputs = buildSingleSymbolInputs({ + scenarioName: `dedicated-timeout-${providerCase.providerId}`, + virtualPath, + text: providerCase.text, + languageId: providerCase.languageId, + effectiveExt: providerCase.ext, + symbolName: providerCase.symbolName + }); + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: providerCase.providerId, + providerConfigKey: providerCase.configKey, + providerConfig: { + enabled: true, + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-initialize'], + timeoutMs: 300, + retries: 0, + breakerThreshold: 1 + }, + inputs + }); + assert.equal(result.byChunkUid.size, 0, `expected no enriched symbols for timeout scenario (${providerCase.providerId})`); + const names = checkNames(result, providerCase.providerId); + const hasTimeoutOrInitFailure = names.has('tooling_initialize_failed') + || names.has(`${providerCase.providerId}_provider_execution_failed`); + assert.equal( + hasTimeoutOrInitFailure, + true, + `expected initialize/provider failure check for timeout scenario (${providerCase.providerId})` + ); +}; + +const runAbortScenario = async (providerCase) => { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: `dedicated-provider-abort-cleanup-${providerCase.providerId}-${Date.now()}`, + directories: ['src'], + files: [ + { + path: providerCase.markerPath, + content: providerCase.markerContent + } + ] + }); + const virtualPath = `src/main${providerCase.ext}`; + const inputs = buildSingleSymbolInputs({ + scenarioName: `dedicated-abort-${providerCase.providerId}`, + virtualPath, + text: providerCase.text, + languageId: providerCase.languageId, + effectiveExt: providerCase.ext, + symbolName: providerCase.symbolName + }); + const controller = new AbortController(); + const abortTimer = setTimeout(() => controller.abort(), 60); + try { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: providerCase.providerId, + providerConfigKey: providerCase.configKey, + providerConfig: { + enabled: true, + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-initialize'], + timeoutMs: 1000, + retries: 0, + breakerThreshold: 1 + }, + inputs, + ctxOverrides: { + abortSignal: controller.signal + } + }); + assert.equal(result.byChunkUid.size, 0, `expected no enriched symbols for abort scenario (${providerCase.providerId})`); + const names = checkNames(result, providerCase.providerId); + const hasAbortOrInitFailure = names.has('tooling_initialize_failed') + || names.has(`${providerCase.providerId}_provider_execution_failed`); + assert.equal( + hasAbortOrInitFailure, + true, + `expected initialize/provider failure check for abort scenario (${providerCase.providerId})` + ); + } finally { + clearTimeout(abortTimer); + } +}; + +resetTrackedSubprocessEvents(); +for (const providerCase of providerCases) { + await runTimeoutScenario(providerCase); + await runAbortScenario(providerCase); +} + +const noTracked = await waitForNoTrackedSubprocesses(); +assert.equal(noTracked, true, 'expected dedicated provider abrupt-failure scenarios to leave no tracked subprocesses'); + +const trackedSnapshot = snapshotTrackedSubprocessEvents({ limit: 5000 }); +const trackedEvents = Array.isArray(trackedSnapshot?.events) ? trackedSnapshot.events : []; +const stallTrackedEvents = trackedEvents.filter((event) => ( + event?.kind === 'process_spawned' +)); +assert.equal( + stallTrackedEvents.length > 0, + true, + 'expected tracked subprocess lifecycle events during dedicated-provider timeout/abort scenarios' +); +const lifecycleEventsByPid = new Map(); +for (const event of trackedEvents) { + const pid = Number(event?.pid); + if (!Number.isFinite(pid)) continue; + if (!lifecycleEventsByPid.has(pid)) lifecycleEventsByPid.set(pid, []); + lifecycleEventsByPid.get(pid).push(event); +} +for (const trackedEvent of stallTrackedEvents) { + const pid = Number(trackedEvent?.pid); + const pidEvents = lifecycleEventsByPid.get(pid) || []; + const hasTerminalEvent = pidEvents.some((event) => ( + event?.kind === 'process_reaped' + || event?.kind === 'process_untracked' + )); + assert.equal( + hasTerminalEvent, + true, + `expected tracked stall process pid=${pid} to emit terminal tracked-subprocess lifecycle events` + ); +} + +const cleanupSummary = await cleanupLspTestRuntime({ + reason: 'dedicated_provider_abrupt_failure_cleanup', + strict: true +}); +assert.equal( + Number(cleanupSummary?.trackedCleanup?.attempted || 0), + 0, + 'expected no residual tracked subprocess reaping after dedicated-provider abrupt-failure scenarios' +); + +console.log('dedicated providers abrupt-failure cleanup test passed'); diff --git a/tests/tooling/lsp/dedicated-providers-multifile-session-reuse.test.js b/tests/tooling/lsp/dedicated-providers-multifile-session-reuse.test.js new file mode 100644 index 000000000..4cbc53483 --- /dev/null +++ b/tests/tooling/lsp/dedicated-providers-multifile-session-reuse.test.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; + +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { skip } from '../../helpers/skip.js'; +import { prependLspTestPath, probeLspCommandForTest } from '../../helpers/lsp-runtime.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `dedicated-providers-multifile-reuse-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const restorePath = prependLspTestPath({ repoRoot: root }); +const fixturePathMarker = path.join('tests', 'fixtures', 'lsp', 'bin').toLowerCase(); +let skipReason = null; + +const providerCases = [ + { + providerId: 'jdtls', + configKey: 'jdtls', + languageId: 'java', + ext: '.java', + symbolName: 'add', + markerPath: 'pom.xml', + markerContent: '\n', + textOne: 'class A { int add(int a, int b) { return a + b; } }\n', + textTwo: 'class B { int add(int a, int b) { return a + b; } }\n' + }, + { + providerId: 'csharp-ls', + configKey: 'csharp', + languageId: 'csharp', + ext: '.cs', + symbolName: 'Greet', + markerPath: 'sample.sln', + markerContent: 'Microsoft Visual Studio Solution File\n', + textOne: 'public class A { string Greet(string name) => name; }\n', + textTwo: 'public class B { string Greet(string name) => name; }\n' + }, + { + providerId: 'solargraph', + configKey: 'solargraph', + languageId: 'ruby', + ext: '.rb', + symbolName: 'greet', + markerPath: 'Gemfile', + markerContent: "source 'https://rubygems.org'\n", + textOne: 'def greet(name)\n name\nend\n', + textTwo: 'def greet(name)\n name\nend\n' + }, + { + providerId: 'elixir-ls', + configKey: 'elixir', + languageId: 'elixir', + ext: '.ex', + symbolName: 'greet', + markerPath: 'mix.exs', + markerContent: 'defmodule Sample.MixProject do\nend\n', + textOne: 'defmodule A do\n def greet(name), do: name\nend\n', + textTwo: 'defmodule B do\n def greet(name), do: name\nend\n' + }, + { + providerId: 'phpactor', + configKey: 'phpactor', + languageId: 'php', + ext: '.php', + symbolName: 'greet', + markerPath: 'composer.json', + markerContent: '{"name":"fixture/php"}\n', + textOne: ' Text\ngreet name = name\n', + textTwo: 'greet :: Text -> Text\ngreet name = name\n' + }, + { + providerId: 'dart', + configKey: 'dart', + languageId: 'dart', + ext: '.dart', + symbolName: 'greet', + markerPath: 'pubspec.yaml', + markerContent: 'name: dart_fixture\n', + textOne: 'String greet(String name) { return name; }\n', + textTwo: 'String greet(String name) { return name; }\n' + } +]; + +const runCase = async (providerCase) => { + const profile = probeLspCommandForTest({ + providerId: providerCase.providerId, + repoRoot: tempRoot + }); + if (!profile?.probe?.ok) { + return { skipped: true, validated: false }; + } + const resolvedCmd = String(profile?.resolved?.cmd || '').toLowerCase(); + const usesFixtureRuntime = resolvedCmd.includes(fixturePathMarker); + const caseRoot = path.join(tempRoot, providerCase.providerId.replace(/[^a-z0-9_-]+/gi, '-')); + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(caseRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(caseRoot, providerCase.markerPath), providerCase.markerContent, 'utf8'); + const counterPath = path.join(caseRoot, 'spawn.counter'); + + const virtualPathOne = `src/one${providerCase.ext}`; + const virtualPathTwo = `src/two${providerCase.ext}`; + const chunkUidOne = `ck64:v1:test:${virtualPathOne}:${providerCase.providerId}:one`; + const chunkUidTwo = `ck64:v1:test:${virtualPathTwo}:${providerCase.providerId}:two`; + const config = { + enabledTools: [providerCase.providerId], + [providerCase.configKey]: { + enabled: true + } + }; + + const result = await withTemporaryEnv({ POC_LSP_COUNTER: counterPath }, async () => runToolingProviders({ + strict: true, + repoRoot: caseRoot, + buildRoot: caseRoot, + toolingConfig: config, + cache: { + enabled: false + } + }, { + documents: [{ + virtualPath: virtualPathOne, + text: providerCase.textOne, + languageId: providerCase.languageId, + effectiveExt: providerCase.ext, + docHash: `${providerCase.providerId}-one` + }, { + virtualPath: virtualPathTwo, + text: providerCase.textTwo, + languageId: providerCase.languageId, + effectiveExt: providerCase.ext, + docHash: `${providerCase.providerId}-two` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: chunkUidOne, + chunkId: `chunk_${providerCase.providerId}_one`, + file: virtualPathOne, + segmentUid: null, + segmentId: null, + range: { start: 0, end: providerCase.textOne.length } + }, + virtualPath: virtualPathOne, + virtualRange: { start: 0, end: providerCase.textOne.length }, + symbolHint: { name: providerCase.symbolName, kind: 'function' }, + languageId: providerCase.languageId + }, { + chunkRef: { + docId: 1, + chunkUid: chunkUidTwo, + chunkId: `chunk_${providerCase.providerId}_two`, + file: virtualPathTwo, + segmentUid: null, + segmentId: null, + range: { start: 0, end: providerCase.textTwo.length } + }, + virtualPath: virtualPathTwo, + virtualRange: { start: 0, end: providerCase.textTwo.length }, + symbolHint: { name: providerCase.symbolName, kind: 'function' }, + languageId: providerCase.languageId + }], + kinds: ['types'] + })); + + const firstHit = result.byChunkUid.has(chunkUidOne); + const secondHit = result.byChunkUid.has(chunkUidTwo); + const providerDiag = result.diagnostics?.[providerCase.providerId] || null; + + if (usesFixtureRuntime) { + assert.equal(firstHit, true, `expected ${providerCase.providerId} hit for first file`); + assert.equal(secondHit, true, `expected ${providerCase.providerId} hit for second file`); + const spawnCount = await countNonEmptyLines(counterPath); + assert.equal(spawnCount, 1, `expected one LSP spawn for ${providerCase.providerId} multi-file run`); + return { skipped: false, validated: true }; + } + + const initializeRequests = Number( + providerDiag?.runtime?.requests?.byMethod?.initialize?.requests || 0 + ); + const validated = (firstHit && secondHit) || initializeRequests === 1; + return { skipped: false, validated }; +}; + +try { + registerDefaultToolingProviders(); + let validatedCases = 0; + for (const providerCase of providerCases) { + const outcome = await runCase(providerCase); + if (outcome?.validated) validatedCases += 1; + } + if (validatedCases === 0) { + skipReason = 'Skipping dedicated providers multifile session reuse test; no provider yielded a verifiable runtime.'; + } +} finally { + await restorePath(); +} + +if (skipReason) { + skip(skipReason); +} + +console.log('dedicated providers multifile session reuse test passed'); + diff --git a/tests/tooling/lsp/document-symbol-failure-disables-remaining-docs.test.js b/tests/tooling/lsp/document-symbol-failure-disables-remaining-docs.test.js new file mode 100644 index 000000000..799571f3e --- /dev/null +++ b/tests/tooling/lsp/document-symbol-failure-disables-remaining-docs.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-docsymbol-disable-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const docAPath = '.poc-vfs/src/sample-a.cpp#seg:disconnect-docsymbol-a.cpp'; +const docBPath = '.poc-vfs/src/sample-b.cpp#seg:disconnect-docsymbol-b.cpp'; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [ + { + virtualPath: docAPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }, + { + virtualPath: docBPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample-a.cpp:disconnect-docsymbol', + chunkId: 'chunk_disconnect_docsymbol_a', + file: 'src/sample-a.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: docAPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck64:v1:test:src/sample-b.cpp:disconnect-docsymbol', + chunkId: 'chunk_disconnect_docsymbol_b', + file: 'src/sample-b.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: docBPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + } + ], + cmd: process.execPath, + args: [serverPath, '--mode', 'disconnect-on-document-symbol'], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }), + retries: 0, + timeoutMs: 1500, + documentSymbolConcurrency: 1 +}); + +assert.equal(Object.keys(result.byChunkUid).length, 0, 'expected disconnect to fail open'); +assert.equal( + result.checks.some((check) => check?.name === 'tooling_document_symbol_failed'), + true, + 'expected tooling_document_symbol_failed check when LSP disconnects during documentSymbol' +); +assert.equal( + Number(result.runtime?.requests?.byMethod?.['textDocument/documentSymbol']?.requests || 0), + 1, + 'expected documentSymbol collection to stop after the first provider-level failure' +); + +console.log('LSP documentSymbol failure disables remaining docs test passed'); diff --git a/tests/tooling/lsp/elixir-provider-initialize-stall-fail-open.test.js b/tests/tooling/lsp/elixir-provider-initialize-stall-fail-open.test.js new file mode 100644 index 000000000..cd43d38f6 --- /dev/null +++ b/tests/tooling/lsp/elixir-provider-initialize-stall-fail-open.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `elixir-provider-initialize-stall-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'lib'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'mix.exs'), 'defmodule Sample.MixProject do\nend\n', 'utf8'); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +registerDefaultToolingProviders(); +const docText = 'defmodule Sample do\n def greet(name), do: name\nend\n'; +const chunkUid = 'ck64:v1:test:lib/sample.ex:elixir-initialize-stall'; +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['elixir-ls'], + elixir: { + enabled: true, + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-initialize'], + timeoutMs: 1500 + } + }, + cache: { + enabled: false + } +}, { + documents: [{ + virtualPath: 'lib/sample.ex', + text: docText, + languageId: 'elixir', + effectiveExt: '.ex', + docHash: 'hash-elixir-initialize-stall' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_elixir_initialize_stall', + file: 'lib/sample.ex', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: 'lib/sample.ex', + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'elixir' + }], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has(chunkUid), false, 'expected fail-open fallback on initialize stall'); +const checks = result.diagnostics?.['elixir-ls']?.checks || []; +assert.equal( + checks.some((check) => check?.name === 'tooling_initialize_failed'), + true, + 'expected initialize failure check in elixir provider diagnostics' +); + +console.log('elixir provider initialize stall fail-open test passed'); diff --git a/tests/tooling/lsp/elixir-provider-mix-lock-missing-preflight.test.js b/tests/tooling/lsp/elixir-provider-mix-lock-missing-preflight.test.js new file mode 100644 index 000000000..75fe84f26 --- /dev/null +++ b/tests/tooling/lsp/elixir-provider-mix-lock-missing-preflight.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'elixir-provider-mix-lock-missing-preflight', + directories: ['lib'], + files: [{ path: 'mix.exs', content: 'defmodule Sample.MixProject do\nend\n' }] +}); +const fixtureElixirCmd = resolveLspFixtureCommand('elixir-ls', { repoRoot: root }); +const docText = 'defmodule Sample do\n def greet(name), do: name\nend\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'elixir-mix-lock-missing-preflight', + virtualPath: 'lib/sample.ex', + text: docText, + languageId: 'elixir', + effectiveExt: '.ex', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'elixir-ls', + providerConfigKey: 'elixir', + providerConfig: { + cmd: fixtureElixirCmd + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, 'expected elixir provider to continue when mix.lock is missing'); + const diagnostics = result.diagnostics?.['elixir-ls'] || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected elixir preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'elixir_workspace_mix_lock_missing', + 'expected elixir mix.lock-missing reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'elixir_workspace_mix_lock_missing'), + true, + 'expected elixir mix.lock-missing warning check' + ); +}); + +console.log('elixir provider mix.lock missing preflight test passed'); diff --git a/tests/tooling/lsp/elixir-provider-runtime-otp-mismatch-preflight.test.js b/tests/tooling/lsp/elixir-provider-runtime-otp-mismatch-preflight.test.js new file mode 100644 index 000000000..26363b069 --- /dev/null +++ b/tests/tooling/lsp/elixir-provider-runtime-otp-mismatch-preflight.test.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { writeRuntimeCommandFixture } from '../../helpers/runtime-command-fixture.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'elixir-provider-runtime-otp-mismatch-preflight', + directories: ['lib', '.runtime-bin'], + files: [{ path: 'mix.exs', content: 'defmodule Sample.MixProject do\nend\n' }] +}); +const runtimeBinDir = path.join(tempRoot, '.runtime-bin'); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'elixir', + stdout: 'Erlang/OTP 26\nElixir 1.16.1 (compiled with Erlang/OTP 26)\n' +}); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'erl', + stderr: 'Erlang/OTP 25 [erts-13.0]\n' +}); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'mix', + stdout: 'Mix 1.16.1 (compiled with Erlang/OTP 26)\n' +}); + +const fixtureElixirCmd = resolveLspFixtureCommand('elixir-ls', { repoRoot: root }); +const docText = 'defmodule Sample do\n def greet(name), do: name\nend\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'elixir-runtime-otp-mismatch-preflight', + virtualPath: 'lib/sample.ex', + text: docText, + languageId: 'elixir', + effectiveExt: '.ex', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root, extraPrepend: [runtimeBinDir] }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'elixir-ls', + providerConfigKey: 'elixir', + providerConfig: { + cmd: fixtureElixirCmd + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, 'expected elixir provider to fail-open on OTP mismatch'); + const diagnostics = result.diagnostics?.['elixir-ls'] || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected elixir preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'elixir_runtime_otp_mismatch', + 'expected elixir runtime OTP mismatch reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'elixir_runtime_otp_mismatch'), + true, + 'expected elixir runtime OTP mismatch warning check' + ); +}); + +console.log('elixir provider runtime OTP mismatch preflight test passed'); diff --git a/tests/tooling/lsp/fallback-reason-codes.test.js b/tests/tooling/lsp/fallback-reason-codes.test.js new file mode 100644 index 000000000..cef41d5b9 --- /dev/null +++ b/tests/tooling/lsp/fallback-reason-codes.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-fallback-reason-codes-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int, int) { return 0; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:fallback-reasons.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:fallback-reasons'; + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (!detail) return null; + if (detail === 'add') { + return { + signature: 'int add(int, int)', + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + if (detail === 'int add(int a, int b)') { + return { + signature: 'int add(int, int)', + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + if (detail === 'int add(int, int)') { + return { + signature: detail, + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + return null; +}; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_fallback_reasons', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'malformed-hover'], + parseSignature, + retries: 0, + timeoutMs: 1500 +}); + +assert.equal( + Number(result?.hoverMetrics?.fallbackUsed || 0) >= 1, + true, + 'expected source fallback usage after incomplete interactive stages' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.missing_param_types >= 1, + true, + 'expected missing_param_types fallback reason' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.hover_unavailable_or_failed >= 1, + true, + 'expected hover_unavailable_or_failed fallback reason' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.signature_help_not_requested >= 1, + true, + 'expected signature_help_not_requested fallback reason' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.definition_not_requested >= 1, + true, + 'expected definition_not_requested fallback reason' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.type_definition_not_requested >= 1, + true, + 'expected type_definition_not_requested fallback reason' +); +assert.equal( + result?.hoverMetrics?.fallbackReasonCounts?.references_not_requested >= 1, + true, + 'expected references_not_requested fallback reason' +); + +console.log('LSP fallback reason code test passed'); diff --git a/tests/tooling/lsp/fallback-runtime-contract-matrix.test.js b/tests/tooling/lsp/fallback-runtime-contract-matrix.test.js new file mode 100644 index 000000000..db8fb49a2 --- /dev/null +++ b/tests/tooling/lsp/fallback-runtime-contract-matrix.test.js @@ -0,0 +1,259 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parsePythonSignature } from '../../../src/index/tooling/signature-parse/python.js'; +import { parseCppTwoIntParamSignature, parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const cppParseSignature = (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: ['add'], + bareReturnType: 'unknown' +}); + +const runTraceCase = async ({ + name, + mode, + docText, + languageId, + effectiveExt, + chunkUid, + range, + symbolHint, + parseSignature, + extraCollect = {}, + assertPayload, + assertTrace +}) => { + const tempRoot = resolveTestCachePath(root, `${name}-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + const tracePath = path.join(tempRoot, 'trace.jsonl'); + const virtualPath = `.poc-vfs/src/${name}${effectiveExt}#seg:${name}${effectiveExt}`; + + let result = null; + try { + await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId, + effectiveExt + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: `chunk_${name.replace(/[^a-z0-9]+/gi, '_')}`, + file: `src/${name}${effectiveExt}`, + segmentUid: null, + segmentId: null, + range + }, + virtualPath, + virtualRange: range, + symbolHint + }], + cmd: process.execPath, + args: [serverPath, '--mode', mode], + parseSignature, + ...extraCollect + }); + }); + + const payload = result.byChunkUid?.[chunkUid]?.payload || null; + assert.ok(payload, `expected payload for ${name}`); + assertPayload(result, payload); + + const traceLines = await parseJsonLinesFile(tracePath); + assertTrace(traceLines, result); + } finally { + // Leave uniquely-named temp roots in place for this matrix to avoid Windows file-handle + // teardown races from masking the actual fallback assertions under suite load. + } +}; + +await runTraceCase({ + name: 'definition-fallback', + mode: 'definition-richer', + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + languageId: 'cpp', + effectiveExt: '.cpp', + chunkUid: 'ck64:v1:test:src/sample.cpp:definition-fallback', + range: (() => { + const text = 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n'; + const start = text.indexOf('add'); + return { start, end: start + 3 }; + })(), + symbolHint: { name: 'add', kind: 'function' }, + parseSignature: cppParseSignature, + assertPayload(result, payload) { + assert.equal(payload.returnType, 'int'); + assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); + assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); + assert.equal(Number(result?.hoverMetrics?.definitionRequested || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.definitionSucceeded || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.fallbackUsed || 0), 0); + }, + assertTrace(traceLines) { + const definitionCalls = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/definition').length; + assert.equal(definitionCalls >= 1, true); + } +}); + +await runTraceCase({ + name: 'references-fallback', + mode: 'references-richer', + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + languageId: 'cpp', + effectiveExt: '.cpp', + chunkUid: 'ck64:v1:test:src/sample.cpp:references-fallback', + range: (() => { + const text = 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n'; + const start = text.indexOf('add'); + return { start, end: start + 3 }; + })(), + symbolHint: { name: 'add', kind: 'function' }, + parseSignature: cppParseSignature, + extraCollect: { + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: true + }, + assertPayload(result, payload) { + assert.equal(payload.returnType, 'int'); + assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); + assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); + assert.equal(Number(result?.hoverMetrics?.referencesRequested || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.referencesSucceeded || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.fallbackUsed || 0), 0); + }, + assertTrace(traceLines) { + const referencesCalls = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/references').length; + assert.equal(referencesCalls >= 1, true); + } +}); + +await runTraceCase({ + name: 'signature-help-fallback', + mode: 'signature-help', + docText: 'const sentinel = 1;\n', + languageId: 'cpp', + effectiveExt: '.cpp', + chunkUid: 'ck64:v1:test:src/sample.cpp:signature-help', + range: { start: 0, end: 'const sentinel = 1;\n'.length }, + symbolHint: { name: 'add', kind: 'function' }, + parseSignature: cppParseSignature, + assertPayload(result, payload) { + assert.equal(payload.returnType, 'int'); + assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); + assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); + assert.equal(Number(result?.hoverMetrics?.signatureHelpRequested || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.signatureHelpSucceeded || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.fallbackUsed || 0), 0); + }, + assertTrace(traceLines) { + const calls = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/signatureHelp').length; + assert.equal(calls >= 1, true); + } +}); + +await runTraceCase({ + name: 'type-definition-fallback', + mode: 'type-definition-richer', + docText: 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n', + languageId: 'cpp', + effectiveExt: '.cpp', + chunkUid: 'ck64:v1:test:src/sample.cpp:type-definition-fallback', + range: (() => { + const text = 'int add(int a, int b) { return a + b; }\nint sentinel = add(1, 2);\n'; + const start = text.indexOf('add'); + return { start, end: start + 3 }; + })(), + symbolHint: { name: 'add', kind: 'function' }, + parseSignature: cppParseSignature, + extraCollect: { + definitionEnabled: false, + typeDefinitionEnabled: true + }, + assertPayload(result, payload) { + assert.equal(payload.returnType, 'int'); + assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); + assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); + assert.equal(Number(result?.hoverMetrics?.definitionRequested || 0), 0); + assert.equal(Number(result?.hoverMetrics?.typeDefinitionRequested || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.typeDefinitionSucceeded || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.fallbackUsed || 0), 0); + }, + assertTrace(traceLines) { + const calls = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/typeDefinition').length; + assert.equal(calls >= 1, true); + } +}); + +await runTraceCase({ + name: 'param-fallback-from-source', + mode: 'signature-help', + docText: 'int add(int a, int b) { return a + b; }\n', + languageId: 'cpp', + effectiveExt: '.cpp', + chunkUid: 'ck64:v1:test:src/sample.cpp:feedface', + range: { start: 0, end: 'int add(int a, int b) { return a + b; }\n'.length }, + symbolHint: { name: 'add', kind: 'function' }, + parseSignature: (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: [], + allowUnnamedPrototype: true + }), + assertPayload(result, payload) { + assert.equal(payload.returnType, 'int'); + assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); + assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); + assert.equal(Number(result?.hoverMetrics?.sourceBootstrapUsed || 0) >= 1, true); + assert.equal(Number(result?.hoverMetrics?.fallbackUsed || 0), 0); + }, + assertTrace(traceLines) { + const hoverRequests = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/hover').length; + const signatureHelpRequests = traceLines.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/signatureHelp').length; + assert.equal(hoverRequests, 0); + assert.equal(signatureHelpRequests, 0); + } +}); + +await runTraceCase({ + name: 'pyright-function-symbol-priority', + mode: 'pyright-parameter-shadow', + docText: 'def greet(name: str) -> str:\n return "hi"\n', + languageId: 'python', + effectiveExt: '.py', + chunkUid: 'ck64:v1:test:src/sample.py:function-priority', + range: (() => { + const text = 'def greet(name: str) -> str:\n return "hi"\n'; + const start = text.indexOf('greet'); + return { start, end: start + 5 }; + })(), + symbolHint: { name: 'greet', kind: 'function' }, + parseSignature: (detail) => parsePythonSignature(detail), + extraCollect: { + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false + }, + assertPayload(_result, payload) { + assert.equal(payload.returnType, 'str'); + assert.equal(payload.signature, 'def greet(name: str) -> str'); + assert.notEqual(payload.signature, '(parameter) name: str'); + assert.deepEqual(payload.paramTypes?.name?.map((entry) => entry.type), ['str']); + }, + assertTrace() {} +}); + +console.log('LSP fallback runtime contract matrix test passed'); diff --git a/tests/tooling/lsp/fd-pressure-backoff-contract.test.js b/tests/tooling/lsp/fd-pressure-backoff-contract.test.js new file mode 100644 index 000000000..efa00a010 --- /dev/null +++ b/tests/tooling/lsp/fd-pressure-backoff-contract.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-fd-pressure-backoff-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:fd-pressure.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:fd-pressure'; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_fd_pressure', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'emit-fd-pressure-warning'], + lifecycleFdPressureBackoffMs: 250, + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) +}); + +const checks = Array.isArray(result.checks) ? result.checks : []; +assert.equal( + checks.some((check) => check?.name === 'tooling_fd_pressure_backoff'), + true, + 'expected fd-pressure backoff warning check' +); +assert.equal( + Number(result.runtime?.lifecycle?.fdPressureEvents || 0) >= 1, + true, + 'expected lifecycle fd-pressure event count' +); + +console.log('LSP fd-pressure backoff contract test passed'); diff --git a/tests/tooling/lsp/go-workspace-managed-go-resolution.test.js b/tests/tooling/lsp/go-workspace-managed-go-resolution.test.js new file mode 100644 index 000000000..cbc413377 --- /dev/null +++ b/tests/tooling/lsp/go-workspace-managed-go-resolution.test.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveGoWorkspaceModulePreflight } from '../../../src/index/tooling/preflight/go-workspace-preflight.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `gopls-managed-go-resolution-${process.pid}-${Date.now()}`); +const toolingRoot = path.join(tempRoot, 'tooling-root'); +const toolingBinDir = path.join(toolingRoot, 'bin'); +const logPath = path.join(toolingBinDir, 'go-invocations.log'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); +await fs.mkdir(toolingBinDir, { recursive: true }); +await fs.writeFile( + path.join(tempRoot, 'go.mod'), + 'module example.com/managed-go-resolution\n\ngo 1.22\n', + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'src', 'sample.go'), + 'package main\n\nfunc Sample() int { return 1 }\n', + 'utf8' +); + +const helperPath = path.join(toolingBinDir, 'go-helper.js'); +await fs.writeFile( + helperPath, + [ + "import fs from 'node:fs';", + "import path from 'node:path';", + "const logPath = process.argv[2];", + 'const args = process.argv.slice(3);', + "fs.appendFileSync(logPath, `${JSON.stringify(args)}\\n`, 'utf8');", + "if (args[0] === 'version') {", + " process.stdout.write('go version go1.22.0 managed/test\\n');", + ' process.exit(0);', + '}', + "if (args[0] === 'help') {", + " process.stdout.write('Go help\\n');", + ' process.exit(0);', + '}', + "if (args[0] === 'list' && args[1] === '-m') {", + " process.stdout.write('example.com/managed-go-resolution\\n');", + ' process.exit(0);', + '}', + "if (args[0] === 'list' && args[1] === './...') {", + " process.stdout.write('example.com/managed-go-resolution\\n');", + ' process.exit(0);', + '}', + "process.stderr.write(`unexpected go invocation: ${args.join(' ')}\\n`);", + 'process.exit(19);' + ].join('\n'), + 'utf8' +); + +if (process.platform === 'win32') { + await fs.writeFile( + path.join(toolingBinDir, 'go.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\go-helper.js" "%~dp0\\go-invocations.log" %*\r\n`, + 'utf8' + ); +} else { + await fs.writeFile( + path.join(toolingBinDir, 'go'), + `#!/bin/sh\n'${process.execPath}' "$(dirname "$0")/go-helper.js" "$(dirname "$0")/go-invocations.log" "$@"\n`, + 'utf8' + ); + await fs.chmod(path.join(toolingBinDir, 'go'), 0o755); +} + +await withTemporaryEnv({ + PATH: path.dirname(process.execPath), + Path: path.dirname(process.execPath) +}, async () => { + const result = await resolveGoWorkspaceModulePreflight({ + ctx: { + repoRoot: tempRoot, + toolingConfig: { dir: toolingRoot }, + cache: { dir: null }, + logger: () => {} + }, + server: { + id: 'gopls', + cmd: 'gopls', + languages: ['go'], + goWorkspaceWarmup: true, + goWorkspaceWarmupMinGoFiles: 1 + }, + documents: [{ + virtualPath: '.poc-vfs/src/sample.go#seg:gopls-managed-go-resolution.txt', + path: '.poc-vfs/src/sample.go#seg:gopls-managed-go-resolution.txt', + languageId: 'go' + }] + }); + + assert.equal(result?.state, 'ready', 'expected managed local go shim to satisfy workspace preflight'); + assert.equal(result?.reasonCode, null, 'expected managed local go shim to avoid blocked workspace reason codes'); +}); + +const invocations = (await fs.readFile(logPath, 'utf8')) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + +assert.equal( + invocations.some((args) => Array.isArray(args) && args[0] === 'list' && args[1] === '-m'), + true, + 'expected module probe to run through managed go shim' +); +assert.equal( + invocations.some((args) => Array.isArray(args) && args[0] === 'list' && args[1] === './...'), + true, + 'expected warmup probe to run through managed go shim' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('go workspace managed go resolution test passed'); diff --git a/tests/tooling/lsp/go-workspace-root-scan-fallback.test.js b/tests/tooling/lsp/go-workspace-root-scan-fallback.test.js new file mode 100644 index 000000000..125be7fe6 --- /dev/null +++ b/tests/tooling/lsp/go-workspace-root-scan-fallback.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveGoWorkspaceModulePreflight } from '../../../src/index/tooling/preflight/go-workspace-preflight.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `gopls-root-scan-fallback-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'services', 'api'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'docs'), { recursive: true }); + +await fs.writeFile( + path.join(tempRoot, 'services', 'api', 'go.mod'), + 'module example.com/api\n\ngo 1.22\n', + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'docs', 'sample.go'), + 'package docs\n\nfunc Sample() {}\n', + 'utf8' +); + +const probePath = path.join(tempRoot, 'go-module-probe.js'); +await fs.writeFile( + probePath, + [ + "const cwd = process.cwd().replace(/\\\\/g, '/');", + "if (!cwd.endsWith('/services/api')) {", + " process.stderr.write(`unexpected cwd ${cwd}\\n`);", + ' process.exit(19);', + '}', + "process.stdout.write('example.com/api\\n');" + ].join('\n'), + 'utf8' +); + +const result = await resolveGoWorkspaceModulePreflight({ + ctx: { + repoRoot: tempRoot, + cache: { dir: null }, + logger: () => {} + }, + server: { + id: 'gopls', + cmd: 'gopls', + languages: ['go'], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [probePath], + goWorkspaceWarmup: false + }, + documents: [{ + virtualPath: '.poc-vfs/docs/sample.go#seg:gopls-root-scan-fallback.txt', + path: '.poc-vfs/docs/sample.go#seg:gopls-root-scan-fallback.txt', + languageId: 'go' + }] +}); + +assert.equal(result?.state, 'ready', 'expected nested Go workspace root to be narrowed instead of blocked'); +assert.equal(result?.reasonCode, null, 'expected narrowed nested root to avoid blocked reason codes'); +assert.equal( + Array.isArray(result?.checks) && result.checks.some((check) => check?.name === 'go_workspace_root_scan_narrowed'), + true, + 'expected narrowed root advisory check' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('go workspace root scan fallback test passed'); diff --git a/tests/tooling/lsp/go-workspace-root-scan-multi-candidate-selection.test.js b/tests/tooling/lsp/go-workspace-root-scan-multi-candidate-selection.test.js new file mode 100644 index 000000000..6fbbc39b5 --- /dev/null +++ b/tests/tooling/lsp/go-workspace-root-scan-multi-candidate-selection.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveGoWorkspaceModulePreflight } from '../../../src/index/tooling/preflight/go-workspace-preflight.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `gopls-root-scan-multi-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); + +const writeProbe = async (targetRootRel, scriptName) => { + const probePath = path.join(tempRoot, scriptName); + await fs.writeFile( + probePath, + [ + "const cwd = process.cwd().replace(/\\\\/g, '/');", + `if (!cwd.endsWith('/${targetRootRel.replace(/\\/g, '/')}')) {`, + " process.stderr.write(`unexpected cwd ${cwd}\\n`);", + ' process.exit(19);', + '}', + "process.stdout.write('ok\\n');" + ].join('\n'), + 'utf8' + ); + return probePath; +}; + +await fs.mkdir(path.join(tempRoot, 'examples', 'go'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'tools', 'lint'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'examples', 'docs'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'examples', 'go', 'go.mod'), 'module example.com/examples\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'tools', 'lint', 'go.mod'), 'module example.com/lint\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'examples', 'docs', 'sample.go'), 'package docs\n\nfunc Sample() {}\n', 'utf8'); + +const uniqueProbePath = await writeProbe('examples/go', 'go-module-probe-unique.js'); +const uniqueResult = await resolveGoWorkspaceModulePreflight({ + ctx: { + repoRoot: tempRoot, + cache: { dir: null }, + logger: () => {} + }, + server: { + id: 'gopls', + cmd: 'gopls', + languages: ['go'], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [uniqueProbePath], + goWorkspaceWarmup: false + }, + documents: [{ + virtualPath: '.poc-vfs/examples/docs/sample.go#seg:gopls-root-scan-multi-unique.txt', + path: '.poc-vfs/examples/docs/sample.go#seg:gopls-root-scan-multi-unique.txt', + languageId: 'go' + }] +}); + +assert.equal(uniqueResult?.state, 'ready', 'expected unique nearest nested root to be selected'); +assert.equal(uniqueResult?.reasonCode, null, 'expected unique nearest nested root to avoid blocked reason'); +assert.equal( + Array.isArray(uniqueResult?.checks) && uniqueResult.checks.some((check) => check?.name === 'go_workspace_root_scan_narrowed'), + true, + 'expected narrowed-root advisory check for multi-root selection' +); + +await fs.mkdir(path.join(tempRoot, 'services', 'api'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'services', 'worker'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'services', 'docs'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'services', 'api', 'go.mod'), 'module example.com/api\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'services', 'worker', 'go.mod'), 'module example.com/worker\n\ngo 1.22\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'services', 'docs', 'sample.go'), 'package docs\n\nfunc Sample() {}\n', 'utf8'); + +const ambiguousResult = await resolveGoWorkspaceModulePreflight({ + ctx: { + repoRoot: tempRoot, + cache: { dir: null }, + logger: () => {} + }, + server: { + id: 'gopls', + cmd: 'gopls', + languages: ['go'], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: ['-e', "process.stdout.write('should not run\\n');"], + goWorkspaceWarmup: false + }, + documents: [{ + virtualPath: '.poc-vfs/services/docs/sample.go#seg:gopls-root-scan-multi-ambiguous.txt', + path: '.poc-vfs/services/docs/sample.go#seg:gopls-root-scan-multi-ambiguous.txt', + languageId: 'go' + }] +}); + +assert.equal(ambiguousResult?.state, 'blocked', 'expected equal-prefix nested roots to remain blocked'); +assert.equal( + ambiguousResult?.reasonCode, + 'go_workspace_blocked_workspace_shape', + 'expected equal-prefix nested roots to keep workspace-shape blocked reason' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('go workspace root scan multi-candidate selection test passed'); diff --git a/tests/tooling/lsp/haskell-provider-ambiguous-cradle-preflight.test.js b/tests/tooling/lsp/haskell-provider-ambiguous-cradle-preflight.test.js new file mode 100644 index 000000000..62bed629b --- /dev/null +++ b/tests/tooling/lsp/haskell-provider-ambiguous-cradle-preflight.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'haskell-provider-ambiguous-cradle-preflight', + directories: ['src'], + files: [ + { path: 'stack.yaml', content: 'resolver: lts-22.0\n' }, + { path: 'sample.cabal', content: 'name: sample\nversion: 0.1.0.0\n' } + ] +}); +const fixtureHaskellCmd = resolveLspFixtureCommand('haskell-language-server', { repoRoot: root }); +const docText = 'greet :: Text -> Text\ngreet name = name\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'haskell-ambiguous-cradle-preflight', + virtualPath: 'src/Main.hs', + text: docText, + languageId: 'haskell', + effectiveExt: '.hs', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'haskell-language-server', + providerConfigKey: 'haskell', + providerConfig: { + cmd: fixtureHaskellCmd + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, 'expected haskell provider to fail-open on ambiguous cradle'); + const diagnostics = result.diagnostics?.['haskell-language-server'] || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected haskell preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'haskell_workspace_ambiguous_cradle', + 'expected haskell ambiguous cradle reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'haskell_workspace_ambiguous_cradle'), + true, + 'expected haskell ambiguous cradle warning check' + ); +}); + +console.log('haskell provider ambiguous cradle preflight test passed'); diff --git a/tests/tooling/lsp/helpers/dedicated-provider-matrix-case.js b/tests/tooling/lsp/helpers/dedicated-provider-matrix-case.js new file mode 100644 index 000000000..bfad3fd30 --- /dev/null +++ b/tests/tooling/lsp/helpers/dedicated-provider-matrix-case.js @@ -0,0 +1,42 @@ +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../../helpers/lsp-provider-fixture.js'; + +export const runDedicatedProviderMatrixCase = async ({ + root = process.cwd(), + entry, + providerConfig, + configOverride = null +}) => { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: entry.fixtureName, + directories: entry.directories, + files: entry.files + }); + const inputs = buildSingleSymbolInputs({ + scenarioName: entry.fixtureName, + virtualPath: entry.virtualPath, + text: entry.docText, + languageId: entry.languageId, + effectiveExt: entry.effectiveExt, + symbolName: entry.symbolName + }); + const resolvedProviderConfig = providerConfig || { + cmd: resolveLspFixtureCommand(entry.fixtureCommand, { repoRoot: root }) + }; + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: entry.providerId, + providerConfigKey: entry.providerConfigKey, + providerConfig: { + ...resolvedProviderConfig, + ...((configOverride && typeof configOverride === 'object') ? configOverride : {}) + }, + inputs + }); + return { result, inputs }; +}; diff --git a/tests/tooling/lsp/helpers/degraded-preflight-case.js b/tests/tooling/lsp/helpers/degraded-preflight-case.js new file mode 100644 index 000000000..888d21a92 --- /dev/null +++ b/tests/tooling/lsp/helpers/degraded-preflight-case.js @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../../helpers/lsp-runtime.js'; + +const assertWarningCheck = ({ checks, checkName, message }) => { + const normalizedChecks = Array.isArray(checks) ? checks : []; + assert.equal( + normalizedChecks.some((check) => check?.name === checkName), + true, + message + ); +}; + +export const runDedicatedProviderDegradedPreflightCase = async ({ + root = process.cwd(), + repo, + providerId, + providerConfigKey, + fixtureCommand, + inputs, + expectedEnrichment, + expectedReasonCode, + expectedCheckName, + diagnosticsKey = providerId, + messages +}) => { + const fixtureCmd = resolveLspFixtureCommand(fixtureCommand, { repoRoot: root }); + await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot: repo, + providerId, + providerConfigKey, + providerConfig: { + cmd: fixtureCmd + }, + inputs + }); + + assert.equal( + result.byChunkUid.has(inputs.chunkUid), + expectedEnrichment, + messages?.enrichment + ); + const diagnostics = result.diagnostics?.[diagnosticsKey] || {}; + assert.equal( + diagnostics?.preflight?.state, + 'degraded', + messages?.state || `expected ${providerId} preflight degraded state` + ); + assert.equal( + diagnostics?.preflight?.reasonCode, + expectedReasonCode, + messages?.reasonCode + ); + assertWarningCheck({ + checks: diagnostics?.checks, + checkName: expectedCheckName, + message: messages?.check + }); + }); +}; + +export const runSingleSymbolDegradedPreflightCase = async ({ + root = process.cwd(), + name, + directories, + files, + providerId, + providerConfigKey, + fixtureCommand, + providerConfig = {}, + input, + expectedReasonCode, + expectedCheckName, + expectedEnrichment = true, + diagnosticsKey = providerId, + messages +}) => { + const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name, + directories, + files + }); + const inputs = buildSingleSymbolInputs(input); + const fixtureCmd = resolveLspFixtureCommand(fixtureCommand, { repoRoot: root }); + await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId, + providerConfigKey, + providerConfig: { + ...providerConfig, + cmd: fixtureCmd + }, + inputs + }); + + assert.equal( + result.byChunkUid.has(inputs.chunkUid), + expectedEnrichment, + messages?.enrichment + ); + const diagnostics = result.diagnostics?.[diagnosticsKey] || {}; + assert.equal( + diagnostics?.preflight?.state, + 'degraded', + messages?.state || `expected ${providerId} preflight degraded state` + ); + assert.equal( + diagnostics?.preflight?.reasonCode, + expectedReasonCode, + messages?.reasonCode + ); + assertWarningCheck({ + checks: diagnostics?.checks, + checkName: expectedCheckName, + message: messages?.check + }); + }); +}; diff --git a/tests/tooling/lsp/helpers/fake-child-process.js b/tests/tooling/lsp/helpers/fake-child-process.js new file mode 100644 index 000000000..404397245 --- /dev/null +++ b/tests/tooling/lsp/helpers/fake-child-process.js @@ -0,0 +1,43 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +export class FakeChildProcess extends EventEmitter { + constructor() { + super(); + this.pid = 0; + this.killed = false; + this.exitCode = null; + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + this.killCalls = 0; + } + + kill(signal = null) { + this.killCalls += 1; + this.killed = true; + this.exitCode = this.exitCode === null ? 0 : this.exitCode; + queueMicrotask(() => { + this.emit('exit', this.exitCode, signal); + this.emit('close', this.exitCode, signal); + }); + return true; + } + + unref() {} +} + +export const createTrackedFakeChildProcessSpawner = ({ configureChild = null } = {}) => { + const spawnedChildren = []; + return { + spawnedChildren, + spawnProcess: () => { + const child = new FakeChildProcess(); + spawnedChildren.push(child); + if (configureChild) { + configureChild(child); + } + return child; + } + }; +}; diff --git a/tests/tooling/lsp/helpers/gopls-workspace-case.js b/tests/tooling/lsp/helpers/gopls-workspace-case.js new file mode 100644 index 000000000..3e14e2d13 --- /dev/null +++ b/tests/tooling/lsp/helpers/gopls-workspace-case.js @@ -0,0 +1,85 @@ +import path from 'node:path'; + +export const goplsSampleDocText = 'package main\nfunc Add(a int, b int) int { return a + b }\n'; + +const toIdPart = (value) => String(value || '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + +export const buildGoplsWorkspaceInputs = ({ + scenario, + partitions, + docText = goplsSampleDocText +}) => { + const documents = []; + const targets = []; + const chunkUids = {}; + for (const [index, partition] of partitions.entries()) { + const service = partition.service; + const suffix = partition.suffix; + const virtualPath = `.poc-vfs/${service}/src/sample.go#seg:${scenario}-${suffix}.txt`; + const chunkUid = `ck64:v1:test:${service}/src/sample.go:${scenario}:${suffix}`; + chunkUids[partition.key || suffix] = chunkUid; + documents.push({ + virtualPath, + text: docText, + languageId: 'go', + effectiveExt: '.go', + docHash: `hash-${scenario}-${suffix}` + }); + targets.push({ + chunkRef: { + docId: index, + chunkUid, + chunkId: `chunk_${toIdPart(scenario)}_${toIdPart(suffix)}`, + file: `${service}/src/sample.go`, + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'Add', kind: 'function' }, + languageId: 'go' + }); + } + return { + documents, + targets, + kinds: ['types'], + chunkUids + }; +}; + +export const buildGoplsWorkspaceContext = ({ + root = process.cwd(), + tempRoot, + providerId = 'lsp-gopls', + serverId = 'gopls', + probePath, + probeArgs = [], + cache = { enabled: false }, + serverConfig = {} +}) => ({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: [providerId], + lsp: { + enabled: true, + servers: [{ + id: serverId, + preset: 'gopls', + cmd: process.execPath, + args: [path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'), '--mode', 'go'], + languages: ['go'], + uriScheme: 'poc-vfs', + preflightRuntimeRequirements: [], + goWorkspaceModuleCmd: process.execPath, + goWorkspaceModuleArgs: [probePath, ...probeArgs], + goWorkspaceWarmup: false, + ...serverConfig + }] + } + }, + cache +}); diff --git a/tests/tooling/lsp/helpers/stale-process-restart-harness.js b/tests/tooling/lsp/helpers/stale-process-restart-harness.js new file mode 100644 index 000000000..e5e7660ea --- /dev/null +++ b/tests/tooling/lsp/helpers/stale-process-restart-harness.js @@ -0,0 +1,39 @@ +import { createLspClient } from '../../../../src/integrations/tooling/lsp/client.js'; +import { sleep } from '../../../../src/shared/sleep.js'; +import { createTrackedFakeChildProcessSpawner } from './fake-child-process.js'; + +export const createStaleProcessRestartHarness = () => { + const lifecycleEvents = []; + const { spawnedChildren, spawnProcess } = createTrackedFakeChildProcessSpawner(); + const client = createLspClient({ + cmd: 'fake-lsp', + args: ['--stdio'], + log: () => {}, + onLifecycleEvent: (event) => lifecycleEvents.push(event), + spawnProcess + }); + + const startWithBackoffRetry = async (attempts = 8) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + client.start(); + return; + } catch (error) { + if (!String(error?.message || '').includes('LSP start backoff active')) { + throw error; + } + await sleep(50); + } + } + throw new Error('Timed out waiting for LSP restart backoff window.'); + }; + + return { + client, + lifecycleEvents, + spawnedChildren, + startWithBackoffRetry + }; +}; + +export { sleep }; diff --git a/tests/tooling/lsp/helpers/stub-lsp-collect-fixture.js b/tests/tooling/lsp/helpers/stub-lsp-collect-fixture.js new file mode 100644 index 000000000..0c3de7599 --- /dev/null +++ b/tests/tooling/lsp/helpers/stub-lsp-collect-fixture.js @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../../helpers/test-cache.js'; + +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; + +export const createStubLspCollectFixture = async (name) => { + const root = process.cwd(); + const tempRoot = resolveTestCachePath(root, `${name}-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + + const chunkUid = 'ck64:v1:test:src/sample.cpp:deadbeef'; + const documents = [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }]; + const targets = [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_deadbeef', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }]; + const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + const collect = (mode, overrides = {}) => collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents, + targets, + cmd: process.execPath, + args: [serverPath, '--mode', mode], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }), + ...overrides + }); + + return { chunkUid, collect, documents, targets, tempRoot }; +}; diff --git a/tests/tooling/lsp/hover-budget-per-file.test.js b/tests/tooling/lsp/hover-budget-per-file.test.js new file mode 100644 index 000000000..76218289b --- /dev/null +++ b/tests/tooling/lsp/hover-budget-per-file.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsp-hover-budget-per-file'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\nint sub(int a, int b) { return a - b; }\n'; + +const buildDocBundle = (name, docId) => { + const virtualPath = `.poc-vfs/src/${name}.cpp#seg:${name}.cpp`; + const addStart = docText.indexOf('add'); + const subStart = docText.indexOf('sub'); + const docs = { + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: `hash_${name}` + }; + const targets = [{ + chunkRef: { + docId, + chunkUid: `ck64:v1:test:src/${name}.cpp:add`, + chunkId: `chunk_${name}_add`, + file: `src/${name}.cpp`, + segmentUid: null, + segmentId: null, + range: { start: addStart, end: addStart + 3 } + }, + virtualPath, + virtualRange: { start: addStart, end: addStart + 3 }, + symbolHint: { name: 'add', kind: 'function' } + }, { + chunkRef: { + docId, + chunkUid: `ck64:v1:test:src/${name}.cpp:sub`, + chunkId: `chunk_${name}_sub`, + file: `src/${name}.cpp`, + segmentUid: null, + segmentId: null, + range: { start: subStart, end: subStart + 3 } + }, + virtualPath, + virtualRange: { start: subStart, end: subStart + 3 }, + symbolHint: { name: 'sub', kind: 'function' } + }]; + return { doc: docs, targets }; +}; + +const bundleA = buildDocBundle('sample_a', 0); +const bundleB = buildDocBundle('sample_b', 1); + +let result = null; +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [bundleA.doc, bundleB.doc], + targets: [...bundleA.targets, ...bundleB.targets], + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-signature-help-two-symbols'], + parseSignature: () => null, + hoverMaxPerFile: 1, + signatureHelpEnabled: false, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false + }); +}); + +const events = await parseJsonLinesFile(tracePath); +const hoverCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/hover').length; +assert.equal(hoverCount, 2, 'expected hover budget to allow one hover request per file'); +assert.equal( + Number(result?.hoverMetrics?.skippedByBudget || 0) >= 2, + true, + 'expected per-file hover budget suppression metrics' +); +const files = Array.isArray(result?.hoverMetrics?.files) ? result.hoverMetrics.files : []; +const sampleAStats = files.find((entry) => String(entry?.virtualPath || '').includes('sample_a.cpp')); +const sampleBStats = files.find((entry) => String(entry?.virtualPath || '').includes('sample_b.cpp')); +assert.equal(Number(sampleAStats?.requested || 0), 1, 'expected sample_a to consume one hover request'); +assert.equal(Number(sampleBStats?.requested || 0), 1, 'expected sample_b to consume one hover request'); + +console.log('LSP hover budget per-file test passed'); diff --git a/tests/tooling/lsp/hover-cache-key-v2.test.js b/tests/tooling/lsp/hover-cache-key-v2.test.js new file mode 100644 index 000000000..89a97d4fd --- /dev/null +++ b/tests/tooling/lsp/hover-cache-key-v2.test.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + buildLspRequestCacheKey, + buildSignatureParseCacheKey, + buildSymbolPositionCacheKey, + loadLspRequestCache, + persistLspRequestCache +} from '../../../src/integrations/tooling/providers/lsp/hover-types.js'; + +const baseInput = { + providerId: 'clangd', + providerVersion: '1.2.3', + workspaceKey: 'repo-root', + docHash: 'doc-hash-v2', + requestKind: 'hover', + position: { line: 7, character: 3 } +}; + +const alphaFunctionKey = buildLspRequestCacheKey(baseInput); +const alphaVariableKey = buildLspRequestCacheKey(baseInput); +const betaFunctionKey = buildLspRequestCacheKey({ + ...baseInput, + requestKind: 'signature_help' +}); + +assert.equal(alphaFunctionKey?.startsWith('rq1|'), true, 'expected request cache key policy prefix'); +assert.equal(alphaFunctionKey, alphaVariableKey, 'expected request cache key to stay deterministic'); +assert.notEqual(alphaFunctionKey, betaFunctionKey, 'expected request cache key to vary by request kind'); + +const alphaPositionKey = buildSymbolPositionCacheKey({ + position: baseInput.position, + symbolName: 'alpha', + symbolKind: 12 +}); +const betaPositionKey = buildSymbolPositionCacheKey({ + position: baseInput.position, + symbolName: 'beta', + symbolKind: 12 +}); +assert.equal(alphaPositionKey, betaPositionKey, 'expected symbol-position key to be position-only'); + +const signatureKeyAlpha = buildSignatureParseCacheKey({ + languageId: 'python', + parserKey: 'pyright', + detailText: '(value: str) -> str', + symbolName: 'alpha', + symbolSensitive: true +}); +const signatureKeyBeta = buildSignatureParseCacheKey({ + languageId: 'python', + parserKey: 'pyright', + detailText: '(value: str) -> str', + symbolName: 'beta', + symbolSensitive: true +}); +const signatureKeyInsensitiveAlpha = buildSignatureParseCacheKey({ + languageId: 'python', + parserKey: 'pyright', + detailText: '(value: str) -> str', + symbolName: 'alpha', + symbolSensitive: false +}); +const signatureKeyInsensitiveBeta = buildSignatureParseCacheKey({ + languageId: 'python', + parserKey: 'pyright', + detailText: '(value: str) -> str', + symbolName: 'beta', + symbolSensitive: false +}); +assert.notEqual(signatureKeyAlpha, signatureKeyBeta, 'expected signature parse cache key to vary by symbol when symbol-sensitive'); +assert.equal( + signatureKeyInsensitiveAlpha, + signatureKeyInsensitiveBeta, + 'expected signature parse cache key to ignore symbol when parser is symbol-insensitive' +); + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', 'lsp-hover-cache-key-v2'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const state = await loadLspRequestCache(tempRoot); +assert.equal( + state.path.endsWith(path.join('lsp', 'request-cache-v1.json')), + true, + 'expected request cache filename' +); +state.entries.set(alphaFunctionKey, { + requestKind: 'hover', + info: { + signature: 'int alpha()', + returnType: 'int' + }, + at: 1234 +}); +await persistLspRequestCache({ + cachePath: state.path, + entries: state.entries, + maxEntries: 1000 +}); +const persisted = JSON.parse(await fs.readFile(state.path, 'utf8')); +assert.equal(persisted?.version, 1, 'expected persisted request cache schema version 1'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +console.log('LSP request cache key test passed'); diff --git a/tests/tooling/lsp/hover-dedupe-overlapping-symbols.test.js b/tests/tooling/lsp/hover-dedupe-overlapping-symbols.test.js new file mode 100644 index 000000000..5267fb4db --- /dev/null +++ b/tests/tooling/lsp/hover-dedupe-overlapping-symbols.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsp-hover-dedupe-overlapping-symbols'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:overlap'; + +let result = null; +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'dochash_hover_overlap' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_overlap', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd-overlapping-symbols'], + parseSignature: () => null + }); +}); + +const events = await parseJsonLinesFile(tracePath); +const hoverCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/hover').length; +assert.equal(hoverCount, 1, 'expected one hover request for overlapping symbols at the same position'); +assert.equal( + Number(result?.hoverMetrics?.incompleteSymbols || 0) >= 2, + true, + 'expected incomplete symbol tracking for overlapping symbol records' +); + +console.log('LSP hover dedupe overlapping symbols test passed'); diff --git a/tests/tooling/lsp/hover-dedupe.test.js b/tests/tooling/lsp/hover-dedupe.test.js new file mode 100644 index 000000000..e2adb744e --- /dev/null +++ b/tests/tooling/lsp/hover-dedupe.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { applyTestEnv, withTemporaryEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsp-hover-dedupe'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; +const documents = [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'dochash_hover_dedupe' +}]; + +const chunkUid = 'ck64:v1:test:src/sample.cpp:abcd1234'; +const targets = [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_abcd1234', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } +}]; + +let result = null; +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + cacheRoot: path.join(tempRoot, 'cache'), + documents, + targets, + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd-duplicate-symbols'], + hoverConcurrency: 8, + parseSignature: () => ({ + signature: 'int add(int a, int b)', + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }) + }); +}); + +const events = await parseJsonLinesFile(tracePath); +const hoverCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/hover').length; + +assert.equal(hoverCount, 1, 'expected duplicate symbol hover requests to be deduped'); +assert.equal( + Number(result?.hoverMetrics?.incompleteSymbols || 0) >= 2, + true, + 'expected incomplete symbol tracking for duplicate symbols' +); +assert.equal( + Number(result?.hoverMetrics?.hoverTriggeredByIncomplete || 0) >= 2, + true, + 'expected hover trigger tracking to include incomplete symbols' +); + +console.log('LSP hover dedupe test passed'); diff --git a/tests/tooling/lsp/hover-merge-quality.test.js b/tests/tooling/lsp/hover-merge-quality.test.js new file mode 100644 index 000000000..b5a3827b7 --- /dev/null +++ b/tests/tooling/lsp/hover-merge-quality.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseCppTwoIntParamSignature } from '../../helpers/lsp-signature-fixtures.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-hover-merge-quality-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:hover-merge.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:hover-merge'; + +const parseSignature = (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: ['add'], + bareReturnType: 'int' +}); + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_hover_merge', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd-hover-richer'], + parseSignature +}); + +const payload = result.byChunkUid?.[chunkUid]?.payload || null; +assert.ok(payload, 'expected payload for chunk'); +assert.equal(payload.returnType, 'int', 'expected return type to remain stable'); +assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); +assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); +assert.equal( + Number(result?.hoverMetrics?.sourceBootstrapUsed || 0) >= 1, + true, + 'expected source bootstrap to complete the param payload before hover' +); +assert.equal( + Number(result?.hoverMetrics?.fallbackUsed || 0), + 0, + 'expected no late source fallback when source bootstrap completes signature' +); + +console.log('LSP hover merge quality test passed'); diff --git a/tests/tooling/lsp/hover-types-payload-policy-modularization.test.js b/tests/tooling/lsp/hover-types-payload-policy-modularization.test.js new file mode 100644 index 000000000..d7b2f0e03 --- /dev/null +++ b/tests/tooling/lsp/hover-types-payload-policy-modularization.test.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const hoverTypesPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types.js'); +const hoverTypesIndexPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'index.js'); +const payloadPolicyPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'payload-policy.js'); + +for (const target of [hoverTypesPath, hoverTypesIndexPath, payloadPolicyPath]) { + assert.equal(fs.existsSync(target), true, `missing expected hover payload-policy file: ${target}`); +} + +const hoverBarrelSource = fs.readFileSync(hoverTypesPath, 'utf8'); +const hoverIndexSource = fs.readFileSync(hoverTypesIndexPath, 'utf8'); + +assert.equal( + hoverBarrelSource.includes("./hover-types/index.js"), + true, + 'expected hover-types barrel to re-export the modularized index surface' +); + +for (const marker of [ + "./payload-policy.js", + 'buildFallbackReasonCodes,', + 'buildLspProvenanceEntry,', + 'buildLspSymbolRef,', + 'createEmptyHoverMetricsResult,', + 'scoreLspConfidence,' +]) { + assert.equal( + hoverIndexSource.includes(marker), + true, + `expected hover-types to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const scoreLspConfidence = ({', + 'const buildLspSymbolRef = ({', + 'const buildLspProvenanceEntry = ({', + 'export const createEmptyHoverMetricsResult = () => ({', + 'const buildFallbackReasonCodes = ({' +]) { + assert.equal( + hoverIndexSource.includes(legacyInlineMarker), + false, + `expected hover-types to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('hover-types payload-policy modularization test passed'); diff --git a/tests/tooling/lsp/hover-types-runtime-modularization.test.js b/tests/tooling/lsp/hover-types-runtime-modularization.test.js new file mode 100644 index 000000000..7238f1be5 --- /dev/null +++ b/tests/tooling/lsp/hover-types-runtime-modularization.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const hoverBarrelPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types.js'); +const hoverIndexPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'index.js'); +const cachePath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'cache.js'); +const concurrencyPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'concurrency.js'); +const mergePath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'merge.js'); +const metricsPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'metrics.js'); +const stagesPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'hover-types', 'stages.js'); + +for (const target of [ + hoverBarrelPath, + hoverIndexPath, + cachePath, + concurrencyPath, + mergePath, + metricsPath, + stagesPath +]) { + assert.equal(fs.existsSync(target), true, `missing expected hover runtime modularization file: ${target}`); +} + +const hoverBarrelSource = fs.readFileSync(hoverBarrelPath, 'utf8'); +const hoverIndexSource = fs.readFileSync(hoverIndexPath, 'utf8'); + +assert.equal( + hoverBarrelSource.includes("./hover-types/index.js"), + true, + 'expected hover-types barrel to re-export the modularized runtime entrypoint' +); + +for (const marker of [ + "./cache.js", + "./concurrency.js", + "./merge.js", + "./metrics.js", + "./stages.js", + 'loadLspRequestCache,', + 'createRequestBudgetController(', + 'createHoverFileStats(', + 'resolveRecordCandidate({', + 'handleStageRequestError({' +]) { + assert.equal( + hoverIndexSource.includes(marker), + true, + `expected hover runtime to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'export const loadLspRequestCache = async', + 'export const clampIntRange = (value, fallback', + 'export const summarizeHoverMetrics = ({ hoverMetrics', + 'const buildSourceSignatureCandidate = (text, virtualRange) => {', + 'const handleStageRequestError = ({', + 'const resolveRecordCandidate = async (record, recordIndex) => {' +]) { + assert.equal( + hoverIndexSource.includes(legacyInlineMarker), + false, + `expected hover runtime to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('hover-types runtime modularization test passed'); diff --git a/tests/tooling/lsp/jdtls-provider-bootstrap.test.js b/tests/tooling/lsp/jdtls-provider-bootstrap.test.js new file mode 100644 index 000000000..2fb456deb --- /dev/null +++ b/tests/tooling/lsp/jdtls-provider-bootstrap.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'jdtls-provider-bootstrap', + directories: ['src'], + files: [{ path: 'pom.xml', content: '' }] +}); +const fixtureJdtlsCmd = resolveLspFixtureCommand('jdtls', { repoRoot: root }); +const docText = 'class App { int add(int a, int b) { return a + b; } }\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'jdtls-bootstrap', + virtualPath: 'src/App.java', + text: docText, + languageId: 'java', + effectiveExt: '.java', + symbolName: 'add' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'jdtls', + providerConfigKey: 'jdtls', + providerConfig: { + cmd: fixtureJdtlsCmd, + lifecycle: { + restartWindowMs: 2100, + maxRestartsPerWindow: 5, + fdPressureBackoffMs: 250 + } + }, + toolingConfig: { + lifecycle: { + lifecycleRestartWindowMs: 60000 + } + }, + inputs + }); + + assert.equal(result.byChunkUid.has(inputs.chunkUid), true, 'expected jdtls provider to enrich Java symbol'); + const providerDiag = result.diagnostics?.jdtls || null; + assert.ok(providerDiag && providerDiag.runtime, 'expected runtime diagnostics for jdtls provider'); + assert.equal(providerDiag.runtime?.lifecycle?.restartWindowMs, 2100, 'expected provider lifecycle override'); + assert.equal(providerDiag.runtime?.lifecycle?.maxRestartsPerWindow, 5, 'expected provider max restarts'); + assert.equal(providerDiag.runtime?.lifecycle?.fdPressureBackoffMs, 250, 'expected provider fd backoff'); + const checks = Array.isArray(providerDiag?.checks) ? providerDiag.checks : []; + assert.equal( + checks.some((check) => check?.name === 'jdtls_workspace_model_missing'), + false, + 'workspace marker guard should not trigger when pom.xml exists' + ); + + console.log('jdtls provider bootstrap test passed'); +}); diff --git a/tests/tooling/lsp/latency-microbench-contract.test.js b/tests/tooling/lsp/latency-microbench-contract.test.js new file mode 100644 index 000000000..6e2e0a54c --- /dev/null +++ b/tests/tooling/lsp/latency-microbench-contract.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-latency-microbench-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:latency.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:latency'; + +const sampleCount = 6; +const perRunP50 = []; +const perRunP95 = []; +let requests = 0; +let timedOut = 0; + +for (let i = 0; i < sampleCount; i += 1) { + const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: `chunk_latency_${i}`, + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd'], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }); + + const latency = result.runtime?.requests?.latencyMs || {}; + perRunP50.push(Number(latency.p50 || 0)); + perRunP95.push(Number(latency.p95 || 0)); + requests += Number(result.runtime?.requests?.requests || 0); + timedOut += Number(result.runtime?.requests?.timedOut || 0); +} + +const timeoutRatio = requests > 0 ? timedOut / requests : 0; +assert.equal(perRunP50.length, sampleCount, 'expected p50 sample for each microbench run'); +assert.equal(perRunP95.length, sampleCount, 'expected p95 sample for each microbench run'); +assert.equal(perRunP50.every((value) => Number.isFinite(value) && value >= 0), true, 'expected finite p50 values'); +assert.equal(perRunP95.every((value) => Number.isFinite(value) && value >= 0), true, 'expected finite p95 values'); +assert.equal( + perRunP95.every((value, index) => value >= perRunP50[index]), + true, + 'expected p95 >= p50 for each run' +); +assert.equal(timeoutRatio <= 0.01, true, `expected timeout ratio <= 1%, got ${timeoutRatio}`); + +console.log('LSP latency microbench contract test passed'); diff --git a/tests/tooling/lsp/lifecycle-health.test.js b/tests/tooling/lsp/lifecycle-health.test.js new file mode 100644 index 000000000..5b783a2d7 --- /dev/null +++ b/tests/tooling/lsp/lifecycle-health.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createToolingLifecycleHealth } from '../../../src/integrations/tooling/providers/shared.js'; + +const health = createToolingLifecycleHealth({ + name: 'lsp-lifecycle-test', + restartWindowMs: 2000, + maxRestartsPerWindow: 3, + fdPressureBackoffMs: 400, + log: () => {} +}); + +const base = Date.now(); +health.onLifecycleEvent({ kind: 'start', at: base }); +health.onLifecycleEvent({ kind: 'exit', at: base + 50, code: 1 }); +health.onLifecycleEvent({ kind: 'start', at: base + 100 }); +health.onLifecycleEvent({ kind: 'error', at: base + 150, code: 'EPIPE' }); +health.onLifecycleEvent({ kind: 'start', at: base + 200 }); +health.noteHandshakeFailure({ at: base + 210, code: 'initialize_failed', message: 'bad initialize payload' }); +health.noteRequestTimeout({ at: base + 220, code: 'ERR_LSP_REQUEST_TIMEOUT', message: 'request timeout' }); +health.onLifecycleEvent({ kind: 'protocol_parse_error', at: base + 230, message: 'unexpected token' }); + +const crashState = health.getState(); +assert.equal(crashState.crashLoopTrips >= 1, true, 'expected crash-loop trip to be recorded'); +assert.equal( + crashState.crashLoopQuarantined, + true, + 'expected crash-loop quarantine to be active within restart window' +); +assert.equal(crashState.startupFailures >= 2, true, 'expected startup failures to be counted before handshake'); +assert.equal(crashState.handshakeFailures, 1, 'expected handshake failures to be counted'); +assert.equal(crashState.requestTimeouts, 1, 'expected request timeout count'); +assert.equal(crashState.protocolParseFailures, 1, 'expected protocol parse failures to be counted'); +assert.equal( + crashState.lastFailureCategory?.category, + 'protocol_parse_failure', + 'expected last failure category to reflect most recent reliability failure' +); + +health.noteStderrLine('EMFILE: too many open files while reading'); +const fdState = health.getState(); +assert.equal(fdState.fdPressureEvents >= 1, true, 'expected fd pressure event to be counted'); +assert.equal(fdState.fdPressureBackoffActive, true, 'expected fd pressure backoff to activate'); +assert.equal(fdState.fdPressureDensityPerMinute > 0, true, 'expected fd pressure density to be reported'); +assert.equal(fdState.requestTimeoutRatePerMinute > 0, true, 'expected timeout rate to be reported'); +assert.equal(fdState.protocolParseFailureRatePerMinute > 0, true, 'expected protocol parse rate to be reported'); + +console.log('LSP lifecycle health test passed'); diff --git a/tests/tooling/lsp/lsp-bychunkuid-keying.test.js b/tests/tooling/lsp/lsp-bychunkuid-keying.test.js deleted file mode 100644 index 688d077e0..000000000 --- a/tests/tooling/lsp/lsp-bychunkuid-keying.test.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsp-bychunkuid'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); -const docText = 'int add(int a, int b) { return a + b; }\n'; -const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'cpp', - effectiveExt: '.cpp' -}]; - -const chunkUid = 'ck64:v1:test:src/sample.cpp:deadbeef'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/sample.cpp', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'add', kind: 'function' } -}]; - -const result = await collectLspTypes({ - rootDir: tempRoot, - vfsRoot: tempRoot, - documents, - targets, - cmd: process.execPath, - args: [serverPath, '--mode', 'clangd'], - parseSignature: (detail) => ({ - signature: detail, - returnType: 'int', - paramTypes: { a: 'int', b: 'int' } - }) -}); - -assert.ok(result.byChunkUid[chunkUid], 'expected LSP results keyed by chunkUid'); -assert.equal(result.byChunkUid[chunkUid].chunk.chunkUid, chunkUid); - -console.log('LSP byChunkUid keying test passed'); diff --git a/tests/tooling/lsp/lsp-failure-accounting-per-target.test.js b/tests/tooling/lsp/lsp-failure-accounting-per-target.test.js deleted file mode 100644 index 963cf8960..000000000 --- a/tests/tooling/lsp/lsp-failure-accounting-per-target.test.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createToolingGuard } from '../../../src/integrations/tooling/providers/shared.js'; - -const guard = createToolingGuard({ - name: 'lsp-guard-test', - retries: 1, - breakerThreshold: 1, - timeoutMs: 100, - log: () => {} -}); - -let attempt = 0; -await guard.run(() => { - attempt += 1; - if (attempt === 1) throw new Error('first failure'); - return 'ok'; -}); - -assert.equal(guard.isOpen(), false, 'expected retries within a target not to trip breaker'); - -let threw = false; -try { - await guard.run(() => { - throw new Error('target failure'); - }); -} catch { - threw = true; -} - -assert.ok(threw, 'expected failing target to throw'); -assert.equal(guard.isOpen(), true, 'expected breaker to trip after target failure'); - -console.log('LSP failure accounting test passed'); diff --git a/tests/tooling/lsp/lsp-hover-dedupe.test.js b/tests/tooling/lsp/lsp-hover-dedupe.test.js deleted file mode 100644 index 3252f0aca..000000000 --- a/tests/tooling/lsp/lsp-hover-dedupe.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsp-hover-dedupe'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const tracePath = path.join(tempRoot, 'trace.jsonl'); -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); - -const docText = 'int add(int a, int b) { return a + b; }\n'; -const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'cpp', - effectiveExt: '.cpp', - docHash: 'dochash_hover_dedupe' -}]; - -const chunkUid = 'ck64:v1:test:src/sample.cpp:abcd1234'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_abcd1234', - file: 'src/sample.cpp', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'add', kind: 'function' } -}]; - -const originalTrace = process.env.POC_LSP_TRACE; -process.env.POC_LSP_TRACE = tracePath; -try { - await collectLspTypes({ - rootDir: tempRoot, - vfsRoot: tempRoot, - cacheRoot: path.join(tempRoot, 'cache'), - documents, - targets, - cmd: process.execPath, - args: [serverPath, '--mode', 'clangd-duplicate-symbols'], - hoverConcurrency: 8, - parseSignature: () => ({ - signature: 'add', - returnType: null, - paramTypes: {} - }) - }); -} finally { - process.env.POC_LSP_TRACE = originalTrace; -} - -const traceRaw = await fs.readFile(tracePath, 'utf8'); -const events = traceRaw.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -const hoverCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/hover').length; - -assert.equal(hoverCount, 1, 'expected duplicate symbol hover requests to be deduped'); - -console.log('LSP hover dedupe test passed'); diff --git a/tests/tooling/lsp/lsp-param-fallback-from-source.test.js b/tests/tooling/lsp/lsp-param-fallback-from-source.test.js deleted file mode 100644 index f73b80657..000000000 --- a/tests/tooling/lsp/lsp-param-fallback-from-source.test.js +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsp-param-fallback-from-source'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); -const docText = 'int add(int a, int b) { return a + b; }\n'; -const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'cpp', - effectiveExt: '.cpp' -}]; - -const chunkUid = 'ck64:v1:test:src/sample.cpp:feedface'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_feedface', - file: 'src/sample.cpp', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'add', kind: 'function' } -}]; - -const parseSignature = (detailText) => { - const detail = String(detailText || '').trim(); - if (!detail) return null; - if (detail === 'int (int, int)') { - return { - signature: detail, - returnType: 'int', - paramTypes: {} - }; - } - const named = detail.match(/^int\s+add\s*\(\s*int\s+([A-Za-z_]\w*)\s*,\s*int\s+([A-Za-z_]\w*)\s*\)$/); - if (!named) return null; - return { - signature: detail, - returnType: 'int', - paramTypes: { - [named[1]]: 'int', - [named[2]]: 'int' - } - }; -}; - -const result = await collectLspTypes({ - rootDir: tempRoot, - vfsRoot: tempRoot, - documents, - targets, - cmd: process.execPath, - args: [serverPath, '--mode', 'clangd-compact'], - parseSignature -}); - -const payload = result.byChunkUid?.[chunkUid]?.payload || null; -assert.ok(payload, 'expected payload for chunkUid'); -assert.equal(payload.returnType, 'int'); -assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); -assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); - -console.log('LSP source fallback param recovery test passed'); diff --git a/tests/tooling/lsp/lsp-provider-config-modularization.test.js b/tests/tooling/lsp/lsp-provider-config-modularization.test.js new file mode 100644 index 000000000..c48c3375c --- /dev/null +++ b/tests/tooling/lsp/lsp-provider-config-modularization.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const barrelPath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider.js'); +const indexPath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'index.js'); +const normalizePath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'normalize.js'); +const preflightLanguagePath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'preflight-language.js'); +const workspacePath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'workspace.js'); +const runtimePath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'runtime.js'); +const factoryPath = path.join(root, 'src', 'index', 'tooling', 'lsp-provider', 'factory.js'); + +for (const target of [barrelPath, indexPath, normalizePath, preflightLanguagePath, workspacePath, runtimePath, factoryPath]) { + assert.equal(fs.existsSync(target), true, `missing expected LSP provider config module: ${target}`); +} + +const barrelSource = fs.readFileSync(barrelPath, 'utf8'); +const indexSource = fs.readFileSync(indexPath, 'utf8'); +const factorySource = fs.readFileSync(factoryPath, 'utf8'); + +assert.equal( + barrelSource.includes("./lsp-provider/index.js"), + true, + 'expected top-level lsp-provider barrel to delegate to the modularized index' +); + +for (const marker of [ + "./factory.js", + "./normalize.js", + "./workspace.js" +]) { + assert.equal(indexSource.includes(marker), true, `expected lsp-provider index to compose ${marker}`); +} + +for (const marker of [ + "./normalize.js", + "./preflight-language.js", + "./runtime.js", + "./workspace.js", + 'await awaitToolingProviderPreflight(', + 'resolveRuntimeCommandFromPreflight(' +]) { + assert.equal(factorySource.includes(marker), true, `expected lsp-provider factory to use ${marker}`); +} + +for (const legacyInlineMarker of [ + 'const normalizeServerConfig = (server, index) => {', + 'const resolveLuaWorkspaceLibraryPreflight = ({ server, repoRoot }) => {', + 'const createConfiguredLspProvider = (server) => {' +]) { + assert.equal( + barrelSource.includes(legacyInlineMarker), + false, + `expected top-level lsp-provider barrel to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('LSP provider config modularization test passed'); diff --git a/tests/tooling/lsp/lsp-restart-generation-safety.test.js b/tests/tooling/lsp/lsp-restart-generation-safety.test.js deleted file mode 100644 index f4db62c7a..000000000 --- a/tests/tooling/lsp/lsp-restart-generation-safety.test.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsp-generation'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const counterPath = path.join(tempRoot, 'spawn-counter.txt'); -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); - -const countSpawns = async () => { - try { - const counterRaw = await fs.readFile(counterPath, 'utf8'); - return counterRaw.trim().split(/\r?\n/).filter(Boolean).length; - } catch { - return 0; - } -}; - -const waitForSpawns = async (expected, timeoutMs = 2000) => { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (await countSpawns() >= expected) return; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error(`Timed out waiting for ${expected} LSP spawn(s).`); -}; - -const client = createLspClient({ - cmd: process.execPath, - args: [serverPath], - env: { ...process.env, POC_LSP_COUNTER: counterPath }, - log: () => {} -}); - -client.start(); -await waitForSpawns(1); -client.kill(); -client.start(); -await waitForSpawns(2); - -await client.initialize({ rootUri: pathToFileURL(tempRoot).href }); -await client.shutdownAndExit(); -await new Promise((resolve) => setTimeout(resolve, 100)); -client.kill(); - -const spawns = await countSpawns(); -assert.equal(spawns, 2, 'expected only two LSP spawns after restart'); - -console.log('LSP generation safety test passed'); diff --git a/tests/tooling/lsp/lsp-shutdown.test.js b/tests/tooling/lsp/lsp-shutdown.test.js deleted file mode 100644 index 75ea5927d..000000000 --- a/tests/tooling/lsp/lsp-shutdown.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; - -const root = process.cwd(); -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); -const logs = []; -const client = createLspClient({ - cmd: process.execPath, - args: [serverPath, '--exit-on-shutdown'], - log: (message) => logs.push(message) -}); - -await client.initialize({ rootUri: pathToFileURL(root).href }); -await client.shutdownAndExit(); -await new Promise((resolve) => setTimeout(resolve, 200)); -client.kill(); - -if (logs.some((line) => line.includes('ERR_STREAM_DESTROYED'))) { - throw new Error('LSP shutdown emitted ERR_STREAM_DESTROYED.'); -} -if (logs.some((line) => /\[lsp\]\s+write error:/i.test(line))) { - throw new Error('LSP shutdown emitted unexpected LSP write error.'); -} -if (logs.some((line) => /\bEPIPE\b/i.test(line))) { - throw new Error('LSP shutdown emitted EPIPE log noise.'); -} - -console.log('LSP shutdown test passed'); diff --git a/tests/tooling/lsp/lsp-vfs-didopen.test.js b/tests/tooling/lsp/lsp-vfs-didopen.test.js deleted file mode 100644 index 64ef21565..000000000 --- a/tests/tooling/lsp/lsp-vfs-didopen.test.js +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'lsp-vfs-didopen'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const tracePath = path.join(tempRoot, 'trace.jsonl'); -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); -const docText = 'int add(int a, int b) { return a + b; }\n'; -const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'cpp', - effectiveExt: '.cpp' -}]; - -const chunkUid = 'ck64:v1:test:src/sample.cpp:deadbeef'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/sample.cpp', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'add', kind: 'function' } -}]; - -const originalTrace = process.env.POC_LSP_TRACE; -process.env.POC_LSP_TRACE = tracePath; -try { - await collectLspTypes({ - rootDir: tempRoot, - vfsRoot: tempRoot, - documents, - targets, - cmd: process.execPath, - args: [serverPath, '--mode', 'clangd'], - parseSignature: (detail) => ({ - signature: detail, - returnType: 'int', - paramTypes: { a: 'int', b: 'int' } - }) - }); -} finally { - process.env.POC_LSP_TRACE = originalTrace; -} - -const traceRaw = await fs.readFile(tracePath, 'utf8'); -const events = traceRaw.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -const didOpenIndex = events.findIndex((evt) => evt.kind === 'notification' && evt.method === 'textDocument/didOpen'); -const documentSymbolIndex = events.findIndex((evt) => evt.kind === 'request' && evt.method === 'textDocument/documentSymbol'); - -assert.ok(didOpenIndex !== -1, 'expected didOpen notification to be recorded'); -assert.ok(documentSymbolIndex !== -1, 'expected documentSymbol request to be recorded'); -assert.ok(didOpenIndex < documentSymbolIndex, 'expected didOpen before documentSymbol'); - -console.log('LSP VFS didOpen ordering test passed'); diff --git a/tests/tooling/lsp/metrics-contract-matrix.test.js b/tests/tooling/lsp/metrics-contract-matrix.test.js new file mode 100644 index 000000000..9326df342 --- /dev/null +++ b/tests/tooling/lsp/metrics-contract-matrix.test.js @@ -0,0 +1,397 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveLspServerPresetByKey } from '../../../src/index/tooling/lsp-presets.js'; +import { + getLspProviderDelta, + listDefaultEnabledLspProviderIds, + listLspProviderDeltas +} from '../../../src/index/tooling/lsp-provider-deltas.js'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { listToolingProviders } from '../../../src/index/tooling/provider-registry.js'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; +import { createToolingGuard } from '../../../src/integrations/tooling/providers/shared.js'; +import { createFramedJsonRpcParser, getJsonRpcWriter } from '../../../src/shared/jsonrpc.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { FakeChildProcess } from './helpers/fake-child-process.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const runMetricsAggregateCases = async () => { + const tempRoot = resolveTestCachePath(root, `lsp-metrics-contract-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + + const cppText = 'int add(int a, int b) { return a + b; }\n'; + const dartText = 'String greet(String name) { return name; }\n'; + + registerDefaultToolingProviders(); + const aggregate = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-test', 'dart'], + lsp: { + enabled: true, + servers: [{ + id: 'test', + cmd: process.execPath, + args: [serverPath, '--mode', 'emit-fd-pressure-warning'], + languages: ['cpp'], + uriScheme: 'poc-vfs' + }] + }, + dart: { + enabled: true, + requireWorkspaceModel: false, + cmd: 'dart-not-found' + } + }, + cache: { enabled: false } + }, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.cpp#seg:metrics-aggregate.cpp', + text: cppText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-cpp-metrics-aggregate' + }, { + virtualPath: '.poc-vfs/src/sample.dart#seg:metrics-aggregate.dart', + text: dartText, + languageId: 'dart', + effectiveExt: '.dart', + docHash: 'hash-dart-metrics-aggregate' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:metrics-aggregate', + chunkId: 'chunk_metrics_cpp', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: cppText.length } + }, + virtualPath: '.poc-vfs/src/sample.cpp#seg:metrics-aggregate.cpp', + virtualRange: { start: 0, end: cppText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'cpp' + }, { + chunkRef: { + docId: 1, + chunkUid: 'ck64:v1:test:src/sample.dart:metrics-aggregate', + chunkId: 'chunk_metrics_dart', + file: 'src/sample.dart', + segmentUid: null, + segmentId: null, + range: { start: 0, end: dartText.length } + }, + virtualPath: '.poc-vfs/src/sample.dart#seg:metrics-aggregate.dart', + virtualRange: { start: 0, end: dartText.length }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'dart' + }], + kinds: ['types'] + }); + + assert.equal(aggregate.metrics?.providersPlanned, 2); + assert.equal(aggregate.metrics?.providersExecuted, 2); + assert.equal(aggregate.metrics?.providersContributed, 1); + assert.ok(aggregate.metrics?.preflights && typeof aggregate.metrics.preflights === 'object'); + assert.equal(Number(aggregate.metrics?.preflights?.total) >= 1, true); + assert.equal(typeof aggregate.metrics?.preflights?.byPolicy, 'object'); + assert.equal(Number(aggregate.metrics?.preflights?.teardown?.timedOut || 0), 0); + assert.equal(aggregate.metrics?.degradedProviderCount, 1); + assert.equal(Number(aggregate.metrics?.degradedWarningChecks || 0) >= 1, true); + assert.equal(Number(aggregate.metrics?.requests?.requests || 0) >= 1, true); + assert.equal(Number(aggregate.metrics?.health?.fdPressureEvents || 0) >= 1, true); + assert.equal(Number(aggregate.metrics?.health?.providersWithFdPressure || 0) >= 1, true); + assert.equal(Number(aggregate.metrics?.health?.pooledProviders || 0) >= 1, true); + assert.equal(aggregate.metrics?.capabilities?.providersWithCapabilitiesMask, 1); + assert.equal(aggregate.metrics?.capabilities?.documentSymbol, 1); + assert.equal(aggregate.metrics?.capabilities?.hover, 1); + assert.equal(aggregate.metrics?.capabilities?.semanticTokens, 0); + assert.equal(aggregate.metrics?.capabilities?.signatureHelp, 0); + assert.equal(aggregate.metrics?.capabilities?.inlayHints, 0); + assert.equal(aggregate.metrics?.capabilities?.definition, 0); + assert.equal(aggregate.metrics?.capabilities?.typeDefinition, 0); + assert.equal(aggregate.metrics?.capabilities?.references, 0); + assert.ok(aggregate.metrics?.hover && typeof aggregate.metrics.hover === 'object'); + assert.equal(Number.isFinite(Number(aggregate.metrics?.hover?.requested)), true); + assert.equal(Number.isFinite(Number(aggregate.metrics?.hover?.signatureHelpTimedOut)), true); + assert.equal(Number.isFinite(Number(aggregate.metrics?.hover?.skippedByGlobalDisable)), true); + assert.deepEqual(Object.keys(aggregate.metrics?.providerRuntime || {}), ['dart', 'lsp-test']); + assert.equal(aggregate.metrics?.providerRuntime?.dart?.degraded?.active, true); + assert.equal(aggregate.metrics?.providerRuntime?.['lsp-test']?.degraded?.active, false); + assert.equal(aggregate.metrics?.providerRuntime?.['lsp-test']?.pooling?.enabled, true); + assert.equal(aggregate.metrics?.providerRuntime?.['lsp-test']?.pooling?.sessionKeyPresent, true); + assert.ok(aggregate.metrics?.providerRuntime?.['lsp-test']?.hover && typeof aggregate.metrics.providerRuntime['lsp-test'].hover === 'object'); + assert.equal(aggregate.metrics?.providerRuntime?.['lsp-test']?.capabilities?.documentSymbol, true); + assert.ok(aggregate.diagnostics?.dart?.preflight && typeof aggregate.diagnostics.dart.preflight === 'object'); + assert.equal(typeof aggregate.diagnostics?.dart?.preflight?.preflightPolicy, 'string'); + + const capabilityRollup = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-test'], + lsp: { + enabled: true, + servers: [{ + id: 'test', + cmd: process.execPath, + args: [serverPath, '--mode', 'all-capabilities'], + languages: ['cpp'], + uriScheme: 'poc-vfs' + }] + } + }, + cache: { enabled: false } + }, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.cpp#seg:capability-rollup.cpp', + text: cppText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-cpp-capability-rollup' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:capability-rollup', + chunkId: 'chunk_metrics_cpp_capability', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: cppText.length } + }, + virtualPath: '.poc-vfs/src/sample.cpp#seg:capability-rollup.cpp', + virtualRange: { start: 0, end: cppText.length }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'cpp' + }], + kinds: ['types'] + }); + + assert.equal(capabilityRollup.metrics?.capabilities?.providersWithCapabilitiesMask, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.documentSymbol, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.hover, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.semanticTokens, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.signatureHelp, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.inlayHints, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.definition, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.typeDefinition, 1); + assert.equal(capabilityRollup.metrics?.capabilities?.references, 1); + const providerCapabilities = capabilityRollup.metrics?.providerRuntime?.['lsp-test']?.capabilities || null; + assert.equal(providerCapabilities?.definition, true); + assert.equal(providerCapabilities?.typeDefinition, true); + assert.equal(providerCapabilities?.references, true); + assert.equal(providerCapabilities?.semanticTokens, true); + assert.equal(providerCapabilities?.inlayHints, true); + assert.equal(Number.isFinite(Number(capabilityRollup.metrics?.providerRuntime?.['lsp-test']?.hover?.requested)), true); + + const hoverTimeout = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['lsp-test'], + lsp: { + enabled: true, + servers: [{ + id: 'test', + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-signature-help-two-symbols'], + languages: ['cpp'], + uriScheme: 'poc-vfs', + signatureHelpTimeoutMs: 1000, + hoverDisableAfterTimeouts: 1, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false + }] + } + }, + cache: { enabled: false } + }, { + documents: [{ + virtualPath: '.poc-vfs/src/sample.cpp#seg:hover-timeout-rollup.cpp', + text: 'int add(int a, int b) { return a + b; }\nint sub(int a, int b) { return a - b; }\n', + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'hash-cpp-hover-timeout-rollup' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:hover-timeout-rollup', + chunkId: 'chunk_metrics_cpp_timeout', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 4, end: 7 } + }, + virtualPath: '.poc-vfs/src/sample.cpp#seg:hover-timeout-rollup.cpp', + virtualRange: { start: 4, end: 7 }, + symbolHint: { name: 'add', kind: 'function' }, + languageId: 'cpp' + }, { + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:hover-timeout-rollup:sub', + chunkId: 'chunk_metrics_cpp_timeout_sub', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 44, end: 47 } + }, + virtualPath: '.poc-vfs/src/sample.cpp#seg:hover-timeout-rollup.cpp', + virtualRange: { start: 44, end: 47 }, + symbolHint: { name: 'sub', kind: 'function' }, + languageId: 'cpp' + }], + kinds: ['types'] + }); + assert.equal(Number(hoverTimeout.metrics?.hover?.signatureHelpRequested || 0) >= 1, true); + assert.equal(Number(hoverTimeout.metrics?.hover?.signatureHelpTimedOut || 0) >= 1, true); + assert.equal(Number(hoverTimeout.metrics?.hover?.providersWithActivity || 0) >= 1, true); + assert.equal( + Number(hoverTimeout.metrics?.hover?.skippedByGlobalDisable || 0) >= 1 + || Number(hoverTimeout.metrics?.hover?.skippedByAdaptiveDisable || 0) >= 1, + true + ); + assert.equal(Number(hoverTimeout.metrics?.providerRuntime?.['lsp-test']?.hover?.signatureHelpTimedOut || 0) >= 1, true); + assert.equal( + Number(hoverTimeout.metrics?.providerRuntime?.['lsp-test']?.hover?.skippedByGlobalDisable || 0) >= 1 + || Number(hoverTimeout.metrics?.providerRuntime?.['lsp-test']?.hover?.skippedByAdaptiveDisable || 0) >= 1, + true + ); +}; + +const runClientMetricsCacheCase = async () => { + const client = createLspClient({ + cmd: 'fake-lsp', + args: ['--stdio'], + log: () => {}, + spawnProcess: () => { + const child = new FakeChildProcess(); + const writer = getJsonRpcWriter(child.stdout); + const outboundParser = createFramedJsonRpcParser({ + onMessage: (message) => { + if (message?.method === 'initialize') { + writer.write({ + jsonrpc: '2.0', + id: message.id, + result: { capabilities: { documentSymbolProvider: true } } + }); + return; + } + if (message?.method === 'textDocument/documentSymbol') { + writer.write({ jsonrpc: '2.0', id: message.id, result: [] }); + } + } + }); + child.stdin.on('data', (chunk) => outboundParser.push(chunk)); + return child; + } + }); + + try { + await client.initialize({ + rootUri: 'file:///fake', + capabilities: { textDocument: { documentSymbol: { hierarchicalDocumentSymbolSupport: true } } }, + timeoutMs: 1000 + }); + const snapshot1 = client.getMetrics(); + const snapshot2 = client.getMetrics(); + assert.equal(snapshot1, snapshot2); + await client.request('textDocument/documentSymbol', { + textDocument: { uri: 'file:///fake/sample.cpp' } + }, { timeoutMs: 1000 }); + const snapshot3 = client.getMetrics(); + assert.notEqual(snapshot2, snapshot3); + const snapshot4 = client.getMetrics(); + assert.equal(snapshot3, snapshot4); + assert.equal(Number(snapshot4?.byMethod?.['textDocument/documentSymbol']?.requests || 0), 1); + } finally { + await Promise.resolve(client.kill()); + } +}; + +const runFailureAccountingCase = async () => { + const guard = createToolingGuard({ + name: 'lsp-guard-test', + retries: 1, + breakerThreshold: 1, + timeoutMs: 100, + log: () => {} + }); + + let attempt = 0; + await guard.run(() => { + attempt += 1; + if (attempt === 1) throw new Error('first failure'); + return 'ok'; + }); + assert.equal(guard.isOpen(), false); + + await assert.rejects(() => guard.run(() => { + throw new Error('target failure'); + }), /target failure/); + assert.equal(guard.isOpen(), true); +}; + +const runProviderDeltaManifestCase = async () => { + const policyPath = path.join(root, 'docs', 'tooling', 'lsp-default-enable-policy.json'); + const policy = JSON.parse(await fs.readFile(policyPath, 'utf8')); + const policyIds = (Array.isArray(policy?.providers) ? policy.providers : []) + .filter((entry) => entry?.defaultEnabled === true) + .map((entry) => String(entry.id || '').trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + + const deltas = listLspProviderDeltas().sort((left, right) => left.id.localeCompare(right.id)); + const deltaIds = deltas.map((delta) => delta.id); + assert.deepEqual(deltaIds, policyIds); + assert.deepEqual(listDefaultEnabledLspProviderIds(), policyIds); + + registerDefaultToolingProviders(); + const providerIds = listToolingProviders({ + lsp: { + enabled: true, + servers: [ + { preset: 'gopls', languages: ['go'] }, + { preset: 'rust-analyzer', languages: ['rust'] }, + { preset: 'yaml-language-server', languages: ['yaml'] }, + { preset: 'lua-language-server', languages: ['lua'] }, + { preset: 'zls', languages: ['zig'] } + ] + } + }).map((provider) => String(provider?.id || '').trim()).filter(Boolean); + + for (const delta of deltas) { + assert.equal(Number.isFinite(Number(delta.requestBudgetWeight)), true); + assert.equal(Number.isFinite(Number(delta.confidenceBias)), true); + assert.equal(Array.isArray(delta.fallbackReasonHints) && delta.fallbackReasonHints.length > 0, true); + assert.equal(Boolean(delta.adaptiveDocScope) || delta.workspaceChecks.length > 0 || delta.bootstrapChecks.length > 0, true); + assert.deepEqual(getLspProviderDelta(delta.id)?.id, delta.id); + if (delta.class === 'preset') { + assert.ok(resolveLspServerPresetByKey(delta.id)); + } else { + assert.equal(providerIds.includes(delta.id), true); + } + } +}; + +await runMetricsAggregateCases(); +await runClientMetricsCacheCase(); +await runFailureAccountingCase(); +await runProviderDeltaManifestCase(); + +console.log('LSP metrics contract matrix test passed'); diff --git a/tests/tooling/lsp/multi-stage-request-ordering.test.js b/tests/tooling/lsp/multi-stage-request-ordering.test.js new file mode 100644 index 000000000..3f38e1b47 --- /dev/null +++ b/tests/tooling/lsp/multi-stage-request-ordering.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-stage-order-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = '// int add(int, int)\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stage-order.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:stage-order'; +const symbolStart = docText.indexOf('add'); + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (!detail) return null; + if ( + detail === 'add' + || detail === 'int add(int, int)' + || detail === 'int add(int a, int b)' + || detail === '// int add(int, int)' + ) { + return { + signature: 'int add(int, int)', + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + return null; +}; + +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_stage_order', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: symbolStart, end: symbolStart + 3 } + }, + virtualPath, + virtualRange: { start: symbolStart, end: symbolStart + 3 }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'all-capabilities'], + parseSignature + }); +}); + +const events = await parseJsonLinesFile(tracePath); +const orderedMethods = events + .filter((entry) => entry.kind === 'request') + .map((entry) => entry.method) + .filter((method) => [ + 'textDocument/hover', + 'textDocument/signatureHelp', + 'textDocument/definition', + 'textDocument/typeDefinition', + 'textDocument/references' + ].includes(method)); + +assert.deepEqual(orderedMethods, [ + 'textDocument/hover', + 'textDocument/signatureHelp', + 'textDocument/definition', + 'textDocument/typeDefinition', + 'textDocument/references' +], 'expected deterministic multi-stage request ordering'); + +console.log('LSP multi-stage request ordering test passed'); diff --git a/tests/tooling/lsp/path-selection-contract-matrix.test.js b/tests/tooling/lsp/path-selection-contract-matrix.test.js new file mode 100644 index 000000000..b54f75855 --- /dev/null +++ b/tests/tooling/lsp/path-selection-contract-matrix.test.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { + __classifyLspDocumentPathPolicyForTests, + resolveLspStartupDocuments +} from '../../../src/integrations/tooling/providers/lsp/path-policy.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); + +const runPolicyClassificationCases = () => { + const policyCases = [ + { + label: 'gopls skips module manifests', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'gopls', + virtualPath: '.poc-vfs/examples/go/go.mod' + }), + expected: { skipDocument: true } + }, + { + label: 'pyright docs are deprioritized but retained', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'pyright', + virtualPath: '.poc-vfs/docs/conf.py' + }), + expected: { + skipDocument: false, + deprioritized: true, + suppressInteractive: true, + skipDocumentSymbol: true + } + }, + { + label: 'clangd source remains eligible', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'clangd', + virtualPath: '.poc-vfs/src/check_error.c' + }), + expected: { + skipDocument: false, + deprioritized: false, + skipDocumentSymbol: false + } + }, + { + label: 'clangd third_party is diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'clangd', + virtualPath: '.poc-vfs/third_party/abseil/strings/tests/ascii_test.cc' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'gopls vendor is diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'gopls', + virtualPath: '.poc-vfs/vendor/example.com/demo/lib.go' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'gopls tools are diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'gopls', + virtualPath: '.poc-vfs/tools/generator/main.go' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'clangd docs are diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'clangd', + virtualPath: '.poc-vfs/docs/tutorial/example.cc' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'clangd contrib is diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'clangd', + virtualPath: '.poc-vfs/contrib/qat/private_key_providers/source/qat.cc' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'clangd platform runners are diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'clangd', + virtualPath: '.poc-vfs/pkgs/flutter_http_example/windows/runner/flutter_window.cpp' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'sourcekit docs are diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'sourcekit', + virtualPath: '.poc-vfs/docs/Demo.swift' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + }, + { + label: 'pyright github automation suppresses interactive work', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'pyright', + virtualPath: '.poc-vfs/.github/scripts/release.py' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true, + suppressInteractive: true + } + }, + { + label: 'gopls hack paths are diagnostics-only', + policy: __classifyLspDocumentPathPolicyForTests({ + providerId: 'gopls', + virtualPath: '.poc-vfs/hack/gen/main.go' + }), + expected: { + skipDocument: false, + skipDocumentSymbol: true + } + } + ]; + + for (const testCase of policyCases) { + for (const [key, value] of Object.entries(testCase.expected)) { + assert.equal(testCase.policy?.[key], value, `unexpected ${key} for ${testCase.label}`); + } + } + + const startupSelection = resolveLspStartupDocuments({ + providerId: 'gopls', + captureDiagnostics: false, + targets: [{ virtualPath: '.poc-vfs/src/main.go' }], + documents: [{ virtualPath: '.poc-vfs/tools/generator/main.go' }, { virtualPath: '.poc-vfs/src/main.go' }] + }); + assert.equal(startupSelection.documents.length, 1); + assert.equal(startupSelection.skippedByDocumentSymbolPolicy, 1); + assert.equal(startupSelection.skippedByMissingTargets, 0); + + const pyrightLowValueStartupSelection = resolveLspStartupDocuments({ + providerId: 'pyright', + captureDiagnostics: false, + targets: [{ virtualPath: '.poc-vfs/.github/scripts/release.py' }], + documents: [{ virtualPath: '.poc-vfs/.github/scripts/release.py' }] + }); + assert.equal(pyrightLowValueStartupSelection.documents.length, 0); + assert.equal(pyrightLowValueStartupSelection.skippedByDocumentSymbolPolicy, 1); + + const untargetedSelection = resolveLspStartupDocuments({ + providerId: 'clangd', + captureDiagnostics: false, + targets: [], + documents: [{ virtualPath: '.poc-vfs/src/check_error.c' }] + }); + assert.equal(untargetedSelection.documents.length, 0); + assert.equal(untargetedSelection.skippedByMissingTargets, 1); +}; + +const runSkipInitializeCases = async () => { + const cases = [ + { + label: 'document-symbol path policy skips initialize', + providerId: 'clangd', + virtualPath: '.poc-vfs/third_party/fmt/test/format-test.cc', + docText: 'int format_test() { return 0; }\n', + documents(tempRoot, docText, virtualPath) { + return [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cc' + }]; + }, + targets(docText, virtualPath) { + return [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:third_party/fmt/test/format-test.cc', + chunkId: 'chunk_docsymbol_policy_skip_init', + file: 'third_party/fmt/test/format-test.cc', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'format_test', kind: 'function' } + }]; + }, + assertResult(result, markerPath) { + assert.equal(Object.keys(result.byChunkUid).length, 0); + assert.equal(result.runtime?.selection?.selectedDocs, 0); + assert.match(String(result.runtime?.selection?.reason || ''), /document-symbol-path-policy/); + assert.equal(result.checks.some((check) => check?.name === 'tooling_initialize_failed'), false); + return fs.stat(markerPath).then(() => false).catch(() => true); + } + }, + { + label: 'no targets skips initialize', + providerId: 'pyright', + virtualPath: '.poc-vfs/src/no_target.py#seg:no_target.py', + docText: 'def no_target():\n return 0\n', + documents(tempRoot, docText, virtualPath) { + return [{ + virtualPath, + text: docText, + languageId: 'python', + effectiveExt: '.py' + }]; + }, + targets() { + return []; + }, + async assertResult(result, markerPath) { + assert.equal(Object.keys(result.byChunkUid).length, 0); + assert.equal(result.runtime?.selection?.selectedDocs, 0); + assert.equal(result.runtime?.selection?.skippedByMissingTargets, 1); + assert.match(String(result.runtime?.selection?.reason || ''), /no-targets/); + assert.equal(result.checks.some((check) => check?.name === 'tooling_initialize_failed'), false); + await assert.rejects(fs.stat(markerPath)); + } + } + ]; + + for (const [index, testCase] of cases.entries()) { + const tempRoot = resolveTestCachePath(root, `lsp-path-selection-${process.pid}-${Date.now()}-${index}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + const markerPath = path.join(tempRoot, 'server-started.txt'); + const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + providerId: testCase.providerId, + documents: testCase.documents(tempRoot, testCase.docText, testCase.virtualPath), + targets: testCase.targets(testCase.docText, testCase.virtualPath), + cmd: process.execPath, + args: [ + '-e', + "require('node:fs').writeFileSync(process.argv[1], 'started'); setTimeout(() => {}, 5000);", + markerPath + ], + timeoutMs: 1000, + retries: 0 + }); + await testCase.assertResult(result, markerPath); + } +}; + +runPolicyClassificationCases(); +await runSkipInitializeCases(); + +console.log('LSP path selection contract matrix test passed'); diff --git a/tests/tooling/lsp/phpactor-provider-composer-lock-missing-preflight.test.js b/tests/tooling/lsp/phpactor-provider-composer-lock-missing-preflight.test.js new file mode 100644 index 000000000..67b7448da --- /dev/null +++ b/tests/tooling/lsp/phpactor-provider-composer-lock-missing-preflight.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import { + buildSingleSymbolInputs, + createLspProviderTempRepo +} from '../../helpers/lsp-provider-fixture.js'; +import { runDedicatedProviderDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'phpactor-provider-composer-lock-missing-preflight', + directories: ['src'], + files: [{ path: 'composer.json', content: '{ "name": "fixture/php" }\n' }] +}); +const docText = ' new Promise((resolve) => setTimeout(resolve, ms)); + +const walkJsFiles = (dir) => { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walkJsFiles(abs)); + continue; + } + if (entry.isFile() && abs.endsWith('.js')) out.push(abs); + } + return out; +}; + +const buildCtx = ({ logger = () => {}, toolingConfig = {} } = {}) => ({ + repoRoot: root, + buildRoot: root, + toolingConfig, + logger +}); + +const buildInputs = (suffix = 'fixture') => ({ + documents: [{ virtualPath: `src/${suffix}.fixture`, languageId: 'fixture' }], + targets: [{ chunkRef: { chunkUid: `chunk-${suffix}`, chunkId: `chunk-${suffix}`, file: `src/${suffix}.fixture` } }] +}); + +const runGuardCases = () => { + const matches = []; + for (const abs of walkJsFiles(toolingRoot)) { + const content = fs.readFileSync(abs, 'utf8'); + if (providerPreflightCallPattern.test(content)) { + matches.push(path.relative(root, abs).replace(/\\/g, '/')); + } + } + assert.equal(matches.length > 0, true); + assert.deepEqual(matches, [expectedPreflightEntrypoint]); + + const bannedSnippets = [ + 'preflight?.commandProfile && typeof preflight.commandProfile === \'object\'\n ? preflight.commandProfile\n : resolveToolingCommandProfile(', + 'if (!commandProfile) {\n commandProfile = resolveToolingCommandProfile(' + ]; + for (const relativePath of runtimeFiles) { + const content = fs.readFileSync(path.join(root, relativePath), 'utf8'); + assert.equal(content.includes('resolveRuntimeCommandFromPreflight('), true, `expected helper usage in ${relativePath}`); + assert.equal(content.includes('await awaitToolingProviderPreflight('), true, `expected preflight await usage in ${relativePath}`); + for (const snippet of bannedSnippets) { + assert.equal(content.includes(snippet), false, `unexpected runtime reprobe snippet in ${relativePath}`); + } + } +}; + +const runMetadataCoverageCase = () => { + registerDefaultToolingProviders(); + const toolingConfig = { + lsp: { + enabled: true, + servers: listLspServerPresets().map((preset) => ({ + id: preset.id, + preset: preset.id, + cmd: preset.cmd, + args: Array.isArray(preset.args) ? preset.args : preset.args ? [preset.args] : [], + languages: Array.isArray(preset.languages) ? preset.languages : [] + })) + } + }; + const providers = listToolingProviders(toolingConfig); + const preflightProviders = providers.filter((provider) => typeof provider?.preflight === 'function'); + assert.equal(preflightProviders.length > 0, true); + + const validClasses = new Set(['probe', 'workspace', 'dependency']); + const validPolicies = new Set(['required', 'optional']); + for (const provider of preflightProviders) { + const id = String(provider?.id || ''); + assert.equal(typeof provider.preflightId === 'string' && provider.preflightId.trim().length > 0, true, `expected preflightId for ${id}`); + assert.equal(validClasses.has(String(provider.preflightClass || '').trim().toLowerCase()), true, `expected preflightClass for ${id}`); + assert.equal(validPolicies.has(String(provider.preflightPolicy || '').trim().toLowerCase()), true, `expected preflightPolicy for ${id}`); + assert.equal(Array.isArray(provider.preflightRuntimeRequirements), true, `expected preflightRuntimeRequirements for ${id}`); + for (const requirement of provider.preflightRuntimeRequirements) { + assert.equal(Boolean(String(requirement?.id || '').trim()), true, `expected requirement id for ${id}`); + assert.equal(Boolean(String(requirement?.cmd || '').trim()), true, `expected requirement cmd for ${id}`); + } + } +}; + +const runCommandProfileHelperCase = () => { + const ctx = { repoRoot: root, toolingConfig: {} }; + const ready = resolveCommandProfilePreflightResult({ + providerId: 'fixture', + requestedCommand: { cmd: process.execPath, args: ['--version'] }, + ctx, + unavailableCheck: { name: 'fixture_command_unavailable', status: 'warn', message: 'fixture command unavailable' } + }); + assert.equal(ready.state, 'ready'); + assert.equal(ready.reasonCode, null); + assert.equal(ready.commandProfile?.probe?.ok, true); + + const degraded = resolveCommandProfilePreflightResult({ + providerId: 'fixture', + requestedCommand: { cmd: 'definitely-missing-command-for-preflight-helper-test', args: [] }, + ctx, + unavailableCheck: { name: 'fixture_command_unavailable', status: 'warn', message: 'fixture command unavailable' } + }); + assert.equal(degraded.state, 'degraded'); + assert.equal(degraded.reasonCode, 'preflight_command_unavailable'); + assert.equal(degraded.check?.name, 'fixture_command_unavailable'); + + const blocked = resolveCommandProfilePreflightResult({ + providerId: 'fixture', + requestedCommand: { cmd: 'definitely-missing-command-for-preflight-helper-test', args: [] }, + ctx, + blockWhenDefinitelyMissing: true, + blockFlag: 'blockSourcekit', + unavailableCheck: { name: 'fixture_command_unavailable', status: 'warn', message: 'fixture command unavailable' } + }); + assert.equal(blocked.state, 'blocked'); + assert.equal(blocked.blockSourcekit, true); + assert.equal(blocked.definitelyMissing, true); + + const luaCaseRoot = path.join(root, '.tmp', `lua-preflight-invalid-layout-${process.pid}-${Date.now()}`); + const luaToolingRoot = path.join(luaCaseRoot, 'tooling-root'); + const luaBinDir = path.join(luaToolingRoot, 'bin'); + fs.mkdirSync(luaBinDir, { recursive: true }); + if (process.platform === 'win32') { + fs.writeFileSync( + path.join(luaBinDir, 'lua-language-server.cmd'), + '@echo off\r\nif "%1"=="-v" exit /b 0\r\nif "%1"=="--version" exit /b 0\r\nexit /b 0\r\n', + 'utf8' + ); + } else { + fs.writeFileSync(path.join(luaBinDir, 'lua-language-server'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + const invalidLayout = resolveCommandProfilePreflightResult({ + providerId: 'lua-language-server', + requestedCommand: { cmd: 'lua-language-server', args: ['-v'] }, + ctx: { + repoRoot: root, + toolingConfig: { dir: luaToolingRoot } + }, + unavailableCheck: { name: 'fixture_command_unavailable', status: 'warn', message: 'fixture command unavailable' } + }); + assert.equal(invalidLayout.state, 'blocked'); + assert.equal(invalidLayout.reasonCode, 'preflight_command_invalid_layout'); + assert.equal(invalidLayout.commandProfile?.probe?.validationFailure?.reasonCode, 'broken-layout'); + assert.match(String(invalidLayout.message || ''), /missing runtime entry/u); + fs.rmSync(luaCaseRoot, { recursive: true, force: true }); + + const runtimeUnknownProbe = resolveRuntimeCommandFromPreflight({ + preflight: { + requestedCommand: { cmd: process.execPath, args: ['--version'] } + }, + fallbackRequestedCommand: { cmd: '', args: [] }, + missingProfileCheck: { name: 'fixture_preflight_command_profile_missing', status: 'warn', message: 'missing profile' } + }); + assert.equal(runtimeUnknownProbe.cmd, process.execPath); + assert.equal(runtimeUnknownProbe.probeKnown, true); + assert.equal(runtimeUnknownProbe.probeOk, true); + assert.equal(runtimeUnknownProbe.checks.length, 0); + + const runtimeResolvedFromFallback = resolveRuntimeCommandFromPreflight({ + preflight: { + state: 'degraded', + reasonCode: 'timeout', + requestedCommand: { cmd: 'pyright-langserver', args: ['--stdio'] } + }, + fallbackRequestedCommand: { cmd: '', args: [] }, + providerId: 'pyright', + repoRoot: root, + toolingConfig: {}, + missingProfileCheck: { name: 'fixture_preflight_command_profile_missing', status: 'warn', message: 'missing profile' } + }); + assert.equal(path.basename(runtimeResolvedFromFallback.cmd).toLowerCase().startsWith('pyright-langserver'), true); + assert.deepEqual(runtimeResolvedFromFallback.args, ['--stdio']); + assert.equal(runtimeResolvedFromFallback.probeKnown, true); + assert.equal(runtimeResolvedFromFallback.commandProfile?.resolved?.cmd?.length > 0, true); + + const dedupedChecks = mergePreflightChecks( + [{ name: 'a', status: 'warn', message: 'm' }, { name: 'a', status: 'warn', message: 'm' }], + { name: 'b', status: 'warn', message: 'm2' }, + [{ name: 'b', status: 'warn', message: 'm2' }] + ); + assert.equal(dedupedChecks.length, 2); +}; + +const runContractCase = () => { + assert.equal(isValidToolingPreflightTransition(TOOLING_PREFLIGHT_STATES.IDLE, TOOLING_PREFLIGHT_STATES.RUNNING), true); + assert.equal(isValidToolingPreflightTransition(TOOLING_PREFLIGHT_STATES.RUNNING, TOOLING_PREFLIGHT_STATES.READY), true); + assert.equal(isValidToolingPreflightTransition(TOOLING_PREFLIGHT_STATES.READY, TOOLING_PREFLIGHT_STATES.RUNNING), false); + + const blockedResult = normalizeToolingPreflightResult({ blockSourcekit: true, timeout: false }); + assert.equal(blockedResult.state, TOOLING_PREFLIGHT_STATES.BLOCKED); + assert.equal(blockedResult.reasonCode, TOOLING_PREFLIGHT_REASON_CODES.LOCK_UNAVAILABLE); + + const timeoutResult = normalizeToolingPreflightResult({ state: 'degraded', timeout: true }); + assert.equal(timeoutResult.state, TOOLING_PREFLIGHT_STATES.DEGRADED); + assert.equal(timeoutResult.reasonCode, TOOLING_PREFLIGHT_REASON_CODES.TIMEOUT); + + const diagnostic = buildToolingPreflightDiagnostic({ + providerId: 'sourcekit', + preflightId: 'sourcekit.package-resolution', + state: TOOLING_PREFLIGHT_STATES.READY, + reasonCode: TOOLING_PREFLIGHT_REASON_CODES.CACHE_HIT, + message: 'cached preflight', + durationMs: 11, + timedOut: false, + cached: true, + startedAtMs: 100, + finishedAtMs: 111 + }); + assert.equal(diagnostic.providerId, 'sourcekit'); + assert.equal(diagnostic.preflightId, 'sourcekit.package-resolution'); + assert.equal(diagnostic.state, TOOLING_PREFLIGHT_STATES.READY); + assert.equal(diagnostic.reasonCode, TOOLING_PREFLIGHT_REASON_CODES.CACHE_HIT); + assert.equal(diagnostic.durationMs, 11); + assert.equal(diagnostic.cached, true); + assert.equal(typeof diagnostic.startedAt, 'string'); + assert.equal(typeof diagnostic.finishedAt, 'string'); +}; + +const runSingleFlightCase = async () => { + const logs = []; + const ctx = buildCtx({ logger: (line) => logs.push(String(line || '')) }); + + let runCount = 0; + const provider = { + id: 'sourcekit-single-flight', + preflightId: 'sourcekit-single-flight.package-resolution', + getConfigHash() { + return 'hash-a'; + }, + async preflight() { + runCount += 1; + await wait(40); + return { state: 'ready', blockSourcekit: false, check: null }; + } + }; + + const inputs = buildInputs('single-flight'); + const waveToken = kickoffToolingProviderPreflights(ctx, [{ provider, ...inputs }]); + assert.equal(typeof waveToken, 'string'); + await wait(5); + const runningSnapshot = readToolingProviderPreflightState(ctx, { provider, inputs }); + assert.equal(runningSnapshot?.state, 'running'); + + const first = await awaitToolingProviderPreflight(ctx, { provider, inputs, waveToken }); + const second = await awaitToolingProviderPreflight(ctx, { provider, inputs, waveToken }); + assert.equal(runCount, 1); + assert.equal(first?.state, 'ready'); + assert.equal(second?.state, 'ready'); + assert.ok(logs.some((line) => line.includes('preflight:start provider=sourcekit-single-flight'))); + assert.ok(logs.some((line) => line.includes('preflight:ok provider=sourcekit-single-flight'))); + assert.ok(logs.some((line) => line.includes('preflight:cache_hit provider=sourcekit-single-flight'))); + + const snapshots = listToolingProviderPreflightStates(ctx); + const successSnapshot = snapshots.find((entry) => entry.providerId === 'sourcekit-single-flight'); + assert.equal(successSnapshot?.state, 'ready'); + assert.equal(successSnapshot?.diagnostic?.state, 'ready'); + + let rejectCount = 0; + const failingProvider = { + id: 'sourcekit-failing', + preflightId: 'sourcekit-failing.package-resolution', + preflightPolicy: 'required', + getConfigHash() { + return 'hash-b'; + }, + async preflight() { + rejectCount += 1; + throw new Error('forced preflight failure'); + } + }; + const failingInputs = buildInputs('single-flight-fail'); + const failingWaveToken = 'failing-wave'; + await assert.rejects( + () => awaitToolingProviderPreflight(ctx, { provider: failingProvider, inputs: failingInputs, waveToken: failingWaveToken }), + /forced preflight failure/ + ); + await assert.rejects( + () => awaitToolingProviderPreflight(ctx, { provider: failingProvider, inputs: failingInputs, waveToken: failingWaveToken }), + /forced preflight failure/ + ); + assert.equal(rejectCount, 1); + const failingSnapshot = readToolingProviderPreflightState(ctx, { provider: failingProvider, inputs: failingInputs }); + assert.equal(failingSnapshot?.state, 'failed'); + assert.equal(failingSnapshot?.diagnostic?.reasonCode, 'preflight_failed'); + + let optionalFailOpenCount = 0; + const optionalProvider = { + id: 'optional-fail-open-provider', + preflightId: 'optional-fail-open-provider.preflight', + preflightPolicy: 'optional', + getConfigHash() { + return 'optional-fail-open-hash'; + }, + async preflight() { + optionalFailOpenCount += 1; + throw new Error('forced optional preflight failure'); + } + }; + const optionalInputs = buildInputs('optional'); + const optionalResult = await awaitToolingProviderPreflight(ctx, { + provider: optionalProvider, + inputs: optionalInputs, + waveToken: 'optional-wave' + }); + assert.equal(optionalFailOpenCount, 1); + assert.equal(optionalResult?.state, 'degraded'); + assert.equal(optionalResult?.blockProvider, false); + const optionalSnapshot = readToolingProviderPreflightState(ctx, { provider: optionalProvider, inputs: optionalInputs }); + assert.equal(optionalSnapshot?.state, 'degraded'); + assert.equal(optionalSnapshot?.diagnostic?.reasonCode, 'preflight_failed'); + + let noopRunCount = 0; + const noopProvider = { + id: 'noop-provider', + preflightId: 'noop-provider.health-check', + getConfigHash() { + return 'noop-hash'; + }, + async preflight() { + noopRunCount += 1; + return { state: 'degraded', reasonCode: 'preflight_command_unavailable' }; + } + }; + const noopInputs = buildInputs('noop'); + const noopResult = await awaitToolingProviderPreflight(ctx, { provider: noopProvider, inputs: noopInputs, waveToken: 'noop-wave' }); + assert.equal(noopRunCount, 1); + assert.equal(noopResult?.state, 'degraded'); + const noopSnapshot = readToolingProviderPreflightState(ctx, { provider: noopProvider, inputs: noopInputs }); + assert.equal(noopSnapshot?.state, 'degraded'); + assert.equal(noopSnapshot?.diagnostic?.reasonCode, 'preflight_command_unavailable'); +}; + +const runEnforcedTimeoutCase = async () => { + let attempts = 0; + const ctx = buildCtx(); + const provider = { + id: 'enforced-timeout-fixture', + preflightId: 'enforced-timeout-fixture.preflight', + preflightTimeoutMs: 40, + getConfigHash() { + return 'enforced-timeout-fixture'; + }, + async preflight() { + attempts += 1; + return await new Promise(() => {}); + } + }; + const inputs = buildInputs('enforced-timeout'); + await assert.rejects( + () => awaitToolingProviderPreflight(ctx, { provider, inputs }), + (error) => error?.code === 'TOOLING_PREFLIGHT_TIMEOUT' + ); + await assert.rejects( + () => awaitToolingProviderPreflight(ctx, { provider, inputs }), + (error) => error?.code === 'TOOLING_PREFLIGHT_TIMEOUT' + ); + assert.equal(attempts, 2); +}; + +const runTimeoutTieringCase = async () => { + const captured = new Map(); + const createProvider = ({ id, preflightClass = null, preflightTimeoutMs = null }) => ({ + id, + preflightId: `${id}.preflight`, + ...(preflightClass ? { preflightClass } : {}), + ...(Number.isFinite(preflightTimeoutMs) ? { preflightTimeoutMs } : {}), + getConfigHash() { + return `${id}-hash`; + }, + async preflight(_ctx, inputs = {}) { + captured.set(id, { + preflightClass: String(inputs.preflightClass || ''), + preflightTimeoutMs: Number(inputs.preflightTimeoutMs) || null + }); + return { state: 'ready' }; + } + }); + const ctx = buildCtx({ + toolingConfig: { + preflight: { + timeoutMs: 1111, + timeoutMsByClass: { + probe: 3210, + workspace: 6543 + } + } + } + }); + const sharedInputs = buildInputs('timeout-tiering'); + await awaitToolingProviderPreflight(ctx, { provider: createProvider({ id: 'probe-provider', preflightClass: 'probe' }), inputs: sharedInputs }); + await awaitToolingProviderPreflight(ctx, { provider: createProvider({ id: 'workspace-provider', preflightClass: 'workspace' }), inputs: sharedInputs }); + await awaitToolingProviderPreflight(ctx, { provider: createProvider({ id: 'dependency-provider', preflightClass: 'dependency' }), inputs: sharedInputs }); + await awaitToolingProviderPreflight(ctx, { + provider: createProvider({ id: 'override-provider', preflightClass: 'workspace', preflightTimeoutMs: 7777 }), + inputs: sharedInputs + }); + assert.equal(captured.get('probe-provider')?.preflightClass, 'probe'); + assert.equal(captured.get('workspace-provider')?.preflightClass, 'workspace'); + assert.equal(captured.get('dependency-provider')?.preflightClass, 'dependency'); + assert.equal(captured.get('probe-provider')?.preflightTimeoutMs, 3210); + assert.equal(captured.get('workspace-provider')?.preflightTimeoutMs, 6543); + assert.equal(captured.get('dependency-provider')?.preflightTimeoutMs, 1111); + assert.equal(captured.get('override-provider')?.preflightTimeoutMs, 7777); +}; + +const runTimeoutEventCase = async () => { + const logs = []; + const ctx = buildCtx({ logger: (line) => logs.push(String(line || '')) }); + const provider = { + id: 'timeout-fixture', + preflightId: 'timeout-fixture.preflight', + getConfigHash() { + return 'timeout-hash'; + }, + async preflight() { + return { state: 'degraded', timeout: true, reasonCode: 'preflight_timeout' }; + } + }; + const result = await awaitToolingProviderPreflight(ctx, { provider, inputs: buildInputs('timeout-event') }); + assert.equal(result?.state, 'degraded'); + assert.equal(result?.timedOut, true); + assert.ok(logs.some((line) => line.includes('preflight:timeout provider=timeout-fixture'))); + assert.ok(logs.some((line) => line.includes('state=degraded'))); +}; + +const runConcurrencySchedulerCase = async () => { + const logs = []; + const ctx = buildCtx({ + logger: (line) => logs.push(String(line || '')), + toolingConfig: { preflight: { maxConcurrency: 1 } } + }); + let runningCount = 0; + let runningPeak = 0; + const createProvider = (id) => ({ + id, + preflightId: `${id}.workspace-model`, + preflightClass: 'workspace', + getConfigHash() { + return `${id}-hash`; + }, + async preflight() { + runningCount += 1; + runningPeak = Math.max(runningPeak, runningCount); + await wait(40); + runningCount = Math.max(0, runningCount - 1); + return { state: 'ready' }; + } + }); + const providerA = createProvider('preflight-a'); + const providerB = createProvider('preflight-b'); + const plans = [ + { provider: providerA, ...buildInputs('preflight-a') }, + { provider: providerB, ...buildInputs('preflight-b') } + ]; + const waveToken = kickoffToolingProviderPreflights(ctx, plans); + await Promise.all([ + awaitToolingProviderPreflight(ctx, { provider: providerA, inputs: plans[0], waveToken }), + awaitToolingProviderPreflight(ctx, { provider: providerB, inputs: plans[1], waveToken }) + ]); + assert.equal(runningPeak, 1); + const metrics = getToolingProviderPreflightSchedulerMetrics(ctx); + assert.equal(metrics.maxConcurrency, 1); + assert.ok(metrics.queuedTotal >= 1); + assert.ok(metrics.queueDepthPeak >= 1); + assert.ok(metrics.queueWaitSamples >= 1); + assert.ok(metrics.byClass?.workspace?.scheduled >= 2); + assert.ok(metrics.byClass?.workspace?.started >= 2); + assert.ok(metrics.byClass?.workspace?.completed >= 2); + assert.ok(logs.some((line) => line.includes('preflight:queued provider='))); + assert.ok(logs.some((line) => line.includes('preflight:dequeued provider='))); +}; + +const runRerunStateRefreshCase = async () => { + const ctx = buildCtx(); + let invocationCount = 0; + const provider = { + id: 'rerun-snapshot-provider', + preflightId: 'rerun-snapshot-provider.health-check', + getConfigHash() { + return 'rerun-snapshot-provider-hash'; + }, + async preflight() { + invocationCount += 1; + if (invocationCount === 1) { + return { state: 'ready', reasonCode: null, message: '' }; + } + throw new Error('forced rerun failure'); + } + }; + const inputs = buildInputs('rerun-refresh'); + await awaitToolingProviderPreflight(ctx, { provider, inputs }); + const firstSnapshot = readToolingProviderPreflightState(ctx, { provider, inputs }); + assert.equal(firstSnapshot?.state, 'ready'); + await assert.rejects(() => awaitToolingProviderPreflight(ctx, { provider, inputs }), /forced rerun failure/); + const secondSnapshot = readToolingProviderPreflightState(ctx, { provider, inputs }); + assert.equal(secondSnapshot?.state, 'failed'); + assert.equal(secondSnapshot?.diagnostic?.reasonCode, 'preflight_failed'); +}; + +const runTeardownCases = async () => { + const timeoutLogs = []; + const timeoutCtx = buildCtx({ logger: (line) => timeoutLogs.push(String(line || '')) }); + const timeoutProvider = { + id: 'teardown-fixture', + preflightId: 'teardown-fixture.preflight', + getConfigHash() { + return 'teardown-hash'; + }, + async preflight() { + await wait(120); + return { state: 'ready' }; + } + }; + kickoffToolingProviderPreflights(timeoutCtx, [{ provider: timeoutProvider, ...buildInputs('teardown-timeout') }]); + const timedOut = await teardownToolingProviderPreflights(timeoutCtx, { timeoutMs: 10 }); + assert.equal(timedOut.timedOut, true); + assert.equal(timedOut.total, 1); + assert.ok(timeoutLogs.some((line) => line.includes('preflight:teardown_timeout'))); + assert.ok(timeoutLogs.some((line) => line.includes('teardown-fixture/teardown-fixture.preflight'))); + await wait(170); + const settled = await teardownToolingProviderPreflights(timeoutCtx, { timeoutMs: 10 }); + assert.equal(settled.timedOut, false); + assert.equal(settled.total, 0); + + const abortLogs = []; + const abortCtx = buildCtx({ logger: (line) => abortLogs.push(String(line || '')) }); + const abortProvider = { + id: 'teardown-abort-fixture', + preflightId: 'teardown-abort-fixture.preflight', + getConfigHash() { + return 'teardown-abort-hash'; + }, + async preflight(_ctx, inputs = {}) { + const signal = inputs?.abortSignal; + return await new Promise((resolve) => { + if (!signal || typeof signal.addEventListener !== 'function') { + setTimeout(() => resolve({ state: 'ready' }), 5000); + return; + } + if (signal.aborted) { + resolve({ state: 'blocked', reasonCode: 'preflight_timeout', timedOut: true }); + return; + } + signal.addEventListener('abort', () => { + resolve({ state: 'blocked', reasonCode: 'preflight_timeout', timedOut: true }); + }, { once: true }); + }); + } + }; + kickoffToolingProviderPreflights(abortCtx, [{ provider: abortProvider, ...buildInputs('teardown-abort') }]); + const aborted = await teardownToolingProviderPreflights(abortCtx, { timeoutMs: 10 }); + assert.equal(aborted.timedOut, true); + assert.equal(aborted.total, 1); + assert.equal(aborted.aborted >= 1, true); + assert.ok(abortLogs.some((line) => line.includes('preflight:teardown_abort'))); + const drained = await teardownToolingProviderPreflights(abortCtx, { timeoutMs: 10 }); + assert.equal(drained.total, 0); +}; + +const runKickoffSkipEmptyCase = () => { + const ctx = buildCtx(); + let runCount = 0; + const provider = { + id: 'kickoff-empty-fixture', + preflightId: 'kickoff-empty-fixture.preflight', + getConfigHash() { + return 'kickoff-empty-hash'; + }, + async preflight() { + runCount += 1; + return { state: 'ready' }; + } + }; + const waveToken = kickoffToolingProviderPreflights(ctx, [ + { provider, documents: [], targets: [] }, + { provider, documents: [{ virtualPath: 'src/file.fixture', languageId: 'fixture' }], targets: [] } + ]); + assert.equal(typeof waveToken, 'string'); + assert.equal(runCount, 0); + assert.equal(listToolingProviderPreflightStates(ctx).length, 0); +}; + +runGuardCases(); +runMetadataCoverageCase(); +runCommandProfileHelperCase(); +runContractCase(); +await runSingleFlightCase(); +await runEnforcedTimeoutCase(); +await runTimeoutTieringCase(); +await runTimeoutEventCase(); +await runConcurrencySchedulerCase(); +await runRerunStateRefreshCase(); +await runTeardownCases(); +runKickoffSkipEmptyCase(); + +console.log('LSP preflight contract matrix test passed'); diff --git a/tests/tooling/lsp/preflight-manager-modularization.test.js b/tests/tooling/lsp/preflight-manager-modularization.test.js new file mode 100644 index 000000000..181965b32 --- /dev/null +++ b/tests/tooling/lsp/preflight-manager-modularization.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const managerPath = path.join(root, 'src', 'index', 'tooling', 'preflight-manager.js'); +const configPath = path.join(root, 'src', 'index', 'tooling', 'preflight', 'manager-config.js'); +const statePath = path.join(root, 'src', 'index', 'tooling', 'preflight', 'manager-state.js'); +const schedulerPath = path.join(root, 'src', 'index', 'tooling', 'preflight', 'manager-scheduler.js'); +const teardownPath = path.join(root, 'src', 'index', 'tooling', 'preflight', 'manager-teardown.js'); + +for (const target of [managerPath, configPath, statePath, schedulerPath, teardownPath]) { + assert.equal(fs.existsSync(target), true, `missing expected preflight modularization file: ${target}`); +} + +const source = fs.readFileSync(managerPath, 'utf8'); + +for (const marker of [ + "./preflight/manager-config.js", + "./preflight/manager-state.js", + "./preflight/manager-scheduler.js", + "./preflight/manager-teardown.js", + 'resolvePreflightTimeoutMs(', + 'resolvePreflightKey(', + 'scheduleTask(', + 'forceCleanupTrackedPreflightProcesses(' +]) { + assert.equal( + source.includes(marker), + true, + `expected preflight manager to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const resolveSchedulerConfig = (ctx) => {', + 'const createManagedAbortBridge = (upstreamSignal) => {', + 'const runScheduledTask = ({ state, ctx, task, fromQueue = false }) => {', + 'const waitForPromisesWithTimeout = async (promises, timeoutMs) => {' +]) { + assert.equal( + source.includes(legacyInlineMarker), + false, + `expected preflight manager to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('preflight manager modularization test passed'); diff --git a/tests/tooling/lsp/protocol-fail-open-matrix.test.js b/tests/tooling/lsp/protocol-fail-open-matrix.test.js new file mode 100644 index 000000000..b20821504 --- /dev/null +++ b/tests/tooling/lsp/protocol-fail-open-matrix.test.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; + +const cases = [ + { + mode: 'malformed-hover', + expectedCheck: null, + unexpectedChecks: ['tooling_initialize_failed', 'tooling_document_symbol_failed'], + expectFailedMetric: true, + timeoutMs: 1500, + hoverRequireMissingReturn: true, + parseSignature: () => null + }, + { + mode: 'malformed-document-symbol', + expectedCheck: 'tooling_document_symbol_failed', + unexpectedChecks: ['tooling_initialize_failed'], + expectFailedMetric: true, + timeoutMs: 1500, + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }, + { + mode: 'malformed-initialize', + expectedCheck: 'tooling_initialize_failed', + unexpectedChecks: [], + expectFailedMetric: false, + timeoutMs: 1200, + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }, + { + mode: 'disconnect-on-document-symbol', + expectedCheck: 'tooling_document_symbol_failed', + unexpectedChecks: ['tooling_initialize_failed'], + expectFailedMetric: true, + timeoutMs: 1500, + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }, + { + mode: 'capability-drift-hover', + expectedCheck: null, + unexpectedChecks: ['tooling_initialize_failed'], + expectFailedMetric: true, + timeoutMs: 1500, + hoverRequireMissingReturn: true, + parseSignature: () => null + }, + { + mode: 'inconsistent-document-symbol', + expectedCheck: null, + unexpectedChecks: ['tooling_initialize_failed'], + expectFailedMetric: false, + timeoutMs: 1500, + parseSignature: () => null + }, + { + mode: 'disconnect-on-hover', + expectedCheck: null, + unexpectedChecks: ['tooling_initialize_failed', 'tooling_document_symbol_failed'], + expectFailedMetric: true, + timeoutMs: 1500, + hoverRequireMissingReturn: true, + parseSignature: () => null + }, + { + mode: 'stall-initialize', + expectedCheck: 'tooling_initialize_failed', + unexpectedChecks: [], + expectFailedMetric: false, + expectTimedOutMetric: true, + timeoutMs: 250, + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }, + { + mode: 'delayed-partial-document-symbol', + expectedCheck: null, + unexpectedChecks: ['tooling_document_symbol_failed', 'tooling_initialize_failed'], + expectChunk: true, + timeoutMs: 2000, + parseSignature: (detail) => ({ + signature: String(detail || 'add'), + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }, + { + mode: 'fragmented-responses', + expectedCheck: null, + unexpectedChecks: ['tooling_initialize_failed'], + expectChunk: true, + timeoutMs: 2000, + args: ['--fragment-size', '3'], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + } +]; + +for (const [index, testCase] of cases.entries()) { + const tempRoot = resolveTestCachePath(root, `lsp-protocol-fail-open-${index}-${process.pid}-${Date.now()}`); + await fs.mkdir(tempRoot, { recursive: true }); + + const virtualPath = `.poc-vfs/src/sample.cpp#seg:${testCase.mode}.cpp`; + const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: `hash-${testCase.mode}` + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:src/sample.cpp:${testCase.mode}`, + chunkId: `chunk_${testCase.mode.replace(/[^a-z0-9]+/gi, '_')}`, + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', testCase.mode, ...(Array.isArray(testCase.args) ? testCase.args : [])], + parseSignature: testCase.parseSignature, + retries: 0, + timeoutMs: testCase.timeoutMs, + hoverRequireMissingReturn: testCase.hoverRequireMissingReturn || false + }); + + if (testCase.expectChunk) { + assert.equal(Object.keys(result.byChunkUid || {}).length >= 1, true, `expected ${testCase.mode} to enrich at least one chunk`); + } else { + assert.equal(Object.keys(result.byChunkUid || {}).length, 0, `expected ${testCase.mode} to fail open`); + } + if (testCase.expectedCheck) { + assert.equal(result.checks.some((check) => check?.name === testCase.expectedCheck), true); + } + for (const unexpectedCheck of testCase.unexpectedChecks) { + assert.equal(result.checks.some((check) => check?.name === unexpectedCheck), false); + } + if (testCase.expectFailedMetric) { + assert.equal(Number(result.runtime?.requests?.failed || 0) >= 1, true); + } + if (testCase.expectTimedOutMetric) { + assert.equal(Number(result.runtime?.requests?.timedOut || 0) >= 1, true); + } +} + +console.log('LSP protocol fail-open matrix test passed'); diff --git a/tests/tooling/lsp/provenance-symbolref.test.js b/tests/tooling/lsp/provenance-symbolref.test.js new file mode 100644 index 000000000..f8ff155dc --- /dev/null +++ b/tests/tooling/lsp/provenance-symbolref.test.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveSymbolJoinKey } from '../../../src/shared/identity.js'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseCppTwoIntParamSignature } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-provenance-symbolref-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'const sentinel = 1;\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:provenance-symbolref.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:provenance-symbolref'; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_provenance_symbolref', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'signature-help'], + parseSignature: (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: ['add'], + bareReturnType: 'unknown' + }) +}); + +const entry = result.byChunkUid?.[chunkUid] || null; +assert.ok(entry, 'expected enriched LSP entry'); +assert.equal(entry.payload?.returnType, 'int', 'expected enriched return type'); +assert.ok(entry.symbolRef, 'expected LSP symbolRef envelope'); +assert.equal(resolveSymbolJoinKey(entry.symbolRef)?.type, 'symbolId', 'expected symbolRef to resolve via symbolId'); +assert.equal(entry.symbolRef?.evidence?.scheme, 'lsp', 'expected LSP evidence scheme'); +assert.equal(entry.symbolRef?.evidence?.confidence, 'high', 'expected high symbolRef confidence for completed stage result'); + +const provenance = entry.provenance || null; +assert.ok(provenance && typeof provenance === 'object', 'expected provenance entry'); +assert.equal(provenance.provider, process.execPath, 'expected provenance provider to be runtime command'); +assert.equal(provenance.source, 'lsp', 'expected provenance source tag'); +assert.equal(provenance.stages?.documentSymbol, true, 'expected documentSymbol provenance flag'); +assert.equal(provenance.stages?.hover?.requested, true, 'expected hover stage request provenance'); +assert.equal(provenance.stages?.signatureHelp?.requested, true, 'expected signatureHelp stage request provenance'); +assert.equal(provenance.stages?.signatureHelp?.succeeded, true, 'expected signatureHelp stage success provenance'); +assert.equal(provenance.evidence?.tier, 'full', 'expected full evidence tier'); +assert.equal(provenance.confidence?.tier, 'high', 'expected high calibrated confidence tier'); +assert.equal(provenance.quality?.incomplete, false, 'expected complete quality result'); +assert.equal(Number(provenance.quality?.paramCoverage || 0), 1, 'expected full parameter coverage'); + +console.log('LSP provenance symbolRef test passed'); diff --git a/tests/tooling/lsp/provider-fidelity-contract-shape.test.js b/tests/tooling/lsp/provider-fidelity-contract-shape.test.js new file mode 100644 index 000000000..544eeb8e4 --- /dev/null +++ b/tests/tooling/lsp/provider-fidelity-contract-shape.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildProviderFidelityContract, + PROVIDER_FIDELITY_STATE +} from '../../../src/index/tooling/provider-contract.js'; + +const contract = buildProviderFidelityContract({ + providerId: 'sourcekit', + state: PROVIDER_FIDELITY_STATE.DEGRADED, + reasonCode: 'sourcekit_preflight_lock_unavailable', + preflightDetails: { + state: 'blocked_dependency', + workspaceKind: 'package_managed_workspace', + dependencyState: 'required' + }, + workspaceKey: 'sourcekit:.', + runtime: { + capabilityGate: { + requested: { + hover: true, + semanticTokens: true + }, + effective: { + hover: true, + semanticTokens: false + } + }, + requests: { + byMethod: { + 'textDocument/documentSymbol': { requests: 3, timedOut: 1, failed: 0 }, + 'textDocument/hover': { requests: 5, timedOut: 0, failed: 0 } + } + } + }, + blockedWorkspaceRoots: ['svc-bad'], + byChunkUid: { + 'ck64:v1:test:src/one.swift:provider-fidelity-shape': { types: [] } + }, + captureDiagnostics: false +}); + +assert.equal(contract.contractVersion, 2); +assert.equal(contract.providerId, 'sourcekit'); +assert.equal(contract.state, 'degraded'); +assert.equal(contract.preflight.workspaceKind, 'package_managed_workspace'); +assert.equal(contract.qualityDelta.partialSuccess, true); +assert.equal(contract.blockedPartitions.count, 1); +assert.equal(contract.workspaceCoverage.totalPartitions, 0); +assert.equal(contract.workspaceCoverage.blockedPartitionCount, 1); +assert.equal(contract.requestClasses.documentSymbol.timedOut, 1); +assert.equal(contract.requestClasses.hover.requests, 5); +assert.equal(contract.skipped.includes('semanticTokens'), true); +assert.equal(contract.requestSuppression.active, true); +assert.equal(contract.requestSuppression.suppressedRequestClasses.includes('semanticTokens'), true); +assert.equal(contract.requestSuppression.degradedRequestClasses.includes('documentSymbol'), true); +assert.equal(contract.semanticCoverage.state, 'partial'); +assert.equal(contract.semanticCoverage.partialSuccess, true); +assert.equal(contract.semanticCoverage.suppressedRequestClasses.includes('semanticTokens'), true); +assert.equal(contract.qualityDelta.degradedRequestClasses.includes('documentSymbol'), true); +assert.equal(contract.contributes.typeEnrichment, true); + +console.log('LSP provider fidelity contract shape test passed'); diff --git a/tests/tooling/lsp/provider-fidelity-coverage-contract.test.js b/tests/tooling/lsp/provider-fidelity-coverage-contract.test.js new file mode 100644 index 000000000..00daef803 --- /dev/null +++ b/tests/tooling/lsp/provider-fidelity-coverage-contract.test.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const lspTestsDir = path.join(root, 'tests', 'tooling', 'lsp'); +const fixtureBinDir = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); +const fileNames = await fs.readdir(lspTestsDir); +const fixtureBins = new Set(await fs.readdir(fixtureBinDir)); +const fixtureCommandByProvider = new Map([ + ['pyright', 'pyright-langserver'], + ['sourcekit', 'sourcekit-lsp'] +]); + +const coverageByProvider = new Map([ + ['clangd', [/^clangd-/u, /^protocol-/u]], + ['pyright', [/^pyright-/u]], + ['sourcekit', [/^sourcekit-/u]], + ['gopls', [/^configured-provider-go/u, /^configured-provider-gopls/u]], + ['rust-analyzer', [/^configured-provider-rust/u]], + ['yaml-language-server', [/^configured-provider-yaml/u]], + ['lua-language-server', [/^configured-provider-lua/u]], + ['zls', [/^configured-provider-zls/u]], + ['jdtls', [/^jdtls-/u]], + ['csharp-ls', [/^csharp-/u]], + ['elixir-ls', [/^elixir-/u]], + ['haskell-language-server', [/^haskell-/u]], + ['phpactor', [/^phpactor-/u]], + ['solargraph', [/^solargraph-/u]], + ['dart', [/^dart-/u]] +]); + +for (const [providerId, patterns] of coverageByProvider.entries()) { + const matched = fileNames.some((fileName) => patterns.some((pattern) => pattern.test(fileName))); + assert.equal(matched, true, `expected targeted LSP test coverage for ${providerId}`); + const fixtureCommand = fixtureCommandByProvider.get(providerId) || providerId; + assert.equal( + fixtureBins.has(fixtureCommand) || fixtureBins.has(`${fixtureCommand}.cmd`), + true, + `expected fixture binary coverage for ${providerId}` + ); +} + +console.log('LSP provider fidelity coverage contract test passed'); diff --git a/tests/tooling/lsp/provider-quarantine-recovery.test.js b/tests/tooling/lsp/provider-quarantine-recovery.test.js new file mode 100644 index 000000000..d0a932efe --- /dev/null +++ b/tests/tooling/lsp/provider-quarantine-recovery.test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-provider-quarantine-recovery-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(tempRoot, 'stub-launcher.js'); +const modePath = path.join(tempRoot, 'mode.txt'); +await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'cpp';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' +); + +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:quarantine-recovery.cpp'; +const runCollect = async () => collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + providerId: 'lsp-provider-quarantine-recovery', + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:quarantine-recovery', + chunkId: 'chunk_quarantine_recovery', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }), + retries: 0, + timeoutMs: 1200, + sessionIdleTimeoutMs: 60_000, + sessionMaxLifetimeMs: 120_000 +}); + +try { + __testLspSessionPool.setQuarantineDurations({ shortMs: 250, extendedMs: 800 }); + + await fs.writeFile(modePath, 'malformed-initialize', 'utf8'); + const failed = await runCollect(); + assert.equal(Object.keys(failed.byChunkUid).length, 0, 'expected malformed initialize to fail open'); + assert.equal( + failed.checks.some((check) => check?.name === 'tooling_initialize_failed'), + true, + 'expected initialize failure warning' + ); + assert.equal( + failed.runtime?.lifecycle?.quarantine?.level, + 'short', + 'expected handshake failure to arm short quarantine' + ); + + const quarantined = await runCollect(); + assert.equal(Object.keys(quarantined.byChunkUid).length, 0, 'expected active quarantine to keep fail-open result empty'); + assert.equal( + quarantined.checks.some((check) => check?.name === 'tooling_provider_quarantined'), + true, + 'expected explicit quarantine warning during cooldown' + ); + + await fs.writeFile(modePath, 'cpp', 'utf8'); + await sleep(300); + const recovered = await runCollect(); + assert.equal( + Object.keys(recovered.byChunkUid).length >= 1, + true, + 'expected provider to recover after quarantine cooldown' + ); + assert.equal( + recovered.checks.some((check) => check?.name === 'tooling_provider_quarantined'), + false, + 'expected successful recovery run to avoid quarantine warning' + ); + + console.log('LSP provider quarantine recovery test passed'); +} finally { + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/provider-runtime-contract-matrix.test.js b/tests/tooling/lsp/provider-runtime-contract-matrix.test.js new file mode 100644 index 000000000..f4694d950 --- /dev/null +++ b/tests/tooling/lsp/provider-runtime-contract-matrix.test.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +registerDefaultToolingProviders(); + +const makeTempRoot = async (name) => { + const tempRoot = resolveTestCachePath(root, `${name}-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + return tempRoot; +}; + +const runOutputShapeCase = async ({ + name, + providerId, + fileName, + languageId, + effectiveExt, + text, + extraFiles = [], + extraAssert = () => {} +}) => { + const tempRoot = await makeTempRoot(name); + for (const [relativePath, contents] of extraFiles) { + const abs = path.join(tempRoot, relativePath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, contents, 'utf8'); + } + await fs.writeFile(path.join(tempRoot, 'src', fileName), text, 'utf8'); + + const provider = getToolingProvider(providerId); + assert.ok(provider, `expected ${providerId} provider`); + const ctx = { + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: {}, + logger: () => {}, + strict: true + }; + const virtualPath = `src/${fileName}`; + const document = { + virtualPath, + effectiveExt, + languageId, + text, + docHash: `doc-${providerId}`, + containerPath: virtualPath + }; + const target = { + virtualPath, + languageId, + chunkRef: { + chunkUid: `ck:test:${providerId}:1`, + file: virtualPath, + start: 0, + end: 10 + }, + symbolHint: { name: 'alpha', kind: 'function' } + }; + const output = await provider.run(ctx, { documents: [document], targets: [target, target] }); + assert.ok(output && typeof output === 'object'); + assert.ok(output.byChunkUid && typeof output.byChunkUid === 'object'); + assert.ok(!('byFile' in output)); + const duplicate = (output.diagnostics?.checks || []).find((check) => check.name === 'duplicate_chunk_uid'); + assert.ok(duplicate, `expected duplicate chunkUid warning for ${providerId}`); + assert.ok(Array.isArray(duplicate.samples) && duplicate.samples[0]?.startsWith('ck:')); + extraAssert(output); +}; + +const runCommandOverrideCase = async ({ + name, + providerId, + configKey, + commandKey = 'cmd', + fixtureBinary, + fileName, + languageId, + effectiveExt, + text, + args = [], + extraConfig = {}, + extraAssert = () => {} +}) => { + const tempRoot = await makeTempRoot(name); + const provider = getToolingProvider(providerId); + assert.ok(provider, `expected ${providerId} provider`); + + const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? `${fixtureBinary}.cmd` : fixtureBinary + ); + await fs.access(fixtureCmd); + + const ctx = { + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + [configKey]: { + [commandKey]: fixtureCmd, + args, + ...extraConfig + } + }, + logger: () => {}, + strict: true + }; + + const virtualPath = `src/${fileName}`; + const document = { + virtualPath, + effectiveExt, + languageId, + text, + docHash: `doc-${providerId}-override`, + containerPath: virtualPath + }; + const chunkUid = `ck:test:${providerId}-override:1`; + const target = { + virtualPath, + languageId, + chunkRef: { + chunkUid, + chunkId: `chunk_${providerId.replace(/[^a-z0-9]+/gi, '_')}_override`, + file: virtualPath, + start: 0, + end: text.length + }, + virtualRange: { start: 0, end: text.length }, + symbolHint: { name: 'alpha', kind: 'function' } + }; + const output = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(output?.byChunkUid?.[chunkUid], `expected payload output for ${providerId}`); + const runtimeCommand = output?.diagnostics?.runtime?.command || ''; + assert.equal(runtimeCommand.length > 0, true, 'expected runtime command'); + extraAssert({ output, fixtureCmd, runtimeCommand }); +}; + +await runOutputShapeCase({ + name: 'clangd-provider-output-shape-matrix', + providerId: 'clangd', + fileName: 'one.c', + languageId: 'c', + effectiveExt: '.c', + text: 'int alpha(void) { return 1; }\n' +}); + +await runOutputShapeCase({ + name: 'pyright-provider-output-shape-matrix', + providerId: 'pyright', + fileName: 'one.py', + languageId: 'python', + effectiveExt: '.py', + text: 'def alpha():\n return 1\n', + extraFiles: [['pyproject.toml', '[project]\nname = "pyright-shape"\n']], + extraAssert(output) { + assert.equal(typeof output.diagnostics?.planning?.workspaceRootRel, 'string'); + } +}); + +await runOutputShapeCase({ + name: 'sourcekit-provider-output-shape-matrix', + providerId: 'sourcekit', + fileName: 'one.swift', + languageId: 'swift', + effectiveExt: '.swift', + text: 'func alpha() -> Int { return 1 }\n' +}); + +await runOutputShapeCase({ + name: 'typescript-provider-output-shape-matrix', + providerId: 'typescript', + fileName: 'one.ts', + languageId: 'typescript', + effectiveExt: '.ts', + text: 'export function alpha(): number { return 1; }\n' +}); + +await runCommandOverrideCase({ + name: 'clangd-provider-command-override-matrix', + providerId: 'clangd', + configKey: 'clangd', + fixtureBinary: 'clangd', + fileName: 'one.c', + languageId: 'c', + effectiveExt: '.c', + text: 'int alpha(void) { return 1; }\n', + args: ['--background-index=false', '--log=error'], + extraAssert({ fixtureCmd, runtimeCommand }) { + assert.equal(path.resolve(runtimeCommand), path.resolve(fixtureCmd)); + } +}); + +await runCommandOverrideCase({ + name: 'pyright-provider-command-override-matrix', + providerId: 'pyright', + configKey: 'pyright', + commandKey: 'command', + fixtureBinary: 'pyright-langserver', + fileName: 'one.py', + languageId: 'python', + effectiveExt: '.py', + text: 'def greet(name: str) -> str:\n return "hi"\n', + args: ['--stdio'], + extraAssert({ output, runtimeCommand }) { + const payload = output?.byChunkUid?.['ck:test:pyright-override:1']?.payload || null; + assert.ok(payload); + assert.equal(payload.returnType, 'str'); + assert.equal(payload.paramTypes?.name?.[0]?.type, 'str'); + assert.equal(path.basename(runtimeCommand).toLowerCase().startsWith('pyright-langserver'), true); + } +}); + +await runCommandOverrideCase({ + name: 'sourcekit-provider-command-override-matrix', + providerId: 'sourcekit', + configKey: 'sourcekit', + fixtureBinary: 'sourcekit-lsp', + fileName: 'one.swift', + languageId: 'swift', + effectiveExt: '.swift', + text: 'func alpha() -> Int { return 1 }\n', + extraConfig: { hostConcurrencyGate: true }, + extraAssert({ output, fixtureCmd, runtimeCommand }) { + assert.equal(path.resolve(runtimeCommand), path.resolve(fixtureCmd)); + assert.equal(output?.diagnostics?.runtime?.pooling?.enabled, false); + } +}); + +console.log('LSP provider runtime contract matrix test passed'); diff --git a/tests/tooling/lsp/provider-runtime-modularization.test.js b/tests/tooling/lsp/provider-runtime-modularization.test.js new file mode 100644 index 000000000..e3ac98e0e --- /dev/null +++ b/tests/tooling/lsp/provider-runtime-modularization.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const providerPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp.js'); +const scopePlanPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'scope-plan.js'); +const requestBudgetPath = path.join(root, 'src', 'integrations', 'tooling', 'providers', 'lsp', 'request-budget.js'); + +for (const target of [providerPath, scopePlanPath, requestBudgetPath]) { + assert.equal(fs.existsSync(target), true, `missing expected LSP runtime modularization file: ${target}`); +} + +const providerSource = fs.readFileSync(providerPath, 'utf8'); + +for (const marker of [ + "./lsp/scope-plan.js", + "./lsp/request-budget.js", + 'createBudgetController(', + 'createEmptyRequestCacheMetrics(', + 'summarizeRequestCacheMetrics(', + '__resolveAdaptiveLspScopePlanForTests(', + '__resolveAdaptiveLspRequestBudgetPlanForTests(' +]) { + assert.equal( + providerSource.includes(marker), + true, + `expected LSP provider runtime to delegate via ${marker}` + ); +} + +for (const legacyInlineMarker of [ + 'const resolveAdaptiveLspScopePlanForTests = ({', + 'const resolveProviderConfidenceBias = ({', + 'const createBudgetController = (maxRequests) => {', + 'const summarizeRequestCacheMetrics = (metrics) => ({' +]) { + assert.equal( + providerSource.includes(legacyInlineMarker), + false, + `expected LSP provider runtime to stop inlining ${legacyInlineMarker}` + ); +} + +console.log('LSP provider runtime modularization test passed'); diff --git a/tests/tooling/lsp/provider-timeout-quarantine.test.js b/tests/tooling/lsp/provider-timeout-quarantine.test.js new file mode 100644 index 000000000..8a17e9f4b --- /dev/null +++ b/tests/tooling/lsp/provider-timeout-quarantine.test.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-provider-timeout-quarantine-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:timeout-quarantine.cpp'; + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (detail !== 'add') return null; + return { + signature: detail, + returnType: 'unknown', + paramTypes: {}, + paramNames: ['a', 'b'] + }; +}; + +const runCollect = async () => collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + providerId: 'lsp-provider-timeout-quarantine', + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:timeout-quarantine', + chunkId: 'chunk_timeout_quarantine', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-signature-help'], + parseSignature, + retries: 0, + timeoutMs: 600, + signatureHelpTimeoutMs: 180, + sessionIdleTimeoutMs: 60_000, + sessionMaxLifetimeMs: 120_000 +}); + +try { + __testLspSessionPool.setQuarantineDurations({ shortMs: 120, extendedMs: 600 }); + + let escalated = null; + for (let attempt = 0; attempt < 4; attempt += 1) { + const current = await runCollect(); + const timedOut = current.checks.some((check) => check?.name === 'tooling_signature_help_timeout'); + const quarantinedNow = current.checks.some((check) => check?.name === 'tooling_provider_quarantined'); + assert.equal( + timedOut || quarantinedNow, + true, + `expected timeout degradation or quarantine on attempt ${attempt + 1}` + ); + if (current?.runtime?.lifecycle?.quarantine?.level === 'extended') { + escalated = current; + break; + } + await sleep(160); + } + + assert.ok(escalated, 'expected timeout storm to escalate into extended quarantine'); + + const quarantined = await runCollect(); + assert.equal( + quarantined.checks.some((check) => check?.name === 'tooling_provider_quarantined'), + true, + 'expected active extended quarantine to fail open with explicit warning' + ); + assert.equal( + quarantined.runtime?.lifecycle?.quarantine?.level, + 'extended', + 'expected provider quarantine summary to retain extended level' + ); + + console.log('LSP provider timeout quarantine test passed'); +} finally { + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/pyright-preflight-workspace-config-invalid.test.js b/tests/tooling/lsp/pyright-preflight-workspace-config-invalid.test.js new file mode 100644 index 000000000..868d50b9c --- /dev/null +++ b/tests/tooling/lsp/pyright-preflight-workspace-config-invalid.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runSingleSymbolDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const docText = 'def alpha() -> int:\n return 1\n'; +await runSingleSymbolDegradedPreflightCase({ + root, + name: 'pyright-preflight-workspace-config-invalid', + directories: ['src'], + files: [ + { path: 'src/one.py', content: docText }, + { path: 'pyrightconfig.json', content: '{ "venvPath": ' } + ], + providerId: 'pyright', + providerConfigKey: 'pyright', + fixtureCommand: 'pyright-langserver', + input: { + scenarioName: 'pyright-workspace-config-invalid', + virtualPath: 'src/one.py', + text: docText, + languageId: 'python', + effectiveExt: '.py', + symbolName: 'alpha' + }, + expectedReasonCode: 'pyright_workspace_config_invalid', + expectedCheckName: 'pyright_workspace_config_invalid', + messages: { + enrichment: 'expected pyright output even with degraded workspace-config preflight', + reasonCode: 'expected pyright workspace-config invalid reason code', + check: 'expected pyright workspace-config invalid warning check' + } +}); + +console.log('pyright preflight workspace config invalid test passed'); diff --git a/tests/tooling/lsp/pyright-preflight-workspace-mono-root.test.js b/tests/tooling/lsp/pyright-preflight-workspace-mono-root.test.js new file mode 100644 index 000000000..94927ae30 --- /dev/null +++ b/tests/tooling/lsp/pyright-preflight-workspace-mono-root.test.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runSingleSymbolDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const docText = 'def alpha() -> int:\n return 1\n'; +await runSingleSymbolDegradedPreflightCase({ + root, + name: `pyright-preflight-workspace-mono-root-${process.pid}-${Date.now()}`, + directories: ['pkg-a', 'pkg-b', 'src'], + files: [ + { path: 'pkg-a/pyproject.toml', content: '[project]\nname = "a"\n' }, + { path: 'pkg-b/setup.py', content: 'from setuptools import setup\nsetup()\n' }, + { path: 'src/one.py', content: docText } + ], + providerId: 'pyright', + providerConfigKey: 'pyright', + fixtureCommand: 'pyright-langserver', + input: { + scenarioName: 'pyright-workspace-mono-root', + virtualPath: 'src/one.py', + text: docText, + languageId: 'python', + effectiveExt: '.py', + symbolName: 'alpha' + }, + expectedReasonCode: 'pyright_workspace_mono_root', + expectedCheckName: 'pyright_workspace_mono_root', + messages: { + enrichment: 'expected pyright output even with mono-root warning', + reasonCode: 'expected pyright mono-root reason code', + check: 'expected pyright mono-root warning check' + } +}); + +console.log('pyright preflight workspace mono-root test passed'); diff --git a/tests/tooling/lsp/pyright-preflight-workspace-scan-outlier.test.js b/tests/tooling/lsp/pyright-preflight-workspace-scan-outlier.test.js new file mode 100644 index 000000000..0edc6972d --- /dev/null +++ b/tests/tooling/lsp/pyright-preflight-workspace-scan-outlier.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runSingleSymbolDegradedPreflightCase } from './helpers/degraded-preflight-case.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const docText = 'def alpha() -> int:\n return 1\n'; +await runSingleSymbolDegradedPreflightCase({ + root, + name: `pyright-preflight-workspace-scan-outlier-${process.pid}-${Date.now()}`, + directories: ['src', 'pkg-a', 'pkg-b'], + files: [ + { path: 'src/one.py', content: docText }, + { path: 'pkg-a/a.py', content: 'A = 1\n' }, + { path: 'pkg-b/b.py', content: 'B = 2\n' } + ], + providerId: 'pyright', + providerConfigKey: 'pyright', + fixtureCommand: 'pyright-langserver', + providerConfig: { + workspaceScanOutlierEntryThreshold: 1, + workspaceScanOutlierDurationMs: 1_000_000 + }, + input: { + scenarioName: 'pyright-workspace-scan-outlier', + virtualPath: 'src/one.py', + text: docText, + languageId: 'python', + effectiveExt: '.py', + symbolName: 'alpha' + }, + expectedReasonCode: 'pyright_workspace_scan_outlier', + expectedCheckName: 'pyright_workspace_scan_outlier', + messages: { + enrichment: 'expected pyright output even with scan-outlier warning', + reasonCode: 'expected pyright scan-outlier reason code', + check: 'expected pyright scan-outlier warning check' + } +}); + +console.log('pyright preflight workspace scan outlier test passed'); diff --git a/tests/tooling/lsp/pyright-provider-hover-timeout-state.test.js b/tests/tooling/lsp/pyright-provider-hover-timeout-state.test.js new file mode 100644 index 000000000..5dad1cac0 --- /dev/null +++ b/tests/tooling/lsp/pyright-provider-hover-timeout-state.test.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { __testPyrightRuntimeHealth } from '../../../src/index/tooling/pyright-runtime-health.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `pyright-hover-timeout-state-${process.pid}-${Date.now()}`); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(tempRoot, 'stub-launcher.js'); +const modePath = path.join(tempRoot, 'mode.txt'); + +const inputs = { + documents: [ + { + virtualPath: 'src/core.py', + text: 'def greet(name):\n return name\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-hover-timeout-core' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck:test:pyright-hover-timeout:core', + chunkId: 'chunk_pyright_hover_timeout_core', + file: 'src/core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 32 } + }, + virtualPath: 'src/core.py', + virtualRange: { start: 0, end: 32 }, + symbolHint: { name: 'greet', kind: 'function' }, + languageId: 'python' + } + ], + kinds: ['types'] +}; + +const toolingConfig = { + enabledTools: ['pyright'], + pyright: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + timeoutMs: 500, + hoverTimeoutMs: 150, + retries: 0, + breakerThreshold: 1 + } +}; + +registerDefaultToolingProviders(); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'pyproject.toml'), '[project]\nname = "pyright-hover-timeout"\n', 'utf8'); + await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'pyright';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' + ); + await fs.writeFile(modePath, 'pyright-hover-timeout', 'utf8'); + __testPyrightRuntimeHealth.reset(); + + const first = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig, + cache: { + enabled: false + } + }, inputs); + + assert.equal(first.byChunkUid.size, 1, 'expected documentSymbol enrichment to survive hover timeout'); + assert.equal( + first.diagnostics?.pyright?.runtime?.requests?.byMethod?.['textDocument/hover']?.timedOut, + 1, + 'expected hover timeout to be recorded' + ); + assert.equal(first.diagnostics?.pyright?.health?.state, 'degraded_soft', 'expected hover timeout run to enter degraded_soft'); + assert.equal(first.diagnostics?.pyright?.health?.nextState, 'degraded_hard', 'expected hover timeout run to persist degraded_hard next state'); + assert.equal(first.diagnostics?.pyright?.health?.reasonCode, 'hover_timeout', 'expected hover timeout reason code'); + assert.equal(first.diagnostics?.pyright?.fallback?.state, 'degraded_soft', 'expected fallback contract to reflect degraded_soft'); + assert.equal( + Array.isArray(first.diagnostics?.pyright?.checks) + && first.diagnostics.pyright.checks.some((check) => check?.name === 'pyright_timeout_storm_truncated'), + true, + 'expected explicit timeout-storm truncation warning for hover timeouts' + ); + + const second = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig, + cache: { + enabled: false + } + }, inputs); + + assert.equal(second.byChunkUid.size, 0, 'expected quarantined rerun to fail open without enrichment'); + assert.equal(second.diagnostics?.pyright?.health?.state, 'quarantined_for_run', 'expected same fingerprint rerun to be quarantined'); + assert.equal(second.diagnostics?.pyright?.fallback?.state, 'quarantined_for_run', 'expected fallback contract to reflect active quarantine'); + assert.equal( + Array.isArray(second.diagnostics?.pyright?.checks) + && second.diagnostics.pyright.checks.some((check) => check?.name === 'pyright_quarantined_for_run'), + true, + 'expected explicit quarantine warning on immediate rerun' + ); + assert.equal( + second.diagnostics?.pyright?.runtime?.requests?.byMethod?.['textDocument/hover']?.requests ?? 0, + 0, + 'expected quarantined rerun to avoid replaying hover requests' + ); + + console.log('pyright provider hover timeout state test passed'); +} finally { + __testPyrightRuntimeHealth.reset(); + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/pyright-provider-output-shape.test.js b/tests/tooling/lsp/pyright-provider-output-shape.test.js deleted file mode 100644 index 61e5c271b..000000000 --- a/tests/tooling/lsp/pyright-provider-output-shape.test.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'pyright-provider-output-shape'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); - -registerDefaultToolingProviders(); -const provider = getToolingProvider('pyright'); -assert.ok(provider, 'expected pyright provider'); - -const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: () => {}, - strict: true -}; - -const document = { - virtualPath: 'src/one.py', - effectiveExt: '.py', - languageId: 'python', - text: 'def alpha():\n return 1\n', - docHash: 'doc-1', - containerPath: 'src/one.py' -}; - -const target = { - virtualPath: 'src/one.py', - languageId: 'python', - chunkRef: { - chunkUid: 'ck:test:pyright:1', - file: 'src/one.py', - start: 0, - end: 10 - } -}; - -const output = await provider.run(ctx, { documents: [document], targets: [target, target] }); -assert.ok(output && typeof output === 'object', 'expected output object'); -assert.ok(output.byChunkUid && typeof output.byChunkUid === 'object', 'expected byChunkUid output'); -assert.ok(!('byFile' in output), 'unexpected byFile key in output'); -const checks = output.diagnostics?.checks || []; -const duplicate = checks.find((check) => check.name === 'duplicate_chunk_uid'); -assert.ok(duplicate, 'expected duplicate chunkUid warning'); -assert.ok( - Array.isArray(duplicate.samples) && duplicate.samples[0]?.startsWith('ck:'), - 'expected duplicate chunkUid samples to be chunk-style ids' -); - -console.log('pyright provider output shape test passed'); diff --git a/tests/tooling/lsp/pyright-provider-planning.test.js b/tests/tooling/lsp/pyright-provider-planning.test.js new file mode 100644 index 000000000..79467808c --- /dev/null +++ b/tests/tooling/lsp/pyright-provider-planning.test.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `pyright-provider-planning-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'pkg-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'pkg-a', 'tests'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'pkg-b', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'pkg-a', 'pyproject.toml'), '[project]\nname = "a"\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'pkg-b', 'pyproject.toml'), '[project]\nname = "b"\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'pkg-a', 'src', 'core.py'), 'def alpha() -> int:\n return 1\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'pkg-a', 'tests', 'test_core.py'), 'def test_alpha() -> None:\n assert True\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'pkg-b', 'src', 'other.py'), 'def beta() -> int:\n return 2\n', 'utf8'); + +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'pyright-langserver.cmd' : 'pyright-langserver' +); + +registerDefaultToolingProviders(); + +const result = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + enabledTools: ['pyright'], + pyright: { + cmd: fixtureCmd + } + }, + cache: { + enabled: false + } +}, { + documents: [ + { + virtualPath: 'pkg-a/src/core.py', + text: 'def alpha() -> int:\n return 1\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-plan-core' + }, + { + virtualPath: 'pkg-a/tests/test_core.py', + text: 'def test_alpha() -> None:\n assert True\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-plan-test' + }, + { + virtualPath: 'pkg-b/src/other.py', + text: 'def beta() -> int:\n return 2\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-plan-other' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck:test:pyright-plan:core', + chunkId: 'chunk_pyright_plan_core', + file: 'pkg-a/src/core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 32 } + }, + virtualPath: 'pkg-a/src/core.py', + virtualRange: { start: 0, end: 32 }, + symbolHint: { name: 'alpha', kind: 'function' }, + languageId: 'python' + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck:test:pyright-plan:test', + chunkId: 'chunk_pyright_plan_test', + file: 'pkg-a/tests/test_core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 39 } + }, + virtualPath: 'pkg-a/tests/test_core.py', + virtualRange: { start: 0, end: 39 }, + symbolHint: { name: 'test_alpha', kind: 'function' }, + languageId: 'python' + }, + { + chunkRef: { + docId: 2, + chunkUid: 'ck:test:pyright-plan:other', + chunkId: 'chunk_pyright_plan_other', + file: 'pkg-b/src/other.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 31 } + }, + virtualPath: 'pkg-b/src/other.py', + virtualRange: { start: 0, end: 31 }, + symbolHint: { name: 'beta', kind: 'function' }, + languageId: 'python' + } + ], + kinds: ['types'] +}); + +assert.equal(result.byChunkUid.has('ck:test:pyright-plan:core'), true, 'expected dominant workspace core doc to be enriched'); +assert.equal(result.byChunkUid.has('ck:test:pyright-plan:test'), false, 'expected low-value test doc to be skipped'); +assert.equal(result.byChunkUid.has('ck:test:pyright-plan:other'), false, 'expected secondary workspace doc to be skipped'); +const diagnostics = result.diagnostics?.pyright || {}; +assert.equal(diagnostics?.planning?.workspaceRootRel, 'pkg-a', 'expected planning summary to pick pkg-a workspace'); +assert.equal( + diagnostics?.planning?.countsByReason?.workspace_mismatch >= 1, + true, + 'expected planning summary to count workspace mismatch docs' +); +assert.equal( + diagnostics?.planning?.countsByReason?.path_policy_low_value >= 1, + true, + 'expected planning summary to count low-value docs' +); +const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; +assert.equal( + checks.some((check) => check?.name === 'pyright_workspace_partition_mismatch'), + true, + 'expected workspace mismatch warning check' +); + +console.log('pyright provider planning test passed'); diff --git a/tests/tooling/lsp/pyright-provider-recovery-fingerprint.test.js b/tests/tooling/lsp/pyright-provider-recovery-fingerprint.test.js new file mode 100644 index 000000000..de2a2e4bc --- /dev/null +++ b/tests/tooling/lsp/pyright-provider-recovery-fingerprint.test.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { __testPyrightRuntimeHealth } from '../../../src/index/tooling/pyright-runtime-health.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `pyright-recovery-fingerprint-${process.pid}-${Date.now()}`); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(tempRoot, 'stub-launcher.js'); +const modePath = path.join(tempRoot, 'mode.txt'); + +const baseConfig = { + enabledTools: ['pyright'], + pyright: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + timeoutMs: 500, + documentSymbolTimeoutMs: 150, + retries: 0, + breakerThreshold: 1 + } +}; + +const failingInputs = { + documents: [ + { + virtualPath: 'src/core.py', + text: 'def alpha() -> int:\n return 1\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-recovery-core' + }, + { + virtualPath: 'src/helpers.py', + text: 'def beta() -> int:\n return 2\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-recovery-helper' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck:test:pyright-recovery:core', + chunkId: 'chunk_pyright_recovery_core', + file: 'src/core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 33 } + }, + virtualPath: 'src/core.py', + virtualRange: { start: 0, end: 33 }, + symbolHint: { name: 'alpha', kind: 'function' }, + languageId: 'python' + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck:test:pyright-recovery:helper', + chunkId: 'chunk_pyright_recovery_helper', + file: 'src/helpers.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 32 } + }, + virtualPath: 'src/helpers.py', + virtualRange: { start: 0, end: 32 }, + symbolHint: { name: 'beta', kind: 'function' }, + languageId: 'python' + } + ], + kinds: ['types'] +}; + +const recoveredInputs = { + documents: [ + { + virtualPath: 'src/core.py', + text: 'def alpha() -> int:\n return 1\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-recovery-core-v2' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck:test:pyright-recovery:core-v2', + chunkId: 'chunk_pyright_recovery_core_v2', + file: 'src/core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 33 } + }, + virtualPath: 'src/core.py', + virtualRange: { start: 0, end: 33 }, + symbolHint: { name: 'alpha', kind: 'function' }, + languageId: 'python' + } + ], + kinds: ['types'] +}; + +registerDefaultToolingProviders(); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'pyproject.toml'), '[project]\nname = "pyright-recovery"\n', 'utf8'); + await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'pyright';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' + ); + await fs.writeFile(modePath, 'stall-document-symbol', 'utf8'); + __testPyrightRuntimeHealth.reset(); + + const failed = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: baseConfig, + cache: { + enabled: false + } + }, failingInputs); + + assert.equal(failed.diagnostics?.pyright?.health?.nextState, 'degraded_hard', 'expected failing run to seed degraded_hard state'); + + await fs.writeFile(modePath, 'pyright', 'utf8'); + const recovered = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: baseConfig, + cache: { + enabled: false + } + }, recoveredInputs); + + assert.equal(recovered.byChunkUid.has('ck:test:pyright-recovery:core-v2'), true, 'expected fingerprint-changed rerun to recover'); + assert.equal(recovered.diagnostics?.pyright?.health?.state, 'warming', 'expected fingerprint change to re-enter warming state'); + assert.equal(recovered.diagnostics?.pyright?.health?.nextState, 'healthy', 'expected successful warming run to promote back to healthy'); + assert.equal( + Array.isArray(recovered.diagnostics?.pyright?.checks) + && recovered.diagnostics.pyright.checks.some((check) => check?.name === 'pyright_quarantined_for_run'), + false, + 'expected fingerprint-changed recovery run to bypass quarantine short-circuit' + ); + + console.log('pyright provider recovery fingerprint test passed'); +} finally { + __testPyrightRuntimeHealth.reset(); + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/pyright-provider-timeout-state.test.js b/tests/tooling/lsp/pyright-provider-timeout-state.test.js new file mode 100644 index 000000000..68b2fadf3 --- /dev/null +++ b/tests/tooling/lsp/pyright-provider-timeout-state.test.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { __testPyrightRuntimeHealth } from '../../../src/index/tooling/pyright-runtime-health.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `pyright-timeout-state-${process.pid}-${Date.now()}`); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(tempRoot, 'stub-launcher.js'); +const modePath = path.join(tempRoot, 'mode.txt'); + +const inputs = { + documents: [ + { + virtualPath: 'src/core.py', + text: 'def alpha() -> int:\n return 1\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-timeout-core' + }, + { + virtualPath: 'src/helpers.py', + text: 'def beta() -> int:\n return 2\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-timeout-helper' + }, + { + virtualPath: 'tests/test_core.py', + text: 'def test_alpha() -> None:\n assert True\n', + languageId: 'python', + effectiveExt: '.py', + docHash: 'hash-pyright-timeout-test' + } + ], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck:test:pyright-timeout:core', + chunkId: 'chunk_pyright_timeout_core', + file: 'src/core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 33 } + }, + virtualPath: 'src/core.py', + virtualRange: { start: 0, end: 33 }, + symbolHint: { name: 'alpha', kind: 'function' }, + languageId: 'python' + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck:test:pyright-timeout:helper', + chunkId: 'chunk_pyright_timeout_helper', + file: 'src/helpers.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 32 } + }, + virtualPath: 'src/helpers.py', + virtualRange: { start: 0, end: 32 }, + symbolHint: { name: 'beta', kind: 'function' }, + languageId: 'python' + }, + { + chunkRef: { + docId: 2, + chunkUid: 'ck:test:pyright-timeout:test', + chunkId: 'chunk_pyright_timeout_test', + file: 'tests/test_core.py', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 41 } + }, + virtualPath: 'tests/test_core.py', + virtualRange: { start: 0, end: 41 }, + symbolHint: { name: 'test_alpha', kind: 'function' }, + languageId: 'python' + } + ], + kinds: ['types'] +}; + +const toolingConfig = { + enabledTools: ['pyright'], + pyright: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + timeoutMs: 500, + documentSymbolTimeoutMs: 150, + retries: 0, + breakerThreshold: 1 + } +}; + +registerDefaultToolingProviders(); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.mkdir(path.join(tempRoot, 'tests'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'pyproject.toml'), '[project]\nname = "pyright-timeout"\n', 'utf8'); + await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'pyright';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' + ); + await fs.writeFile(modePath, 'stall-document-symbol', 'utf8'); + __testPyrightRuntimeHealth.reset(); + + const first = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig, + cache: { + enabled: false + } + }, inputs); + + assert.equal(first.byChunkUid.size, 0, 'expected timeout-degraded run to produce no type enrichment'); + assert.equal( + first.diagnostics?.pyright?.runtime?.requests?.byMethod?.['textDocument/documentSymbol']?.timedOut, + 1, + 'expected serial Pyright timeout handling to record a single documentSymbol timeout' + ); + assert.equal(first.diagnostics?.pyright?.health?.state, 'degraded_soft', 'expected timeout run to enter degraded_soft'); + assert.equal(first.diagnostics?.pyright?.health?.nextState, 'degraded_hard', 'expected timeout run to persist degraded_hard next state'); + assert.equal(first.diagnostics?.pyright?.fallback?.state, 'degraded_soft', 'expected fallback contract to reflect degraded_soft'); + assert.equal(first.diagnostics?.pyright?.fidelity?.state, 'degraded', 'expected fidelity contract to reflect degraded provider state'); + assert.equal(first.diagnostics?.pyright?.fidelity?.qualityDelta?.partialSuccess, true, 'expected timeout run to report truthful partial success'); + assert.equal(first.diagnostics?.pyright?.fidelity?.requestClasses?.documentSymbol?.timedOut, 1, 'expected fidelity contract to track timed out request class'); + assert.equal( + Array.isArray(first.diagnostics?.pyright?.fidelity?.runtimeIssues) + && first.diagnostics.pyright.fidelity.runtimeIssues.includes('document_symbol_timeout') + && first.diagnostics.pyright.fidelity.runtimeIssues.includes('timeout_storm_truncated'), + true, + 'expected fidelity contract to expose shared and provider-specific runtime issue classes for timeout degradation' + ); + assert.equal( + Array.isArray(first.diagnostics?.pyright?.checks) + && first.diagnostics.pyright.checks.some((check) => check?.name === 'pyright_timeout_storm_truncated'), + true, + 'expected explicit timeout-storm truncation warning' + ); + + const second = await runToolingProviders({ + strict: true, + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig, + cache: { + enabled: false + } + }, inputs); + + assert.equal(second.byChunkUid.size, 0, 'expected quarantined run to fail open without enrichment'); + assert.equal(second.diagnostics?.pyright?.health?.state, 'quarantined_for_run', 'expected same fingerprint rerun to be quarantined'); + assert.equal(second.diagnostics?.pyright?.fallback?.state, 'quarantined_for_run', 'expected fallback contract to reflect active quarantine'); + assert.equal(second.diagnostics?.pyright?.fidelity?.state, 'quarantined', 'expected fidelity contract to reflect active quarantine'); + assert.equal(second.diagnostics?.pyright?.fidelity?.qualityDelta?.partialSuccess, false, 'expected quarantined rerun to report no partial success'); + assert.equal( + Array.isArray(second.diagnostics?.pyright?.fidelity?.runtimeIssues) + && second.diagnostics.pyright.fidelity.runtimeIssues.includes('workspace_quarantined'), + true, + 'expected fidelity contract to preserve workspace quarantine classification' + ); + assert.equal( + Array.isArray(second.diagnostics?.pyright?.checks) + && second.diagnostics.pyright.checks.some((check) => check?.name === 'pyright_quarantined_for_run'), + true, + 'expected explicit quarantine warning on immediate rerun' + ); + assert.equal( + second.diagnostics?.pyright?.runtime?.requests?.byMethod?.['textDocument/documentSymbol']?.requests ?? 0, + 0, + 'expected quarantined rerun to avoid replaying documentSymbol requests' + ); + + console.log('pyright provider timeout state test passed'); +} finally { + __testPyrightRuntimeHealth.reset(); + await __testLspSessionPool.reset(); + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/pyright-request-planner.test.js b/tests/tooling/lsp/pyright-request-planner.test.js new file mode 100644 index 000000000..ca79a0721 --- /dev/null +++ b/tests/tooling/lsp/pyright-request-planner.test.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { __resolvePyrightRequestPlanForTests } from '../../../src/index/tooling/pyright-planner.js'; + +const buildDoc = (virtualPath, text, languageId = 'python') => ({ + virtualPath, + text, + languageId, + effectiveExt: languageId === 'python' ? '.py' : '.txt' +}); + +const buildTarget = (virtualPath, id) => ({ + virtualPath, + chunkRef: { + chunkUid: `ck:${id}`, + chunkId: `chunk:${id}`, + file: virtualPath.replace(/^\.poc-vfs\//u, '').replace(/#.*$/u, '') + } +}); + +const docs = [ + buildDoc('.poc-vfs/pkg-a/src/core.py#seg:core', 'class Core:\n pass\n\ndef alpha():\n return 1\n'), + buildDoc('.poc-vfs/pkg-a/src/helpers.py#seg:helpers', 'def helper():\n return 2\n'), + buildDoc('.poc-vfs/pkg-a/tests/test_core.py#seg:test_core', 'def test_core():\n assert True\n'), + buildDoc('.poc-vfs/pkg-b/src/other.py#seg:other', 'def other():\n return 3\n'), + buildDoc('.poc-vfs/src/native.cc#seg:native', 'int native() { return 1; }\n', 'cpp') +]; + +const targets = [ + buildTarget('.poc-vfs/pkg-a/src/core.py#seg:core', 'core:0'), + buildTarget('.poc-vfs/pkg-a/src/core.py#seg:core', 'core:1'), + buildTarget('.poc-vfs/pkg-a/src/helpers.py#seg:helpers', 'helpers:0'), + buildTarget('.poc-vfs/pkg-a/tests/test_core.py#seg:test_core', 'test:0'), + buildTarget('.poc-vfs/pkg-b/src/other.py#seg:other', 'other:0') +]; + +const baselinePlan = __resolvePyrightRequestPlanForTests({ + repoRoot: process.cwd(), + documents: docs.filter((doc) => doc.languageId === 'python'), + targets, + allDocuments: docs, + workspaceRootByVirtualPath: { + '.poc-vfs/pkg-a/src/core.py#seg:core': 'pkg-a', + '.poc-vfs/pkg-a/src/helpers.py#seg:helpers': 'pkg-a', + '.poc-vfs/pkg-a/tests/test_core.py#seg:test_core': 'pkg-a', + '.poc-vfs/pkg-b/src/other.py#seg:other': 'pkg-b' + } +}); + +assert.equal(baselinePlan.workspaceRootRel, 'pkg-a', 'expected pyright to narrow to the dominant workspace root'); +assert.equal( + baselinePlan.selectedDocuments.some((doc) => String(doc.virtualPath).includes('pkg-b/src/other.py')), + false, + 'expected mismatched workspace documents to be skipped' +); +assert.equal( + baselinePlan.selectedDocuments.some((doc) => String(doc.virtualPath).includes('pkg-a/tests/test_core.py')), + false, + 'expected low-value test documents to be skipped' +); +assert.equal( + baselinePlan.diagnostics.countsByReason.workspace_mismatch >= 1, + true, + 'expected workspace mismatch count' +); +assert.equal( + baselinePlan.diagnostics.countsByReason.path_policy_low_value >= 1, + true, + 'expected path policy low-value count' +); +assert.equal( + baselinePlan.documentSymbolConcurrency <= 3, + true, + 'expected mixed-language pressure to tighten pyright concurrency' +); + +const pressureDocs = Array.from({ length: 80 }, (_, index) => buildDoc( + `.poc-vfs/pkg-a/src/module-${String(index).padStart(3, '0')}.py#seg:module-${index}`, + `def fn_${index}():\n return ${index}\n` +)); +const pressureTargets = pressureDocs.map((doc, index) => buildTarget(doc.virtualPath, `pressure:${index}`)); +const pressuredPlan = __resolvePyrightRequestPlanForTests({ + repoRoot: process.cwd(), + documents: pressureDocs, + targets: pressureTargets, + allDocuments: pressureDocs, + persistedHealth: { + workspaceRootRel: 'pkg-a', + documentSymbolTimeouts: 3, + documentSymbolFailures: 4, + documentSymbolP95Ms: 3200 + }, + workspaceRootByVirtualPath: Object.fromEntries( + pressureDocs.map((doc) => [doc.virtualPath, 'pkg-a']) + ) +}); + +assert.equal(pressuredPlan.healthLevel, 'severe', 'expected persisted pyright health to classify as severe'); +assert.equal(pressuredPlan.documentSymbolConcurrency, 1, 'expected severe health pressure to force serial documentSymbol planning'); +assert.equal(pressuredPlan.selectedDocuments.length <= 24, true, 'expected severe health pressure to cap selected docs aggressively'); +assert.equal( + pressuredPlan.diagnostics.countsByReason.budget_capped >= 1, + true, + 'expected planner to record budget-capped documents under health pressure' +); + +const hoverPressuredPlan = __resolvePyrightRequestPlanForTests({ + repoRoot: process.cwd(), + documents: pressureDocs, + targets: pressureTargets, + allDocuments: pressureDocs, + persistedHealth: { + workspaceRootRel: 'pkg-a', + documentSymbolTimeouts: 0, + documentSymbolFailures: 0, + documentSymbolP95Ms: 0, + hoverTimeouts: 2, + hoverFailures: 1, + hoverP95Ms: 3100 + }, + workspaceRootByVirtualPath: Object.fromEntries( + pressureDocs.map((doc) => [doc.virtualPath, 'pkg-a']) + ) +}); + +assert.equal(hoverPressuredPlan.healthLevel, 'severe', 'expected persisted hover timeout pressure to classify as severe'); +assert.equal(hoverPressuredPlan.documentSymbolConcurrency, 1, 'expected severe hover pressure to force serial documentSymbol planning'); +assert.equal(hoverPressuredPlan.selectedDocuments.length <= 24, true, 'expected severe hover pressure to cap selected docs aggressively'); + +console.log('pyright request planner test passed'); diff --git a/tests/tooling/lsp/reader-closed-restart-reaps-stale-process.test.js b/tests/tooling/lsp/reader-closed-restart-reaps-stale-process.test.js new file mode 100644 index 000000000..f41cc0f06 --- /dev/null +++ b/tests/tooling/lsp/reader-closed-restart-reaps-stale-process.test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createStaleProcessRestartHarness, + sleep +} from './helpers/stale-process-restart-harness.js'; + +const { client, lifecycleEvents, spawnedChildren, startWithBackoffRetry } = + createStaleProcessRestartHarness(); + +try { + client.start(); + assert.equal(spawnedChildren.length, 1, 'expected initial fake child spawn'); + + const firstChild = spawnedChildren[0]; + firstChild.stdout.emit('close'); + await sleep(25); + + await startWithBackoffRetry(); + assert.equal(spawnedChildren.length, 2, 'expected replacement child spawn after stale reader close'); + + const staleReapEvent = lifecycleEvents.find( + (event) => ( + String(event.reason || '').startsWith('reader_closed') + && (event.kind === 'reap' || event.kind === 'kill_diagnostics') + ) + ); + if (staleReapEvent) { + assert.ok( + staleReapEvent.kind === 'reap' || staleReapEvent.kind === 'kill_diagnostics', + 'expected reader-closed lifecycle event to represent stale-process cleanup' + ); + } +} finally { + await Promise.resolve(client.kill()); +} + +console.log('LSP reader-closed restart stale-process reap test passed'); diff --git a/tests/tooling/lsp/request-budget-plan.test.js b/tests/tooling/lsp/request-budget-plan.test.js new file mode 100644 index 000000000..85dc3e1c6 --- /dev/null +++ b/tests/tooling/lsp/request-budget-plan.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { __resolveAdaptiveLspRequestBudgetPlanForTests } from '../../../src/integrations/tooling/providers/lsp.js'; + +const baseline = __resolveAdaptiveLspRequestBudgetPlanForTests({ + providerId: 'pyright', + workspaceKey: 'repo-root', + selection: { + selectedDocs: 24, + selectedTargets: 96, + hoverMaxPerFile: 4 + }, + clientMetrics: { + byMethod: { + 'textDocument/hover': { + requests: 20, + failed: 1, + timedOut: 0, + latencyMs: { p95: 900 } + } + } + }, + lifecycleState: { + crashLoopQuarantined: false, + fdPressureBackoffActive: false + }, + guardState: { + tripCount: 0 + } +}); + +assert.equal(baseline.providerId, 'pyright'); +assert.equal(baseline.workspaceKey, 'repo-root'); +assert.equal(baseline.byKind.documentSymbol.maxRequests > 0, true, 'expected documentSymbol budget'); +assert.equal(baseline.byKind.hover.maxRequests > 0, true, 'expected hover budget'); +assert.equal( + baseline.byKind.signatureHelp.maxRequests < baseline.byKind.hover.maxRequests, + true, + 'expected separate signatureHelp budget below hover budget' +); +assert.equal( + baseline.byKind.references.maxRequests < baseline.byKind.definition.maxRequests, + true, + 'expected references budget to be tighter than definition budget' +); + +const degraded = __resolveAdaptiveLspRequestBudgetPlanForTests({ + providerId: 'pyright', + workspaceKey: 'repo-root', + selection: { + selectedDocs: 24, + selectedTargets: 96, + hoverMaxPerFile: 4 + }, + clientMetrics: { + byMethod: { + 'textDocument/hover': { + requests: 10, + failed: 4, + timedOut: 3, + latencyMs: { p95: 3200 } + }, + 'textDocument/signatureHelp': { + requests: 8, + failed: 3, + timedOut: 2, + latencyMs: { p95: 2800 } + } + } + }, + lifecycleState: { + crashLoopQuarantined: false, + fdPressureBackoffActive: true + }, + guardState: { + tripCount: 1 + } +}); + +assert.equal(degraded.degraded, true, 'expected degraded budget plan'); +assert.equal( + degraded.byKind.hover.maxRequests < baseline.byKind.hover.maxRequests, + true, + 'expected hover budget to tighten under pressure' +); +assert.equal( + degraded.byKind.signatureHelp.maxRequests < baseline.byKind.signatureHelp.maxRequests, + true, + 'expected signatureHelp budget to tighten under pressure' +); +assert.equal( + degraded.byKind.hover.reasonCodes.includes('timeout_pressure'), + true, + 'expected timeout pressure reason' +); +assert.equal( + degraded.byKind.hover.reasonCodes.includes('breaker_or_quarantine'), + true, + 'expected lifecycle pressure reason' +); + +console.log('LSP request budget plan test passed'); diff --git a/tests/tooling/lsp/request-cache-persistent-hit.test.js b/tests/tooling/lsp/request-cache-persistent-hit.test.js new file mode 100644 index 000000000..457d68b05 --- /dev/null +++ b/tests/tooling/lsp/request-cache-persistent-hit.test.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { __testLspSessionPool } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const removeDirWithRetry = async (targetPath, attempts = 6) => { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + const code = String(error?.code || '').trim().toUpperCase(); + if (!['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code) || attempt === attempts - 1) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } + } + if (lastError) throw lastError; +}; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-request-cache-hit-${process.pid}-${Date.now()}`); +await removeDirWithRetry(tempRoot); +await fs.mkdir(tempRoot, { recursive: true }); + +const cacheRoot = path.join(tempRoot, 'cache'); +const firstTracePath = path.join(tempRoot, 'trace-first.jsonl'); +const secondTracePath = path.join(tempRoot, 'trace-second.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'const sentinel = 1;\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:request-cache.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:request-cache'; + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (!detail) return null; + if (detail === 'add') { + return { + signature: detail, + returnType: 'unknown', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + if (detail === 'int add(int a, int b)') { + return { + signature: detail, + returnType: 'int', + paramTypes: { + a: 'int', + b: 'int' + }, + paramNames: ['a', 'b'] + }; + } + return null; +}; + +const runCollect = async (tracePath) => { + let result = null; + await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + cacheRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp', + docHash: 'doc-hash-request-cache' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_request_cache', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'signature-help'], + providerId: 'clangd', + providerVersion: '9.9.9', + workspaceKey: 'repo-root', + parseSignature + }); + }); + return result; +}; + +try { + const first = await runCollect(firstTracePath); + const second = await runCollect(secondTracePath); + + const firstEvents = await parseJsonLinesFile(firstTracePath); + const secondEvents = await parseJsonLinesFile(secondTracePath); + const firstRequestCount = firstEvents.filter((entry) => entry.kind === 'request').length; + const secondRequestCount = secondEvents.filter((entry) => entry.kind === 'request').length; + + assert.equal(firstRequestCount > 0, true, 'expected initial run to issue LSP requests'); + assert.equal(secondRequestCount < firstRequestCount, true, 'expected persistent request cache to suppress second-run requests'); + assert.equal( + Number(second?.runtime?.requestCache?.persistedHits || 0) >= 1, + true, + 'expected second run to report persisted request cache hits' + ); + assert.equal( + Number(second?.runtime?.requestCache?.byKind?.hover?.hits || 0) >= 1, + true, + 'expected hover request cache hit telemetry' + ); + assert.equal( + Number(second?.runtime?.requestCache?.byKind?.signature_help?.hits || 0) >= 1, + true, + 'expected signatureHelp request cache hit telemetry' + ); + + console.log('LSP persistent request cache hit test passed'); +} finally { + await __testLspSessionPool.reset(); + await removeDirWithRetry(tempRoot); +} diff --git a/tests/tooling/lsp/request-timeout-sends-cancel.test.js b/tests/tooling/lsp/request-timeout-sends-cancel.test.js new file mode 100644 index 000000000..0af6d74e1 --- /dev/null +++ b/tests/tooling/lsp/request-timeout-sends-cancel.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; +import { createFramedJsonRpcParser } from '../../../src/shared/jsonrpc.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { FakeChildProcess } from './helpers/fake-child-process.js'; + +const outboundMessages = []; +const client = createLspClient({ + cmd: 'fake-lsp', + args: ['--stdio'], + log: () => {}, + spawnProcess: () => { + const child = new FakeChildProcess(); + const outboundParser = createFramedJsonRpcParser({ + onMessage: (message) => outboundMessages.push(message) + }); + child.stdin.on('data', (chunk) => { + outboundParser.push(chunk); + }); + return child; + } +}); + +try { + const holdOpen = setInterval(() => {}, 25); + let timedOut = false; + try { + await client.request('textDocument/signatureHelp', { textDocument: { uri: 'file:///fake.cpp' } }, { timeoutMs: 40 }); + } catch (error) { + timedOut = String(error?.code || '') === 'ERR_LSP_REQUEST_TIMEOUT'; + } finally { + clearInterval(holdOpen); + } + assert.equal(timedOut, true, 'expected request timeout rejection'); + + await sleep(40); + + const requestMessage = outboundMessages.find((message) => message?.method === 'textDocument/signatureHelp'); + const cancelMessage = outboundMessages.find((message) => message?.method === '$/cancelRequest'); + assert.ok(requestMessage && Number.isFinite(Number(requestMessage.id)), 'expected timed-out request frame'); + assert.ok(cancelMessage, 'expected timeout path to send $/cancelRequest'); + assert.equal( + Number(cancelMessage?.params?.id), + Number(requestMessage.id), + 'expected cancel request id to target timed-out request id' + ); + + console.log('LSP request timeout sends cancel test passed'); +} finally { + await Promise.resolve(client.kill()); +} diff --git a/tests/tooling/lsp/restart-generation-safety.test.js b/tests/tooling/lsp/restart-generation-safety.test.js new file mode 100644 index 000000000..81d59bb92 --- /dev/null +++ b/tests/tooling/lsp/restart-generation-safety.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsp-generation'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const counterPath = path.join(tempRoot, 'spawn-counter.txt'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const countSpawns = async () => countNonEmptyLines(counterPath); + +const waitForSpawns = async (expected, timeoutMs = 2000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await countSpawns() >= expected) return; + await sleep(25); + } + throw new Error(`Timed out waiting for ${expected} LSP spawn(s).`); +}; + +const client = createLspClient({ + cmd: process.execPath, + args: [serverPath], + env: applyTestEnv({ + syncProcess: false, + extraEnv: { POC_LSP_COUNTER: counterPath } + }), + log: () => {} +}); + +try { + client.start(); + await waitForSpawns(1); + await Promise.resolve(client.kill()); + client.start(); + await waitForSpawns(2); + + await client.initialize({ rootUri: pathToFileURL(tempRoot).href }); + await client.shutdownAndExit(); + await sleep(100); +} finally { + await Promise.resolve(client.kill()); +} + +await sleep(200); +assert.equal( + getTrackedSubprocessCount(), + 0, + 'expected tracked subprocess registry to be empty after restart/kill sequence' +); + +const spawns = await countSpawns(); +assert.equal(spawns, 2, 'expected only two LSP spawns after restart'); + +console.log('LSP generation safety test passed'); diff --git a/tests/tooling/lsp/runtime-cleanup-reaps-tracked-subprocesses.test.js b/tests/tooling/lsp/runtime-cleanup-reaps-tracked-subprocesses.test.js new file mode 100644 index 000000000..97ec0aee0 --- /dev/null +++ b/tests/tooling/lsp/runtime-cleanup-reaps-tracked-subprocesses.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { cleanupLspTestRuntime } from '../../helpers/lsp-runtime.js'; +import { getTrackedSubprocessCount } from '../../../src/shared/subprocess/tracking.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; +import { sleep } from '../../../src/shared/sleep.js'; + +const childPromise = spawnSubprocess( + process.execPath, + ['-e', 'setInterval(() => {}, 10_000)'], + { + name: 'lsp-runtime-cleanup-contract-child', + rejectOnNonZeroExit: false, + timeoutMs: 60_000 + } +); +childPromise.catch(() => {}); + +await sleep(50); +assert.ok( + getTrackedSubprocessCount() >= 1, + 'expected at least one tracked subprocess before cleanup' +); + +await cleanupLspTestRuntime({ reason: 'lsp_runtime_cleanup_contract' }); +await sleep(50); + +assert.equal( + getTrackedSubprocessCount(), + 0, + 'expected cleanupLspTestRuntime to reap tracked subprocesses' +); + +await Promise.race([ + childPromise, + sleep(2_000) +]); + +console.log('lsp runtime cleanup reaps tracked subprocesses test passed'); diff --git a/tests/tooling/lsp/runtime-config-resolution.test.js b/tests/tooling/lsp/runtime-config-resolution.test.js new file mode 100644 index 000000000..8a162fc61 --- /dev/null +++ b/tests/tooling/lsp/runtime-config-resolution.test.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { resolveLspRuntimeConfig } from '../../../src/index/tooling/lsp-runtime-config.js'; + +const resolved = resolveLspRuntimeConfig({ + providerConfig: { + timeoutMs: 1500, + softDeadlineMs: 1700, + maxRetries: 3, + hoverEnabled: false, + signatureHelpEnabled: false, + semanticTokensEnabled: false, + inlayHintsEnabled: false, + hoverRequireMissingReturn: false, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false, + definitionTimeoutMs: 3900, + typeDefinitionTimeoutMs: 4100, + referencesTimeoutMs: 4300, + hoverMaxPerFile: 7, + hoverDisableAfterTimeouts: 2, + signatureHelpConcurrency: 6, + documentSymbolTimeoutMs: 2400, + lifecycle: { + restartWindowMs: 2100, + maxRestartsPerWindow: 4, + sessionIdleTimeoutMs: 2500 + } + }, + globalConfigs: [{ + timeoutMs: 31000, + softDeadlineMs: 9000, + maxRetries: 9, + circuitBreakerThreshold: 11, + hoverTimeoutMs: 3600, + signatureHelpTimeoutMs: 5100, + definitionConcurrency: 5, + typeDefinitionConcurrency: 4, + referencesConcurrency: 3, + hoverEnabled: true, + signatureHelpEnabled: true, + lifecycle: { + lifecycleRestartWindowMs: 9000, + lifecycleMaxRestartsPerWindow: 8, + lifecycleFdPressureBackoffMs: 700, + lifecycleSessionMaxLifetimeMs: 600000 + } + }], + defaults: { + timeoutMs: 45000, + retries: 2, + breakerThreshold: 5 + } +}); + +assert.equal(resolved.timeoutMs, 1500, 'expected provider timeout override'); +assert.equal(resolved.softDeadlineMs, 1700, 'expected provider soft deadline override'); +assert.equal(resolved.retries, 3, 'expected provider retries override'); +assert.equal(resolved.breakerThreshold, 11, 'expected global breaker threshold fallback'); +assert.equal(resolved.documentSymbolTimeoutMs, 2400, 'expected provider documentSymbol timeout'); +assert.equal(resolved.hoverTimeoutMs, 3600, 'expected global hover timeout fallback'); +assert.equal(resolved.signatureHelpTimeoutMs, 5100, 'expected global signatureHelp timeout fallback'); +assert.equal(resolved.definitionTimeoutMs, 3900, 'expected provider definition timeout'); +assert.equal(resolved.typeDefinitionTimeoutMs, 4100, 'expected provider typeDefinition timeout'); +assert.equal(resolved.referencesTimeoutMs, 4300, 'expected provider references timeout'); +assert.equal(resolved.hoverMaxPerFile, 7, 'expected provider hover max-per-file'); +assert.equal(resolved.hoverDisableAfterTimeouts, 2, 'expected provider hover timeout-disable threshold'); +assert.equal(resolved.signatureHelpConcurrency, 6, 'expected provider signatureHelp concurrency'); +assert.equal(resolved.hoverEnabled, false, 'expected provider hover enabled override'); +assert.equal(resolved.signatureHelpEnabled, false, 'expected provider signatureHelp enabled override'); +assert.equal(resolved.semanticTokensEnabled, false, 'expected provider semanticTokens enabled override'); +assert.equal(resolved.inlayHintsEnabled, false, 'expected provider inlayHints enabled override'); +assert.equal(resolved.definitionEnabled, false, 'expected provider definition enabled override'); +assert.equal(resolved.definitionConcurrency, 5, 'expected global definition concurrency fallback'); +assert.equal(resolved.typeDefinitionEnabled, false, 'expected provider typeDefinition enabled override'); +assert.equal(resolved.typeDefinitionConcurrency, 4, 'expected global typeDefinition concurrency fallback'); +assert.equal(resolved.referencesEnabled, false, 'expected provider references enabled override'); +assert.equal(resolved.referencesConcurrency, 3, 'expected global references concurrency fallback'); +assert.equal(resolved.hoverRequireMissingReturn, false, 'expected provider hover completeness override'); +assert.equal(resolved.lifecycleRestartWindowMs, 2100, 'expected provider lifecycle restart window alias'); +assert.equal(resolved.lifecycleMaxRestartsPerWindow, 4, 'expected provider lifecycle max restarts alias'); +assert.equal(resolved.lifecycleFdPressureBackoffMs, 700, 'expected global lifecycle fd backoff fallback'); +assert.equal(resolved.sessionIdleTimeoutMs, 2500, 'expected provider session idle timeout alias'); +assert.equal(resolved.sessionMaxLifetimeMs, 600000, 'expected global session max lifetime alias'); + +const defaultsOnly = resolveLspRuntimeConfig({ + providerConfig: null, + globalConfigs: [], + defaults: { + timeoutMs: 12000, + retries: 1, + breakerThreshold: 3 + } +}); + +assert.equal(defaultsOnly.timeoutMs, 12000, 'expected timeout default'); +assert.equal(defaultsOnly.softDeadlineMs, null, 'expected soft deadline to remain unset'); +assert.equal(defaultsOnly.retries, 1, 'expected retries default'); +assert.equal(defaultsOnly.breakerThreshold, 3, 'expected breaker default'); +assert.equal(defaultsOnly.documentSymbolTimeoutMs, null, 'expected documentSymbol timeout to remain unset'); +assert.equal(defaultsOnly.hoverTimeoutMs, null, 'expected hover timeout to remain unset'); +assert.equal(defaultsOnly.signatureHelpTimeoutMs, null, 'expected signatureHelp timeout to remain unset'); +assert.equal(defaultsOnly.definitionTimeoutMs, null, 'expected definition timeout to remain unset'); +assert.equal(defaultsOnly.typeDefinitionTimeoutMs, null, 'expected typeDefinition timeout to remain unset'); +assert.equal(defaultsOnly.referencesTimeoutMs, null, 'expected references timeout to remain unset'); +assert.equal(defaultsOnly.hoverMaxPerFile, null, 'expected hover max-per-file to remain unset'); +assert.equal(defaultsOnly.hoverDisableAfterTimeouts, null, 'expected hover timeout-disable threshold to remain unset'); +assert.equal(defaultsOnly.signatureHelpConcurrency, null, 'expected signatureHelp concurrency to remain unset'); +assert.equal(defaultsOnly.hoverEnabled, null, 'expected hover enabled to remain unset'); +assert.equal(defaultsOnly.signatureHelpEnabled, null, 'expected signatureHelp enabled to remain unset'); +assert.equal(defaultsOnly.semanticTokensEnabled, null, 'expected semanticTokens enabled to remain unset'); +assert.equal(defaultsOnly.inlayHintsEnabled, null, 'expected inlayHints enabled to remain unset'); +assert.equal(defaultsOnly.definitionEnabled, null, 'expected definition enabled to remain unset'); +assert.equal(defaultsOnly.definitionConcurrency, null, 'expected definition concurrency to remain unset'); +assert.equal(defaultsOnly.typeDefinitionEnabled, null, 'expected typeDefinition enabled to remain unset'); +assert.equal(defaultsOnly.typeDefinitionConcurrency, null, 'expected typeDefinition concurrency to remain unset'); +assert.equal(defaultsOnly.referencesEnabled, null, 'expected references enabled to remain unset'); +assert.equal(defaultsOnly.referencesConcurrency, null, 'expected references concurrency to remain unset'); +assert.equal(defaultsOnly.hoverRequireMissingReturn, null, 'expected hover completeness toggle to remain unset'); +assert.equal(defaultsOnly.lifecycleRestartWindowMs, null, 'expected lifecycle restart window to remain unset'); +assert.equal(defaultsOnly.lifecycleMaxRestartsPerWindow, null, 'expected lifecycle max restarts to remain unset'); +assert.equal(defaultsOnly.lifecycleFdPressureBackoffMs, null, 'expected lifecycle fd backoff to remain unset'); +assert.equal(defaultsOnly.sessionIdleTimeoutMs, null, 'expected session idle timeout to remain unset'); +assert.equal(defaultsOnly.sessionMaxLifetimeMs, null, 'expected session max lifetime to remain unset'); + +console.log('LSP runtime config resolution test passed'); diff --git a/tests/tooling/lsp/runtime-envelope.test.js b/tests/tooling/lsp/runtime-envelope.test.js new file mode 100644 index 000000000..febcd87fb --- /dev/null +++ b/tests/tooling/lsp/runtime-envelope.test.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-runtime-envelope-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:runtime'; + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_runtime', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd'], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) +}); + +assert.ok(result.runtime && typeof result.runtime === 'object', 'expected runtime envelope'); +assert.equal(typeof result.runtime.command, 'string', 'expected runtime command'); +assert.ok(result.runtime.capabilities && typeof result.runtime.capabilities === 'object', 'expected capability mask'); +assert.equal(Object.getPrototypeOf(result.runtime.capabilities), null, 'expected null-prototype capability mask'); +assert.ok(result.runtime.capabilityGate && typeof result.runtime.capabilityGate === 'object', 'expected capability gate envelope'); +assert.equal(result.runtime.capabilities.documentSymbol, true, 'expected documentSymbol capability flag'); +assert.equal(result.runtime.capabilities.hover, true, 'expected hover capability flag'); +assert.equal(result.runtime.capabilities.semanticTokens, false, 'expected semanticTokens capability flag'); +assert.equal(result.runtime.capabilities.signatureHelp, false, 'expected signatureHelp capability flag'); +assert.equal(result.runtime.capabilities.inlayHints, false, 'expected inlayHints capability flag'); +assert.equal(result.runtime.capabilities.definition, false, 'expected definition capability flag'); +assert.equal(result.runtime.capabilities.typeDefinition, false, 'expected typeDefinition capability flag'); +assert.equal(result.runtime.capabilities.references, false, 'expected references capability flag'); +assert.equal(result.runtime.capabilityGate.effective.documentSymbol, true, 'expected effective documentSymbol gate'); +assert.equal(result.runtime.capabilityGate.effective.signatureHelp, false, 'expected effective signatureHelp gate'); +assert.deepEqual( + result.runtime.capabilityGate.missing, + ['definition', 'inlayHints', 'references', 'semanticTokens', 'signatureHelp', 'typeDefinition'] +); +assert.ok(result.runtime.lifecycle && typeof result.runtime.lifecycle === 'object', 'expected lifecycle metrics'); +assert.ok(result.runtime.guard && typeof result.runtime.guard === 'object', 'expected guard metrics'); +assert.ok(result.runtime.requests && typeof result.runtime.requests === 'object', 'expected request metrics'); +assert.ok(result.runtime.requestBudgets && typeof result.runtime.requestBudgets === 'object', 'expected request budget envelope'); +assert.ok(result.runtime.requestCache && typeof result.runtime.requestCache === 'object', 'expected request cache envelope'); +assert.ok(result.runtime.pooling && typeof result.runtime.pooling === 'object', 'expected pooling metrics'); +assert.ok(result.runtime.hoverMetrics && typeof result.runtime.hoverMetrics === 'object', 'expected hover metrics'); +assert.equal(result.runtime.pooling.enabled, true, 'expected pooling enabled by default'); +assert.equal( + Number.isFinite(Number(result.runtime.requests.requests)), + true, + 'expected request count metric' +); +assert.equal( + Number.isFinite(Number(result.runtime.requests.byMethod?.initialize?.requests)), + true, + 'expected per-method initialize request metric' +); +assert.equal( + Number(result.runtime.requests.latencyMs?.count || 0) >= 1, + true, + 'expected global request latency sample count' +); +assert.equal( + Number.isFinite(Number(result.runtime.requests.latencyMs?.p95)), + true, + 'expected global p95 latency metric' +); +assert.equal( + Number(result.runtime.requests.byMethod?.initialize?.latencyMs?.count || 0) >= 1, + true, + 'expected initialize latency sample count' +); +assert.equal( + Number(result.runtime.requestBudgets?.byKind?.documentSymbol?.maxRequests || 0) >= 1, + true, + 'expected documentSymbol request budget' +); +assert.equal( + Number(result.runtime.requestBudgets?.byKind?.hover?.maxRequests || 0) >= 1, + true, + 'expected hover request budget' +); +assert.equal( + Number.isFinite(Number(result.runtime.requestCache?.hits || 0)), + true, + 'expected request cache hit counter' +); +assert.equal( + Number.isFinite(Number(result.runtime.requestCache?.misses || 0)), + true, + 'expected request cache miss counter' +); +assert.equal( + Number.isFinite(Number(result.runtime.requests.byMethod?.initialize?.latencyMs?.p50)), + true, + 'expected initialize p50 latency metric' +); +assert.equal( + Number.isFinite(Number(result.runtime.lifecycle.startsInWindow)), + true, + 'expected lifecycle starts count' +); + +console.log('LSP runtime envelope test passed'); diff --git a/tests/tooling/lsp/rust-workspace-broken-example-root-promotion.test.js b/tests/tooling/lsp/rust-workspace-broken-example-root-promotion.test.js new file mode 100644 index 000000000..7db781c06 --- /dev/null +++ b/tests/tooling/lsp/rust-workspace-broken-example-root-promotion.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildSelectedRustWorkspacePartitions } from '../../../src/index/tooling/rust-workspace-partitioning.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `rust-workspace-broken-example-promotion-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'examples', 'broken', 'src'), { recursive: true }); + +await fs.writeFile( + path.join(tempRoot, 'Cargo.toml'), + [ + '[workspace]', + 'members = ["examples/broken"]' + ].join('\n'), + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'examples', 'broken', 'Cargo.toml'), + '[package\nname = "broken-example"\n', + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'examples', 'broken', 'src', 'main.rs'), + 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', + 'utf8' +); + +const result = buildSelectedRustWorkspacePartitions(tempRoot, [ + '.poc-vfs/examples/broken/src/main.rs#seg:rust-workspace-broken-example-promotion.txt' +]); + +assert.deepEqual(result.unmatchedPaths, [], 'expected broken example member document to match a promoted workspace partition'); +assert.equal(result.partitions.length, 1, 'expected single promoted workspace partition'); +assert.equal(result.partitions[0]?.rootRel, '.', 'expected broken example member to promote to ancestor workspace root'); +assert.equal(result.partitions[0]?.role, 'workspace_root', 'expected promoted broken example partition role to be workspace_root'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('rust workspace broken example root promotion test passed'); diff --git a/tests/tooling/lsp/rust-workspace-member-root-promotion.test.js b/tests/tooling/lsp/rust-workspace-member-root-promotion.test.js new file mode 100644 index 000000000..f2eaac422 --- /dev/null +++ b/tests/tooling/lsp/rust-workspace-member-root-promotion.test.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { buildSelectedRustWorkspacePartitions } from '../../../src/index/tooling/rust-workspace-partitioning.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `rust-workspace-root-promotion-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'crates', 'member', 'src'), { recursive: true }); + +await fs.writeFile( + path.join(tempRoot, 'Cargo.toml'), + [ + '[workspace]', + 'members = ["crates/member"]' + ].join('\n'), + 'utf8' +); +await fs.writeFile( + path.join(tempRoot, 'crates', 'member', 'Cargo.toml'), + [ + '[package]', + 'name = "member"', + 'version = "0.1.0"', + 'edition = "2021"' + ].join('\n'), + 'utf8' +); +await fs.writeFile(path.join(tempRoot, 'crates', 'member', 'src', 'lib.rs'), 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n', 'utf8'); + +const result = buildSelectedRustWorkspacePartitions(tempRoot, [ + '.poc-vfs/crates/member/src/lib.rs#seg:rust-workspace-root-promotion.txt' +]); + +assert.deepEqual(result.unmatchedPaths, [], 'expected Rust member document to match a workspace partition'); +assert.equal(result.partitions.length, 1, 'expected single promoted workspace partition'); +assert.equal(result.partitions[0]?.rootRel, '.', 'expected workspace member to promote to ancestor workspace root'); +assert.equal(result.partitions[0]?.role, 'workspace_root', 'expected promoted partition role to be workspace_root'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('rust workspace member root promotion test passed'); diff --git a/tests/tooling/lsp/semantic-signals.test.js b/tests/tooling/lsp/semantic-signals.test.js new file mode 100644 index 000000000..33641f5bd --- /dev/null +++ b/tests/tooling/lsp/semantic-signals.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + decodeSemanticTokens, + findSemanticTokenAtPosition, + normalizeSemanticTokenClass, + parseInlayHintSignalInfo +} from '../../../src/integrations/tooling/providers/lsp/semantic-signals.js'; +import { buildLineIndex } from '../../../src/shared/lines.js'; + +assert.equal( + normalizeSemanticTokenClass({ providerId: 'clangd', tokenType: 'function' }), + 'function', + 'expected clangd function token normalization' +); +assert.equal( + normalizeSemanticTokenClass({ providerId: 'rust-analyzer', tokenType: 'struct' }), + 'struct', + 'expected rust-analyzer struct token normalization' +); + +const decoded = decodeSemanticTokens({ + providerId: 'clangd', + legend: { + tokenTypes: ['namespace', 'class', 'function', 'parameter'], + tokenModifiers: ['declaration'] + }, + data: [0, 9, 3, 2, 1, 0, 4, 1, 3, 1] +}); +assert.equal(decoded.length, 2, 'expected two decoded semantic tokens'); +assert.equal(decoded[0]?.semanticClass, 'function', 'expected semantic token class mapping'); +assert.equal( + findSemanticTokenAtPosition(decoded, { line: 0, character: 10 })?.semanticClass, + 'function', + 'expected semantic token lookup by cursor position' +); + +const text = 'function add(a, b) { return a + b; }\n'; +const lineIndex = buildLineIndex(text); +const inlayInfo = parseInlayHintSignalInfo({ + hints: [{ + position: { line: 0, character: 13 }, + label: 'a: integer' + }, { + position: { line: 0, character: 16 }, + label: 'b: integer' + }, { + position: { line: 0, character: 18 }, + label: '-> integer' + }], + lineIndex, + text, + targetRange: { start: 0, end: text.length }, + paramNames: ['a', 'b'], + languageId: 'javascript' +}); +assert.equal(inlayInfo?.returnType, 'number', 'expected inlay return type normalization'); +assert.equal(inlayInfo?.paramTypes?.a?.[0]?.type, 'number', 'expected inlay param type normalization'); +assert.equal(inlayInfo?.paramTypes?.a?.[0]?.source, 'lsp_inlay', 'expected inlay hint provenance source'); + +console.log('LSP semantic signals test passed'); diff --git a/tests/tooling/lsp/semantic-tokens-and-inlay-hints.test.js b/tests/tooling/lsp/semantic-tokens-and-inlay-hints.test.js new file mode 100644 index 000000000..eb1327a47 --- /dev/null +++ b/tests/tooling/lsp/semantic-tokens-and-inlay-hints.test.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildLineIndex } from '../../../src/shared/lines.js'; +import { buildTargetLookupIndex } from '../../../src/integrations/tooling/providers/lsp/target-index.js'; +import { + createEmptyHoverMetricsResult, + processDocumentTypes +} from '../../../src/integrations/tooling/providers/lsp/hover-types.js'; + +const docText = 'function add(a, b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.js#seg:semantic-inlay.js'; +const chunkUid = 'ck64:v1:test:src/sample.js:semantic-inlay'; +const addIndex = docText.indexOf('add'); +const aIndex = docText.indexOf('a', addIndex + 3); +const bIndex = docText.indexOf('b', aIndex + 1); +const closeParenIndex = docText.indexOf(')', addIndex); + +const lineIndexFactory = (text) => buildLineIndex(text); +const target = { + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_semantic_inlay', + file: 'src/sample.js', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } +}; +const targetIndexesByPath = new Map([[virtualPath, buildTargetLookupIndex([target])]]); +const byChunkUid = {}; +const hoverFileStats = new Map(); +const hoverLatencyMs = []; +const hoverMetrics = createEmptyHoverMetricsResult(); + +const identityLimiter = async (fn) => await fn(); +const mockClient = { + notify() {}, + async request(method) { + if (method === 'textDocument/documentSymbol') { + return [{ + name: 'add', + kind: 12, + detail: 'add', + range: { + start: { line: 0, character: addIndex }, + end: { line: 0, character: addIndex + 3 } + }, + selectionRange: { + start: { line: 0, character: addIndex }, + end: { line: 0, character: addIndex + 3 } + } + }]; + } + if (method === 'textDocument/semanticTokens/full') { + return { + data: [ + 0, addIndex, 3, 2, 1, + 0, aIndex - addIndex, 1, 3, 1, + 0, bIndex - aIndex, 1, 3, 1 + ] + }; + } + if (method === 'textDocument/inlayHint') { + return [{ + position: { line: 0, character: aIndex }, + label: 'a: integer', + kind: 1 + }, { + position: { line: 0, character: bIndex }, + label: 'b: integer', + kind: 1 + }, { + position: { line: 0, character: closeParenIndex }, + label: '-> integer', + kind: 1 + }]; + } + throw new Error(`unexpected request ${method}`); + } +}; + +await processDocumentTypes({ + doc: { + virtualPath, + text: docText, + languageId: 'javascript', + docHash: 'hash-semantic-inlay' + }, + cmd: 'semantic-inlay-test', + client: mockClient, + guard: { + isOpen: () => false, + run: async (fn) => await fn({ timeoutMs: 5000 }), + getState: () => ({}) + }, + guardRun: async (fn) => await fn({ timeoutMs: 5000 }), + log: () => {}, + strict: true, + parseSignature: () => null, + lineIndexFactory, + uri: 'poc-vfs://semantic-inlay.js', + legacyUri: null, + languageId: 'javascript', + openDocs: new Map(), + targetIndexesByPath, + byChunkUid, + signatureParseCache: new Map(), + hoverEnabled: false, + semanticTokensEnabled: true, + signatureHelpEnabled: false, + inlayHintsEnabled: true, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false, + docPathPolicy: null, + hoverRequireMissingReturn: true, + resolvedHoverKinds: null, + resolvedHoverMaxPerFile: 2, + resolvedHoverDisableAfterTimeouts: 2, + resolvedHoverTimeout: 5000, + resolvedSignatureHelpTimeout: 5000, + resolvedDefinitionTimeout: 5000, + resolvedTypeDefinitionTimeout: 5000, + resolvedReferencesTimeout: 5000, + resolvedDocumentSymbolTimeout: 5000, + hoverLimiter: identityLimiter, + signatureHelpLimiter: identityLimiter, + definitionLimiter: identityLimiter, + typeDefinitionLimiter: identityLimiter, + referencesLimiter: identityLimiter, + requestCacheEntries: new Map(), + requestCachePersistedKeys: new Set(), + requestCacheMetrics: { + providerId: 'semantic-inlay-test', + hits: 0, + misses: 0, + memoryHits: 0, + persistedHits: 0, + negativeHits: 0, + writes: 0, + byKind: Object.create(null) + }, + markRequestCacheDirty: () => {}, + requestBudgetControllers: { + documentSymbol: { tryReserve: () => true }, + semanticTokens: { tryReserve: () => true }, + inlayHints: { tryReserve: () => true } + }, + requestCacheContext: { + providerId: 'clangd', + providerVersion: '1.0.0', + workspaceKey: 'repo-root' + }, + semanticTokensLegend: { + tokenTypes: ['namespace', 'class', 'function', 'parameter'], + tokenModifiers: ['declaration'] + }, + hoverControl: { disabledGlobal: false }, + documentSymbolControl: { disabled: false }, + hoverFileStats, + hoverLatencyMs, + hoverMetrics, + symbolProcessingConcurrency: 4, + softDeadlineAt: null, + positionEncoding: 'utf-16', + checks: [], + checkFlags: Object.create(null), + abortSignal: null +}); + +const entry = byChunkUid[chunkUid] || null; +assert.ok(entry, 'expected tooling entry for semantic/inlay mode'); +assert.equal(entry.payload?.returnType, 'number', 'expected inlay return type enrichment'); +assert.equal(entry.payload?.paramTypes?.a?.[0]?.type, 'number', 'expected inlay param type enrichment'); +assert.equal(entry.payload?.paramTypes?.b?.[0]?.type, 'number', 'expected second inlay param type enrichment'); +assert.equal(entry.symbolRef?.kind, 'function', 'expected semantic token class to feed symbolRef kind'); +assert.equal(entry.provenance?.stages?.semanticTokens?.succeeded, true, 'expected semantic token stage provenance'); +assert.equal(entry.provenance?.stages?.inlayHints?.succeeded, true, 'expected inlay-hint stage provenance'); +assert.equal(Number(hoverMetrics?.semanticTokensRequested || 0) >= 1, true, 'expected semantic token runtime metric'); +assert.equal(Number(hoverMetrics?.inlayHintsRequested || 0) >= 1, true, 'expected inlay-hint runtime metric'); + +console.log('LSP semantic tokens and inlay hints test passed'); diff --git a/tests/tooling/lsp/session-pool-lifecycle-matrix.test.js b/tests/tooling/lsp/session-pool-lifecycle-matrix.test.js new file mode 100644 index 000000000..43613746c --- /dev/null +++ b/tests/tooling/lsp/session-pool-lifecycle-matrix.test.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { __testLspSessionPool, withLspSession } from '../../../src/integrations/tooling/providers/lsp/session-pool.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; + +const createCollectRunner = ({ tempRoot, providerId, mode, sessionMaxLifetimeMs = 120_000 }) => async (chunkSuffix) => collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + providerId, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: `ck64:v1:test:src/sample.cpp:${providerId}-${chunkSuffix}`, + chunkId: `chunk_${providerId.replace(/[^a-z0-9]+/gi, '_')}_${chunkSuffix}`, + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', mode], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }), + sessionIdleTimeoutMs: 60_000, + sessionMaxLifetimeMs +}); + +const cleanupTempRoot = async (tempRoot) => { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +}; + +const waitForSessionPoolToDrain = async ({ timeoutMs = 5000, pollMs = 100 } = {}) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (__testLspSessionPool.getSize() === 0 && __testLspSessionPool.getPendingDisposals() === 0) { + return true; + } + await sleep(pollMs); + } + return __testLspSessionPool.getSize() === 0 && __testLspSessionPool.getPendingDisposals() === 0; +}; + +const runCase = async (name, fn) => { + const tempRoot = resolveTestCachePath(root, `${name}-${process.pid}-${Date.now()}`); + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(tempRoot, { recursive: true }); + try { + await fn(tempRoot); + } finally { + await __testLspSessionPool.reset(); + await cleanupTempRoot(tempRoot); + } +}; + +await runCase('lsp-session-pool-reuse-matrix', async (tempRoot) => { + const counterPath = path.join(tempRoot, 'lsp-session-pool.counter'); + const runCollect = createCollectRunner({ + tempRoot, + providerId: 'lsp-session-pool-reuse-matrix', + mode: 'initialize-once' + }); + + await withTemporaryEnv({ POC_LSP_COUNTER: counterPath }, async () => { + const first = await runCollect('one'); + const second = await runCollect('two'); + const spawnCount = await countNonEmptyLines(counterPath); + assert.equal(spawnCount, 1); + assert.equal(first.runtime?.pooling?.enabled, true); + assert.equal(first.runtime?.pooling?.reused, false); + assert.equal(second.runtime?.pooling?.reused, true); + assert.equal(second.enriched >= 1, true); + }); +}); + +await runCase('lsp-session-pool-poisoned-matrix', async (tempRoot) => { + const runCollect = createCollectRunner({ + tempRoot, + providerId: 'lsp-session-pool-poisoned-matrix', + mode: 'disconnect-on-document-symbol' + }); + + const result = await runCollect('poisoned'); + assert.equal(result.checks.some((check) => check?.name === 'tooling_document_symbol_failed'), true); + assert.equal(__testLspSessionPool.getSize(), 0); +}); + +await runCase('lsp-session-pool-lifetime-matrix', async (tempRoot) => { + const counterPath = path.join(tempRoot, 'lsp-session-lifetime.counter'); + const runCollect = createCollectRunner({ + tempRoot, + providerId: 'lsp-session-pool-lifetime-matrix', + mode: 'clangd', + sessionMaxLifetimeMs: 1_000 + }); + + await withTemporaryEnv({ POC_LSP_COUNTER: counterPath }, async () => { + const first = await runCollect('one'); + await sleep(1_200); + const second = await runCollect('two'); + const spawnCount = await countNonEmptyLines(counterPath); + assert.equal(spawnCount, 2); + assert.equal(first.runtime?.pooling?.reused, false); + assert.equal(second.runtime?.pooling?.reused, false); + assert.equal(second.runtime?.pooling?.recycleCount >= 0, true); + }); +}); + +await runCase('lsp-session-pool-disposal-barrier-matrix', async (tempRoot) => { + __testLspSessionPool.setDisposeDelayMs(350); + const sessionOptions = { + enabled: true, + repoRoot: tempRoot, + providerId: 'lsp-session-pool-disposal-barrier-matrix', + workspaceKey: tempRoot, + cmd: process.execPath, + args: ['-e', 'setTimeout(() => {}, 50)'], + cwd: tempRoot, + timeoutMs: 1000, + retries: 0, + breakerThreshold: 1, + sessionIdleTimeoutMs: 60_000, + sessionMaxLifetimeMs: 1_000 + }; + + await withLspSession(sessionOptions, async () => null); + await sleep(1_100); + const startedAt = Date.now(); + await withLspSession(sessionOptions, async () => null); + const elapsedMs = Date.now() - startedAt; + assert.equal(__testLspSessionPool.getSize(), 1); + assert.equal(elapsedMs >= 150, true, `expected disposal barrier wait, got ${elapsedMs}ms`); + await sleep(50); + assert.equal(__testLspSessionPool.getPendingDisposals(), 0); +}); + +await runCase('lsp-session-pool-background-lifetime-matrix', async (tempRoot) => { + const counterPath = path.join(tempRoot, 'lsp-session-bg-lifetime.counter'); + const runCollect = createCollectRunner({ + tempRoot, + providerId: 'lsp-session-pool-background-lifetime-matrix', + mode: 'clangd', + sessionMaxLifetimeMs: 1_500 + }); + + await withTemporaryEnv({ POC_LSP_COUNTER: counterPath }, async () => { + await runCollect('background'); + assert.equal(__testLspSessionPool.getSize(), 1); + await sleep(3_200); + const drained = await waitForSessionPoolToDrain(); + assert.equal(drained, true); + }); +}); + +console.log('LSP session pool lifecycle matrix test passed'); diff --git a/tests/tooling/lsp/shutdown-does-not-respawn-on-closed-transport.test.js b/tests/tooling/lsp/shutdown-does-not-respawn-on-closed-transport.test.js new file mode 100644 index 000000000..2c2f5e861 --- /dev/null +++ b/tests/tooling/lsp/shutdown-does-not-respawn-on-closed-transport.test.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { createTrackedFakeChildProcessSpawner } from './helpers/fake-child-process.js'; + +const { spawnedChildren, spawnProcess } = createTrackedFakeChildProcessSpawner(); +const client = createLspClient({ + cmd: 'fake-lsp', + args: ['--stdio'], + log: () => {}, + spawnProcess +}); + +try { + client.start(); + assert.equal(spawnedChildren.length, 1, 'expected initial fake child spawn'); + const firstChild = spawnedChildren[0]; + firstChild.stdin.emit('close'); + await sleep(20); + + await client.shutdownAndExit(); + await sleep(50); + + assert.equal( + spawnedChildren.length, + 1, + 'expected shutdown on closed transport to avoid spawning a replacement process' + ); +} finally { + await Promise.resolve(client.kill()); +} + +console.log('LSP shutdown closed-transport no-respawn test passed'); diff --git a/tests/tooling/lsp/shutdown.test.js b/tests/tooling/lsp/shutdown.test.js new file mode 100644 index 000000000..ae23fa4d3 --- /dev/null +++ b/tests/tooling/lsp/shutdown.test.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; +import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; +import { sleep } from '../../../src/shared/sleep.js'; + +const root = process.cwd(); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const logs = []; +const client = createLspClient({ + cmd: process.execPath, + args: [serverPath, '--exit-on-shutdown'], + log: (message) => logs.push(message) +}); + +try { + await client.initialize({ rootUri: pathToFileURL(root).href }); + await runWithTimeout( + () => client.shutdownAndExit(), + { + timeoutMs: 7000, + errorFactory: () => createTimeoutError({ + code: 'ERR_TEST_TIMEOUT', + message: 'LSP shutdownAndExit did not settle within 7000ms.' + }) + } + ); + await sleep(200); +} finally { + await Promise.resolve(client.kill()); +} + +if (logs.some((line) => line.includes('ERR_STREAM_DESTROYED'))) { + throw new Error('LSP shutdown emitted ERR_STREAM_DESTROYED.'); +} +if (logs.some((line) => /\[lsp\]\s+write error:/i.test(line))) { + throw new Error('LSP shutdown emitted unexpected LSP write error.'); +} +if (logs.some((line) => /\bEPIPE\b/i.test(line))) { + throw new Error('LSP shutdown emitted EPIPE log noise.'); +} + +console.log('LSP shutdown test passed'); diff --git a/tests/tooling/lsp/signature-help-timeout-adaptive-gates-next-symbol.test.js b/tests/tooling/lsp/signature-help-timeout-adaptive-gates-next-symbol.test.js new file mode 100644 index 000000000..ab70a8fc5 --- /dev/null +++ b/tests/tooling/lsp/signature-help-timeout-adaptive-gates-next-symbol.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseCppTwoIntParamSignature } from '../../helpers/lsp-signature-fixtures.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath( + root, + `lsp-signature-help-timeout-adaptive-gates-next-symbol-${process.pid}-${Date.now()}` +); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\nint sub(int a, int b) { return a - b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:signature-help-timeout-multi.cpp'; +const addStart = docText.indexOf('add'); +const subStart = docText.indexOf('sub'); +const addChunkUid = 'ck64:v1:test:src/sample.cpp:signature-help-timeout-multi:add'; +const subChunkUid = 'ck64:v1:test:src/sample.cpp:signature-help-timeout-multi:sub'; + +const parseSignature = (detailText) => parseCppTwoIntParamSignature(detailText, { + bareNames: ['add', 'sub'], + bareReturnType: 'unknown' +}); + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: addChunkUid, + chunkId: 'chunk_signature_help_timeout_multi_add', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: addStart, end: addStart + 3 } + }, + virtualPath, + virtualRange: { start: addStart, end: addStart + 3 }, + symbolHint: { name: 'add', kind: 'function' } + }, { + chunkRef: { + docId: 0, + chunkUid: subChunkUid, + chunkId: 'chunk_signature_help_timeout_multi_sub', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: subStart, end: subStart + 3 } + }, + virtualPath, + virtualRange: { start: subStart, end: subStart + 3 }, + symbolHint: { name: 'sub', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-signature-help-two-symbols'], + parseSignature, + signatureHelpTimeoutMs: 1000, + hoverDisableAfterTimeouts: 1, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false +}); + +assert.equal( + Number(result?.hoverMetrics?.signatureHelpRequested || 0), + 1, + 'expected adaptive suppression to stop signatureHelp on the second symbol' +); +assert.equal( + Number(result?.hoverMetrics?.signatureHelpTimedOut || 0) >= 1, + true, + 'expected signatureHelp timeout metric increment' +); +assert.equal( + Number(result?.hoverMetrics?.timedOut || 0) >= 1, + true, + 'expected timeout metric increment' +); +assert.equal( + Number(result?.hoverMetrics?.skippedByGlobalDisable || 0) >= 1 + || Number(result?.hoverMetrics?.skippedByAdaptiveDisable || 0) >= 1, + true, + 'expected adaptive suppression skip metrics for subsequent symbols' +); + +console.log('LSP signatureHelp adaptive gating test passed'); diff --git a/tests/tooling/lsp/signature-parse-matrix.test.js b/tests/tooling/lsp/signature-parse-matrix.test.js new file mode 100644 index 000000000..c2c95c0e4 --- /dev/null +++ b/tests/tooling/lsp/signature-parse-matrix.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { parseElixirSignature } from '../../../src/index/tooling/signature-parse/elixir.js'; +import { parseHaskellSignature } from '../../../src/index/tooling/signature-parse/haskell.js'; +import { parsePythonSignature } from '../../../src/index/tooling/signature-parse/python.js'; +import { parseRubySignature } from '../../../src/index/tooling/signature-parse/ruby.js'; +import { parseSwiftSignature } from '../../../src/index/tooling/signature-parse/swift.js'; +import { parseZigSignature } from '../../../src/index/tooling/signature-parse/zig.js'; + +const suites = [ + { + name: 'python', + parse: parsePythonSignature, + invalid: 'python hover details without signature', + cases: [ + ['def greet(name: str) -> str:', 'str', { name: 'str' }], + [['```python', 'def greet(name: str, count: int = 1) -> str', '```'].join('\n'), 'str', { name: 'str', count: 'int' }], + ['(function) greet(name: str, count: int = 1) -> str', 'str', { name: 'str', count: 'int' }], + [['def greet(', ' name: str,', ' count: int,', ') -> str:'].join('\n'), 'str', { name: 'str', count: 'int' }], + [['@overload', 'def load(name: str) -> str: ...'].join('\n'), 'str', { name: 'str' }], + ['async def fetch(url: str, *, timeout: float | None = None) -> bytes:', 'bytes', { url: 'str', timeout: 'float | None' }], + ['def parse(items: builtins.list[str]) -> typing.Optional[builtins.int]:', 'Optional[int]', { items: 'list[str]' }], + ['def run(*args: str, **kwargs: int) -> None:', 'None', { args: 'str', kwargs: 'int' }], + ['def reorder(a: int, /, b: str, *, c: bool) -> None:', 'None', { a: 'int', b: 'str', c: 'bool' }], + ['> `def greet(name: str) -> str:`', 'str', { name: 'str' }], + [[ '> ```python', '> @cache(maxsize=128)', '> @traced', '> def greet(name: str) -> str:', '> ```' ].join('\n'), 'str', { name: 'str' }] + ] + }, + { + name: 'ruby', + parse: parseRubySignature, + invalid: 'not a ruby signature', + cases: [ + ['greet(name, title = nil) -> String', 'String', {}], + ['User#greet(name : String, title : String = nil) -> String', 'String', { name: 'String', title: 'String' }], + ['self.build(attrs: Hash, &block) => User', 'User', { attrs: 'Hash' }] + ] + }, + { + name: 'swift', + parse: parseSwiftSignature, + invalid: 'not a signature', + cases: [ + ['func greet(name: String) -> String', 'String', { name: 'String' }], + ['func process(_ value: T, using block: @escaping (T) -> Void) -> Result', 'Result', { value: 'T', block: '(T) -> Void' }], + ['func load() async throws -> [String: Int]', '[String: Int]', {}], + ['init?(rawValue: Int)', 'Self', { rawValue: 'Int' }], + ['var title: Swift.String { get }', 'String', {}], + ['render(view:)\nfunc render(view: View) -> Swift.Int', 'Int', { view: 'View' }] + ] + }, + { + name: 'haskell', + parse: parseHaskellSignature, + invalid: 'not a haskell signature', + cases: [ + ['greet :: Text -> Text', 'Text', { arg1: 'Text' }], + ['sumTwo :: Int -> Int -> Int', 'Int', { arg1: 'Int', arg2: 'Int' }], + ['mkPair :: a -> b -> (a, b)', '(a, b)', { arg1: 'a', arg2: 'b' }], + ['mapMaybe :: (a -> Maybe b) -> [a] -> [b]', '[b]', { arg1: '(a -> Maybe b)', arg2: '[a]' }], + ['liftM :: Monad m => (a -> b) -> m a -> m b', 'm b', { arg1: '(a -> b)', arg2: 'm a' }] + ] + }, + { + name: 'elixir', + parse: parseElixirSignature, + invalid: 'not an elixir signature', + cases: [ + ['greet(name :: String.t()) :: String.t()', 'String.t()', { name: 'String.t()' }], + ['sum(a :: integer(), b :: integer()) :: integer()', 'integer()', { a: 'integer()', b: 'integer()' }], + ['run(name, opts \\\\ [])', null, {}] + ] + }, + { + name: 'zig', + parse: parseZigSignature, + invalid: 'not a zig signature', + cases: [ + ['fn add(a: i32, b: i32) i32', 'i32', { a: 'i32', b: 'i32' }], + ['pub fn run(self: *Self, input: []const u8) !void', '!void', { self: '*Self', input: '[]const u8' }], + ['fn map(comptime T: type, values: []const T) []T', '[]T', { T: 'type', values: '[]const T' }] + ] + } +]; + +for (const suite of suites) { + for (const [detail, returnType, params] of suite.cases) { + const parsed = suite.parse(detail); + assert.ok(parsed, `expected ${suite.name} parser output for: ${detail}`); + assert.equal(parsed.returnType, returnType, `unexpected ${suite.name} return type for: ${detail}`); + for (const [name, expectedType] of Object.entries(params)) { + assert.equal(parsed.paramTypes?.[name], expectedType, `unexpected ${suite.name} param type for ${name} in: ${detail}`); + } + } + + const invalid = suite.parse(suite.invalid); + assert.equal(invalid, null, `expected invalid ${suite.name} detail to return null`); +} + +console.log('LSP signature parse matrix test passed'); diff --git a/tests/tooling/lsp/signature-quality-score.test.js b/tests/tooling/lsp/signature-quality-score.test.js new file mode 100644 index 000000000..d0b87cfcf --- /dev/null +++ b/tests/tooling/lsp/signature-quality-score.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + isIncompleteTypePayload, + scoreSignatureInfo +} from '../../../src/integrations/tooling/providers/lsp/hover-types.js'; + +const weak = { + signature: 'add', + returnType: 'int', + paramNames: ['a', 'b'], + paramTypes: {} +}; + +const strong = { + signature: 'int add(int a, int b)', + returnType: 'int', + paramNames: ['a', 'b'], + paramTypes: { a: 'int', b: 'int' } +}; + +const weakCompleteness = isIncompleteTypePayload(weak, { symbolKind: 12 }); +assert.equal(weakCompleteness.incomplete, true, 'expected weak payload to be incomplete'); +assert.equal(weakCompleteness.missingParamTypes, true, 'expected weak payload to miss typed params'); + +const strongCompleteness = isIncompleteTypePayload(strong, { symbolKind: 12 }); +assert.equal(strongCompleteness.incomplete, false, 'expected strong payload to be complete'); + +const weakScore = scoreSignatureInfo(weak, { symbolKind: 12 }); +const strongScore = scoreSignatureInfo(strong, { symbolKind: 12 }); +assert.equal(strongScore.total > weakScore.total, true, 'expected strong payload score to exceed weak score'); + +const ambiguous = { + signature: 'fn add(a, b) -> any', + returnType: 'any', + paramNames: ['a', 'b'], + paramTypes: { a: 'int', b: 'int' } +}; +const ambiguousCompleteness = isIncompleteTypePayload(ambiguous, { symbolKind: 12 }); +assert.equal(ambiguousCompleteness.missingReturn, true, 'expected ambiguous return to be treated as incomplete'); + +console.log('LSP signature quality score test passed'); diff --git a/tests/tooling/lsp/soft-deadline-suppresses-stage-requests.test.js b/tests/tooling/lsp/soft-deadline-suppresses-stage-requests.test.js new file mode 100644 index 000000000..768152ee5 --- /dev/null +++ b/tests/tooling/lsp/soft-deadline-suppresses-stage-requests.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-soft-deadline-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int a, int b) { return a + b; }\nint sub(int a, int b) { return a - b; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:soft-deadline.cpp'; +const addStart = docText.indexOf('add'); +const subStart = docText.indexOf('sub'); + +const result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:soft:add', + chunkId: 'chunk_soft_deadline_add', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: addStart, end: addStart + 3 } + }, + virtualPath, + virtualRange: { start: addStart, end: addStart + 3 }, + symbolHint: { name: 'add', kind: 'function' } + }, { + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/sample.cpp:soft:sub', + chunkId: 'chunk_soft_deadline_sub', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: subStart, end: subStart + 3 } + }, + virtualPath, + virtualRange: { start: subStart, end: subStart + 3 }, + symbolHint: { name: 'sub', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'stall-signature-help-two-symbols'], + parseSignature: () => null, + signatureHelpTimeoutMs: 1000, + hoverDisableAfterTimeouts: 999, + softDeadlineMs: 1000, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false +}); + +assert.equal( + Number(result?.hoverMetrics?.signatureHelpRequested || 0), + 1, + 'expected soft deadline to suppress second signatureHelp request' +); +assert.equal( + Number(result?.hoverMetrics?.skippedBySoftDeadline || 0) >= 1, + true, + 'expected soft deadline skip metric to increment' +); +assert.equal( + Array.isArray(result?.checks) && result.checks.some((entry) => entry?.name === 'tooling_soft_deadline_reached'), + true, + 'expected soft deadline check entry' +); + +console.log('LSP soft deadline suppression test passed'); diff --git a/tests/tooling/lsp/solargraph-provider-bootstrap.test.js b/tests/tooling/lsp/solargraph-provider-bootstrap.test.js new file mode 100644 index 000000000..b265516b4 --- /dev/null +++ b/tests/tooling/lsp/solargraph-provider-bootstrap.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { requireLspCommandOrSkip, withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'solargraph-provider-bootstrap', + directories: ['lib'], + files: [{ path: 'Gemfile', content: "source 'https://rubygems.org'\n" }] +}); +const fixtureSolargraphCmd = resolveLspFixtureCommand('solargraph', { repoRoot: root }); +const docText = 'def greet(name, title = nil)\n "#{title} #{name}"\nend\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'solargraph-bootstrap', + virtualPath: 'lib/app.rb', + text: docText, + languageId: 'ruby', + effectiveExt: '.rb', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + requireLspCommandOrSkip({ + providerId: 'solargraph', + cmd: fixtureSolargraphCmd, + repoRoot: tempRoot, + reason: 'Skipping solargraph bootstrap test; fixture solargraph command probe failed.' + }); + + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'solargraph', + providerConfigKey: 'solargraph', + providerConfig: { + cmd: fixtureSolargraphCmd + }, + inputs + }); + + const providerDiag = result.diagnostics?.solargraph || null; + assert.ok(providerDiag, 'expected diagnostics for solargraph provider'); + if (result.byChunkUid.has(inputs.chunkUid)) { + const hit = result.byChunkUid.get(inputs.chunkUid); + assert.equal(hit.payload?.returnType, 'String', 'expected parsed Ruby return type'); + assert.equal(hit.payload?.paramTypes?.name?.[0]?.type, 'String', 'expected parsed Ruby param type'); + } else { + const checks = Array.isArray(providerDiag?.checks) ? providerDiag.checks : []; + assert.equal( + checks.length > 0 || Boolean(providerDiag?.runtime), + true, + 'expected diagnostics metadata when solargraph did not enrich' + ); + } +}); + +console.log('solargraph provider bootstrap test passed'); diff --git a/tests/tooling/lsp/solargraph-provider-gemfile-lock-missing-preflight.test.js b/tests/tooling/lsp/solargraph-provider-gemfile-lock-missing-preflight.test.js new file mode 100644 index 000000000..80b407889 --- /dev/null +++ b/tests/tooling/lsp/solargraph-provider-gemfile-lock-missing-preflight.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { requireLspCommandOrSkip, withLspTestPath } from '../../helpers/lsp-runtime.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'solargraph-provider-gemfile-lock-missing-preflight', + directories: ['lib'], + files: [{ path: 'Gemfile', content: "source 'https://rubygems.org'\n" }] +}); +const fixtureSolargraphCmd = resolveLspFixtureCommand('solargraph', { repoRoot: root }); +const docText = 'def greet(name)\n name\nend\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'solargraph-gemfile-lock-missing-preflight', + virtualPath: 'lib/app.rb', + text: docText, + languageId: 'ruby', + effectiveExt: '.rb', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root }, async () => { + requireLspCommandOrSkip({ + providerId: 'solargraph', + cmd: fixtureSolargraphCmd, + repoRoot: tempRoot, + reason: 'Skipping solargraph Gemfile.lock preflight test; fixture solargraph command probe failed.' + }); + + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'solargraph', + providerConfigKey: 'solargraph', + providerConfig: { + cmd: fixtureSolargraphCmd + }, + inputs + }); + + const diagnostics = result.diagnostics?.solargraph || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected solargraph preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'solargraph_workspace_gemfile_lock_missing', + 'expected solargraph Gemfile.lock-missing reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'solargraph_workspace_gemfile_lock_missing'), + true, + 'expected solargraph Gemfile.lock-missing warning check' + ); +}); + +console.log('solargraph provider Gemfile.lock missing preflight test passed'); diff --git a/tests/tooling/lsp/solargraph-provider-runtime-toolchain-missing-bundle-preflight.test.js b/tests/tooling/lsp/solargraph-provider-runtime-toolchain-missing-bundle-preflight.test.js new file mode 100644 index 000000000..4ebcfa5a3 --- /dev/null +++ b/tests/tooling/lsp/solargraph-provider-runtime-toolchain-missing-bundle-preflight.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + buildSingleSymbolInputs, + createLspProviderTempRepo, + resolveLspFixtureCommand, + runDedicatedProviderFixture +} from '../../helpers/lsp-provider-fixture.js'; +import { withLspTestPath } from '../../helpers/lsp-runtime.js'; +import { writeRuntimeCommandFixture } from '../../helpers/runtime-command-fixture.js'; + +const root = process.cwd(); +const tempRoot = await createLspProviderTempRepo({ + repoRoot: root, + name: 'solargraph-provider-runtime-toolchain-missing-bundle-preflight', + directories: ['lib', '.runtime-bin'], + files: [ + { path: 'Gemfile', content: "source 'https://rubygems.org'\n" }, + { path: 'Gemfile.lock', content: 'GEM\n specs:\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n\n' } + ] +}); +const runtimeBinDir = path.join(tempRoot, '.runtime-bin'); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'ruby', + stdout: 'ruby 3.3.0p0 (2024-01-01 revision 000000) [x64-mingw32]\n' +}); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'gem', + stdout: '3.5.0\n' +}); +await writeRuntimeCommandFixture({ + binDir: runtimeBinDir, + name: 'bundle', + stderr: 'bundle: command not found\n', + exitCode: 127 +}); + +const fixtureSolargraphCmd = resolveLspFixtureCommand('solargraph', { repoRoot: root }); +const docText = 'def greet(name)\n name\nend\n'; +const inputs = buildSingleSymbolInputs({ + scenarioName: 'solargraph-runtime-toolchain-missing-bundle-preflight', + virtualPath: 'lib/app.rb', + text: docText, + languageId: 'ruby', + effectiveExt: '.rb', + symbolName: 'greet' +}); + +await withLspTestPath({ repoRoot: root, extraPrepend: [runtimeBinDir] }, async () => { + const result = await runDedicatedProviderFixture({ + tempRoot, + providerId: 'solargraph', + providerConfigKey: 'solargraph', + providerConfig: { + cmd: fixtureSolargraphCmd + }, + inputs + }); + + const diagnostics = result.diagnostics?.solargraph || {}; + assert.equal(diagnostics?.preflight?.state, 'degraded', 'expected solargraph preflight degraded state'); + assert.equal( + diagnostics?.preflight?.reasonCode, + 'solargraph_runtime_toolchain_missing_bundle', + 'expected solargraph runtime toolchain missing bundle reason code' + ); + const checks = Array.isArray(diagnostics?.checks) ? diagnostics.checks : []; + assert.equal( + checks.some((check) => check?.name === 'solargraph_runtime_toolchain_missing_bundle'), + true, + 'expected solargraph runtime toolchain missing bundle warning check' + ); +}); + +console.log('solargraph provider runtime toolchain missing bundle preflight test passed'); diff --git a/tests/tooling/lsp/source-bootstrap-incomplete-still-requests.test.js b/tests/tooling/lsp/source-bootstrap-incomplete-still-requests.test.js new file mode 100644 index 000000000..4927d43d1 --- /dev/null +++ b/tests/tooling/lsp/source-bootstrap-incomplete-still-requests.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'lsp-source-bootstrap-incomplete-still-requests'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'int add(int, int) { return 0; }\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:source-bootstrap-incomplete.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:source-bootstrap-incomplete'; + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (!detail) return null; + if (detail === 'add') { + return { + signature: detail, + returnType: 'unknown', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + if (detail === 'int add(int, int)') { + return { + signature: detail, + returnType: 'int', + paramTypes: {}, + paramNames: ['a', 'b'] + }; + } + if (detail === 'int add(int a, int b)') { + return { + signature: detail, + returnType: 'int', + paramTypes: { + a: 'int', + b: 'int' + }, + paramNames: ['a', 'b'] + }; + } + return null; +}; + +let result = null; +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_source_bootstrap_incomplete', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'signature-help'], + parseSignature + }); +}); + +const payload = result.byChunkUid?.[chunkUid]?.payload || null; +assert.ok(payload, 'expected payload for chunk'); +assert.equal(payload.returnType, 'int', 'expected complete return type'); +assert.deepEqual(payload.paramTypes?.a?.map((entry) => entry.type), ['int']); +assert.deepEqual(payload.paramTypes?.b?.map((entry) => entry.type), ['int']); +assert.equal( + Number(result?.hoverMetrics?.sourceBootstrapUsed || 0) >= 1, + true, + 'expected incomplete source signature to be merged before later stages' +); +assert.equal( + Number(result?.hoverMetrics?.signatureHelpRequested || 0) >= 1, + true, + 'expected signatureHelp request when source bootstrap remains incomplete' +); +assert.equal( + Number(result?.hoverMetrics?.signatureHelpSucceeded || 0) >= 1, + true, + 'expected signatureHelp to complete the payload' +); + +const traceLines = await parseJsonLinesFile(tracePath); +const signatureHelpRequests = traceLines.filter( + (entry) => entry.kind === 'request' && entry.method === 'textDocument/signatureHelp' +).length; +assert.equal(signatureHelpRequests >= 1, true, 'expected signatureHelp request trace entry'); + +console.log('LSP source bootstrap incomplete path test passed'); diff --git a/tests/tooling/lsp/sourcekit-candidate-ordering.test.js b/tests/tooling/lsp/sourcekit-candidate-ordering.test.js new file mode 100644 index 000000000..3facecaf1 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-candidate-ordering.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + compareSourcekitCandidatePriority, + scoreSourcekitCandidate +} from '../../../src/index/tooling/sourcekit-provider.js'; + +const makeEntry = (candidate, index) => ({ + candidate, + index, + score: scoreSourcekitCandidate(candidate) +}); + +const candidates = [ + makeEntry('/z/toolchains/sourcekit-lsp', 0), + makeEntry('/opt/toolchains/sourcekit-lsp-preview', 1), + makeEntry('/opt/toolchains/sourcekit-lsp+asserts', 2), + makeEntry('/a/toolchains/sourcekit-lsp', 3) +]; + +const sorted = candidates + .slice() + .sort(compareSourcekitCandidatePriority) + .map((entry) => entry.candidate); +assert.deepEqual(sorted, [ + '/z/toolchains/sourcekit-lsp', + '/a/toolchains/sourcekit-lsp', + '/opt/toolchains/sourcekit-lsp-preview', + '/opt/toolchains/sourcekit-lsp+asserts' +], 'expected deterministic stable-first ordering that preserves discovery priority'); + +const alternateInputOrder = [ + candidates[3], + candidates[0], + candidates[2], + candidates[1] +]; +const alternateSorted = alternateInputOrder + .slice() + .sort(compareSourcekitCandidatePriority) + .map((entry) => entry.candidate); +assert.deepEqual( + alternateSorted, + sorted, + 'expected candidate ordering to remain deterministic regardless of discovery order' +); + +console.log('sourcekit candidate ordering test passed'); diff --git a/tests/tooling/lsp/sourcekit-degraded-startup-suppresses-semantic-tokens.test.js b/tests/tooling/lsp/sourcekit-degraded-startup-suppresses-semantic-tokens.test.js new file mode 100644 index 000000000..9a2e1f24e --- /dev/null +++ b/tests/tooling/lsp/sourcekit-degraded-startup-suppresses-semantic-tokens.test.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { resolveSourcekitPreflightLockPath } from '../../../src/index/tooling/sourcekit-provider.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-degraded-startup-suppresses-semantic-tokens', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); +const logs = []; +const { ctx } = fixture.contextFor(logs); +const preflightLockPath = resolveSourcekitPreflightLockPath(ctx.repoRoot); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(fixture.tempRoot, 'stub-launcher.js'); +const modePath = path.join(fixture.tempRoot, 'mode.txt'); +const tracePath = path.join(fixture.tempRoot, 'trace.jsonl'); + +await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'sourcekit';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' +); +await fs.writeFile(modePath, 'all-capabilities', 'utf8'); + +const document = { + virtualPath: 'src/one.swift', + effectiveExt: '.swift', + languageId: 'swift', + text: 'func add(a: Int, b: Int) -> Int { return a + b }\n', + docHash: 'doc-sourcekit-degraded-startup', + containerPath: 'src/one.swift' +}; +const target = { + virtualPath: 'src/one.swift', + languageId: 'swift', + chunkRef: { + chunkUid: 'ck:test:sourcekit:degraded-startup', + chunkId: 'chunk_sourcekit_degraded_startup', + file: 'src/one.swift', + start: 0, + end: document.text.length + }, + virtualRange: { + start: 0, + end: document.text.length + }, + symbolHint: { + name: 'add', + kind: 'function' + } +}; + +let heldLock = null; +try { + heldLock = await acquireFileLock({ + lockPath: preflightLockPath, + waitMs: 0, + pollMs: 25, + staleMs: 5 * 60 * 1000, + forceStaleCleanup: true, + metadata: { scope: 'sourcekit-degraded-startup-test' } + }); + assert.ok(heldLock, 'expected test to acquire sourcekit preflight lock'); + + await withSourcekitPreflightProvider({ + fixture, + logs, + env: { POC_LSP_TRACE: tracePath }, + context: { ctx, document, target } + }, async ({ provider }) => { + const output = await provider.run({ + ...ctx, + toolingConfig: { + sourcekit: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + preflightFailOpen: true, + preflightLockWaitMs: 0, + preflightLockPollMs: 25, + hoverEnabled: true, + hoverTimeoutMs: 150, + timeoutMs: 500, + retries: 0, + breakerThreshold: 1, + hostConcurrencyGate: false + } + } + }, { + documents: [document], + targets: [target] + }); + + assert.equal(Boolean(output?.byChunkUid?.[target.chunkRef.chunkUid]), true, 'expected sourcekit to preserve partial success under degraded startup'); + assert.equal(output?.diagnostics?.preflight?.reasonCode, 'sourcekit_preflight_lock_unavailable', 'expected degraded startup reason code to be preserved'); + assert.equal(output?.diagnostics?.fidelity?.state, 'degraded', 'expected fidelity contract to classify degraded startup'); + assert.equal(output?.diagnostics?.fidelity?.preflight?.workspaceKind, 'package_managed_workspace', 'expected fidelity contract to preserve workspace kind'); + assert.equal(output?.diagnostics?.fidelity?.preflight?.dependencyState, 'required', 'expected fidelity contract to preserve dependency state'); + assert.equal(output?.diagnostics?.fidelity?.qualityDelta?.partialSuccess, true, 'expected degraded startup to report truthful partial success'); + assert.equal(output?.diagnostics?.fidelity?.semanticCoverage?.state, 'partial', 'expected degraded startup semantic coverage to be partial'); + assert.equal(output?.diagnostics?.admission?.startupMode, 'weak_startup', 'expected explicit weak-startup admission mode'); + assert.equal( + Array.isArray(output?.diagnostics?.admission?.admittedRequestClasses) + && output.diagnostics.admission.admittedRequestClasses.includes('hover'), + true, + 'expected weak-startup admission policy to preserve hover coverage' + ); + assert.equal( + output?.diagnostics?.fidelity?.requestSuppression?.active, + true, + 'expected degraded startup request suppression to be explicit' + ); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.requestSuppression?.suppressedRequestClasses) + && output.diagnostics.fidelity.requestSuppression.suppressedRequestClasses.includes('semanticTokens') + && output.diagnostics.fidelity.requestSuppression.suppressedRequestClasses.includes('signatureHelp') + && output.diagnostics.fidelity.requestSuppression.suppressedRequestClasses.includes('inlayHints'), + true, + 'expected degraded startup request suppression to record the full weak-startup request policy' + ); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.skipped) + && output.diagnostics.fidelity.skipped.includes('semanticTokens') + && output.diagnostics.fidelity.skipped.includes('signatureHelp') + && output.diagnostics.fidelity.skipped.includes('inlayHints'), + true, + 'expected fidelity contract to record all suppressed weak-startup request classes' + ); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.runtimeIssues) + && output.diagnostics.fidelity.runtimeIssues.includes('package_preflight_lock_unavailable') + && output.diagnostics.fidelity.runtimeIssues.includes('weak_startup_semantic_tokens_suppressed') + && output.diagnostics.fidelity.runtimeIssues.includes('weak_startup_request_suppression'), + true, + 'expected fidelity contract to expose specific degraded-startup issue classes' + ); + assert.equal(output?.diagnostics?.runtime?.hoverMetrics?.semanticTokensTimedOut ?? 0, 0, 'expected semantic token timeout to be suppressed under degraded startup'); + assert.equal( + Array.isArray(output?.diagnostics?.checks) + && output.diagnostics.checks.some((check) => check?.name === 'sourcekit_semantic_tokens_suppressed_weak_startup'), + true, + 'expected degraded-startup semantic token suppression check' + ); + assert.equal( + Array.isArray(output?.diagnostics?.checks) + && output.diagnostics.checks.some((check) => check?.name === 'sourcekit_weak_startup_request_suppression'), + true, + 'expected explicit weak-startup request-suppression check' + ); + + const events = await parseJsonLinesFile(tracePath); + const hoverRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/hover').length; + const semanticTokenRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/semanticTokens/full').length; + const signatureHelpRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/signatureHelp').length; + const inlayHintRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/inlayHint').length; + assert.equal(Number.isFinite(hoverRequests), true, 'expected weak-startup trace parsing to remain valid'); + assert.equal(semanticTokenRequests, 0, 'expected weak-startup policy to suppress semantic token requests'); + assert.equal(signatureHelpRequests, 0, 'expected weak-startup policy to suppress signature-help requests'); + assert.equal(inlayHintRequests, 0, 'expected weak-startup policy to suppress inlay-hint requests'); + }); +} finally { + if (heldLock?.release) { + await heldLock.release(); + } + await fixture.restorePath(); + const cleanup = await removePathWithRetry(fixture.tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} + +console.log('sourcekit degraded startup suppresses semantic tokens test passed'); diff --git a/tests/tooling/lsp/sourcekit-mixed-workspace-optional-dependencies.test.js b/tests/tooling/lsp/sourcekit-mixed-workspace-optional-dependencies.test.js new file mode 100644 index 000000000..0f4b4e2aa --- /dev/null +++ b/tests/tooling/lsp/sourcekit-mixed-workspace-optional-dependencies.test.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { countNonEmptyLines, parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-mixed-workspace-optional-dependencies', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 7, + resolveStderr: 'forced mixed-workspace resolve failure' +}); +const logs = []; +const { ctx, document, target } = fixture.contextFor(logs); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(fixture.tempRoot, 'stub-launcher.js'); +const modePath = path.join(fixture.tempRoot, 'mode.txt'); +const tracePath = path.join(fixture.tempRoot, 'trace.jsonl'); + +await fs.mkdir(path.join(fixture.tempRoot, 'Demo.xcodeproj'), { recursive: true }); +await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'sourcekit';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' +); +await fs.writeFile(modePath, 'all-capabilities', 'utf8'); + +try { + await withSourcekitPreflightProvider({ + fixture, + logs, + env: { POC_LSP_TRACE: tracePath }, + context: { ctx, document, target } + }, async ({ provider }) => { + const output = await provider.run({ + ...ctx, + toolingConfig: { + sourcekit: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + hoverEnabled: true, + hoverTimeoutMs: 150, + timeoutMs: 500, + retries: 0, + breakerThreshold: 1, + hostConcurrencyGate: false + } + } + }, { + documents: [document], + targets: [target] + }); + + assert.equal(output?.diagnostics?.preflight?.workspaceKind, 'mixed_workspace'); + assert.equal(output?.diagnostics?.preflight?.dependencyState, 'optional'); + assert.equal(output?.diagnostics?.preflight?.preflightState, 'ready'); + assert.equal(output?.diagnostics?.preflight?.reasonCode, 'sourcekit_mixed_workspace_dependencies_optional'); + assert.equal( + Array.isArray(output?.diagnostics?.checks) + && output.diagnostics.checks.some((check) => check?.name === 'sourcekit_package_preflight_failed'), + false, + 'expected mixed workspace not to fail closed on skipped SwiftPM dependency resolution' + ); + + const count = await countNonEmptyLines(fixture.counterPath); + assert.equal(count, 0, 'expected no swift package resolve invocation for mixed workspaces'); + const events = await parseJsonLinesFile(tracePath); + const runtimeRequests = events.filter((entry) => entry.kind === 'request' && entry.method !== 'initialize'); + assert.equal(runtimeRequests.length > 0, true, 'expected mixed Swift workspace to continue issuing SourceKit requests after optional dependency preflight'); + assert.equal( + logs.some((line) => line.includes('sourcekit package preflight: running')), + false, + 'expected mixed workspace dependencies to remain optional and skip package resolve' + ); + }); +} finally { + await fixture.restorePath(); + const cleanup = await removePathWithRetry(fixture.tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} + +console.log('sourcekit mixed workspace optional dependencies test passed'); diff --git a/tests/tooling/lsp/sourcekit-package-preflight-abort.test.js b/tests/tooling/lsp/sourcekit-package-preflight-abort.test.js index 1c413267a..622022a71 100644 --- a/tests/tooling/lsp/sourcekit-package-preflight-abort.test.js +++ b/tests/tooling/lsp/sourcekit-package-preflight-abort.test.js @@ -6,10 +6,10 @@ import { fileURLToPath } from 'node:url'; import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { prependLspTestPath } from '../../helpers/lsp-runtime.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const tempRoot = resolveTestCachePath(root, 'sourcekit-package-preflight-abort'); -const fixtureBinDir = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); const binDir = path.join(tempRoot, 'bin'); const swiftCmdPath = path.join(binDir, 'swift.cmd'); const swiftPosixPath = path.join(binDir, 'swift'); @@ -82,8 +82,10 @@ try { await fs.chmod(swiftPosixPath, 0o755); } catch {} -const originalPath = process.env.PATH; -process.env.PATH = [binDir, fixtureBinDir, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter); +const restorePath = prependLspTestPath({ + repoRoot: root, + extraPrepend: [binDir, path.dirname(process.execPath)] +}); try { registerDefaultToolingProviders(); @@ -91,49 +93,50 @@ try { assert.ok(provider, 'expected sourcekit provider'); const abortController = new AbortController(); - setTimeout(() => abortController.abort(new Error('abort sourcekit preflight run')), 75); + const abortTimer = setTimeout(() => abortController.abort(new Error('abort sourcekit preflight run')), 75); - const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: () => {}, - strict: true, - abortSignal: abortController.signal - }; - const document = { - virtualPath: 'src/one.swift', - effectiveExt: '.swift', - languageId: 'swift', - text: 'func alpha() -> Int { return 1 }\n', - docHash: 'doc-1', - containerPath: 'src/one.swift' - }; - const target = { - virtualPath: 'src/one.swift', - languageId: 'swift', - chunkRef: { - chunkUid: 'ck:test:sourcekit:preflight-abort:1', - file: 'src/one.swift', - start: 0, - end: 12 - } - }; + try { + const ctx = { + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: {}, + logger: () => {}, + strict: true, + abortSignal: abortController.signal + }; + const document = { + virtualPath: 'src/one.swift', + effectiveExt: '.swift', + languageId: 'swift', + text: 'func alpha() -> Int { return 1 }\n', + docHash: 'doc-1', + containerPath: 'src/one.swift' + }; + const target = { + virtualPath: 'src/one.swift', + languageId: 'swift', + chunkRef: { + chunkUid: 'ck:test:sourcekit:preflight-abort:1', + file: 'src/one.swift', + start: 0, + end: 12 + } + }; - const startedAtMs = Date.now(); - await assert.rejects( - () => provider.run(ctx, { documents: [document], targets: [target] }), - (err) => err?.code === 'ABORT_ERR', - 'expected sourcekit run to abort while preflight is in progress' - ); - const elapsedMs = Date.now() - startedAtMs; - assert.ok(elapsedMs < 2000, `expected sourcekit preflight abort to short-circuit promptly (elapsed=${elapsedMs}ms)`); -} finally { - if (originalPath == null) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; + const startedAtMs = Date.now(); + await assert.rejects( + () => provider.run(ctx, { documents: [document], targets: [target] }), + (err) => err?.code === 'ABORT_ERR', + 'expected sourcekit run to abort while preflight is in progress' + ); + const elapsedMs = Date.now() - startedAtMs; + assert.ok(elapsedMs < 2000, `expected sourcekit preflight abort to short-circuit promptly (elapsed=${elapsedMs}ms)`); + } finally { + clearTimeout(abortTimer); } +} finally { + await restorePath(); } console.log('sourcekit package preflight abort test passed'); + diff --git a/tests/tooling/lsp/sourcekit-package-preflight-cache-resolved-invalidation.test.js b/tests/tooling/lsp/sourcekit-package-preflight-cache-resolved-invalidation.test.js new file mode 100644 index 000000000..38d224a28 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-package-preflight-cache-resolved-invalidation.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-cache-resolved-invalidation', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); +const logs = []; + +try { + await withSourcekitPreflightProvider({ fixture, logs }, async ({ provider, ctx, document, target }) => { + const first = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(first && typeof first.byChunkUid === 'object', 'expected first sourcekit run output'); + const firstCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(firstCount, 1, 'expected sourcekit preflight to run once on first pass'); + + const second = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(second && typeof second.byChunkUid === 'object', 'expected second sourcekit run output'); + const secondCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(secondCount, 1, 'expected sourcekit package preflight cache to hit on second pass'); + + await fs.writeFile( + path.join(fixture.tempRoot, 'Package.resolved'), + JSON.stringify({ pins: [{ identity: 'demo', version: '1.2.3' }] }, null, 2), + 'utf8' + ); + + const third = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(third && typeof third.byChunkUid === 'object', 'expected third sourcekit run output'); + const thirdCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(thirdCount, 2, 'expected Package.resolved change to invalidate sourcekit preflight cache'); + + assert.ok( + logs.some((line) => line.includes('sourcekit package preflight cache hit')), + 'expected sourcekit preflight cache hit log before resolved invalidation' + ); + }); +} finally { + await fixture.restorePath(); +} + +console.log('sourcekit package preflight cache Package.resolved invalidation test passed'); diff --git a/tests/tooling/lsp/sourcekit-package-preflight-cache.test.js b/tests/tooling/lsp/sourcekit-package-preflight-cache.test.js index 6288d55e9..de0b542c4 100644 --- a/tests/tooling/lsp/sourcekit-package-preflight-cache.test.js +++ b/tests/tooling/lsp/sourcekit-package-preflight-cache.test.js @@ -3,181 +3,55 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'sourcekit-package-preflight-cache'); -const fixtureBinDir = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); -const markerPath = path.join(tempRoot, '.build', 'pairofcleats', 'sourcekit-package-preflight.json'); -const counterPath = path.join(tempRoot, 'swift-preflight.counter'); -const binDir = path.join(tempRoot, 'bin'); -const swiftCmdPath = path.join(binDir, 'swift.cmd'); -const swiftPosixPath = path.join(binDir, 'swift'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.mkdir(binDir, { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'src', 'one.swift'), 'func alpha() -> Int { return 1 }\n', 'utf8'); -await fs.writeFile( - path.join(tempRoot, 'Package.swift'), - [ - '// swift-tools-version: 6.0', - 'import PackageDescription', - 'let package = Package(', - ' name: "Sample",', - ' dependencies: [', - ' .package(url: "https://example.com/demo.git", from: "1.0.0")', - ' ],', - ' targets: [', - ' .target(name: "Sample")', - ' ]', - ')', - '' - ].join('\n'), - 'utf8' -); -await fs.writeFile( - swiftCmdPath, - [ - '@echo off', - 'if "%1"=="--version" (', - ' echo Swift stub', - ' exit /b 0', - ')', - 'if "%1"=="--help" (', - ' echo Swift stub help', - ' exit /b 0', - ')', - 'if "%1"=="package" if "%2"=="resolve" (', - ' if not "%POC_SWIFT_PREFLIGHT_COUNTER%"=="" echo resolve>>"%POC_SWIFT_PREFLIGHT_COUNTER%"', - ' exit /b 0', - ')', - 'exit /b 1', - '' - ].join('\r\n'), - 'utf8' -); -await fs.writeFile( - swiftPosixPath, - [ - '#!/usr/bin/env sh', - 'if [ "$1" = "--version" ]; then', - ' echo "Swift stub"', - ' exit 0', - 'fi', - 'if [ "$1" = "--help" ]; then', - ' echo "Swift stub help"', - ' exit 0', - 'fi', - 'if [ "$1" = "package" ] && [ "$2" = "resolve" ]; then', - ' if [ -n "$POC_SWIFT_PREFLIGHT_COUNTER" ]; then', - ' printf "resolve\\n" >> "$POC_SWIFT_PREFLIGHT_COUNTER"', - ' fi', - ' exit 0', - 'fi', - 'exit 1', - '' - ].join('\n'), - 'utf8' -); -try { - await fs.chmod(swiftPosixPath, 0o755); -} catch {} - -const originalPath = process.env.PATH; -const originalCounter = process.env.POC_SWIFT_PREFLIGHT_COUNTER; +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-cache', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); const logs = []; -process.env.PATH = [binDir, fixtureBinDir, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter); -process.env.POC_SWIFT_PREFLIGHT_COUNTER = counterPath; - try { - registerDefaultToolingProviders(); - const provider = getToolingProvider('sourcekit'); - assert.ok(provider, 'expected sourcekit provider'); - - const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: (line) => logs.push(String(line || '')), - strict: true - }; - const document = { - virtualPath: 'src/one.swift', - effectiveExt: '.swift', - languageId: 'swift', - text: 'func alpha() -> Int { return 1 }\n', - docHash: 'doc-1', - containerPath: 'src/one.swift' - }; - const target = { - virtualPath: 'src/one.swift', - languageId: 'swift', - chunkRef: { - chunkUid: 'ck:test:sourcekit:preflight-cache:1', - file: 'src/one.swift', - start: 0, - end: 12 - } - }; - - const first = await provider.run(ctx, { documents: [document], targets: [target] }); - assert.ok(first && typeof first.byChunkUid === 'object', 'expected first sourcekit run output'); - - const counterAfterFirst = await fs.readFile(counterPath, 'utf8'); - const firstCount = counterAfterFirst.split(/\r?\n/).filter(Boolean).length; - assert.equal(firstCount, 1, 'expected swift package preflight to run exactly once on first pass'); - - const second = await provider.run(ctx, { documents: [document], targets: [target] }); - assert.ok(second && typeof second.byChunkUid === 'object', 'expected second sourcekit run output'); - - const counterAfterSecond = await fs.readFile(counterPath, 'utf8'); - const secondCount = counterAfterSecond.split(/\r?\n/).filter(Boolean).length; - assert.equal(secondCount, 1, 'expected sourcekit package preflight cache to skip repeated resolve'); - - await fs.writeFile( - path.join(tempRoot, 'Package.swift'), - [ - '// swift-tools-version: 6.0', - 'import PackageDescription', - 'let package = Package(', - ' name: "Sample",', - ' dependencies: [', - ' .package(url: "https://example.com/demo.git", from: "1.1.0")', - ' ],', - ' targets: [', - ' .target(name: "Sample")', - ' ]', - ')', - '' - ].join('\n'), - 'utf8' - ); - const third = await provider.run(ctx, { documents: [document], targets: [target] }); - assert.ok(third && typeof third.byChunkUid === 'object', 'expected third sourcekit run output'); - const counterAfterThird = await fs.readFile(counterPath, 'utf8'); - const thirdCount = counterAfterThird.split(/\r?\n/).filter(Boolean).length; - assert.equal(thirdCount, 2, 'expected manifest change to invalidate preflight cache'); - - await fs.access(markerPath); - assert.ok( - logs.some((line) => line.includes('sourcekit package preflight cache hit')), - 'expected cache-hit log after repeated run' - ); + await withSourcekitPreflightProvider({ fixture, logs }, async ({ provider, ctx, document, target }) => { + const first = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(first && typeof first.byChunkUid === 'object', 'expected first sourcekit run output'); + assert.equal(first?.diagnostics?.preflight?.workspaceKind, 'package_managed_workspace'); + assert.equal(first?.diagnostics?.preflight?.dependencyState, 'required'); + assert.equal(first?.diagnostics?.preflight?.cached, false); + + const firstCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(firstCount, 1, 'expected swift package preflight to run exactly once on first pass'); + + const second = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(second && typeof second.byChunkUid === 'object', 'expected second sourcekit run output'); + assert.equal(second?.diagnostics?.preflight?.cached, true, 'expected second run to reuse cached preflight outcome'); + + const secondCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(secondCount, 1, 'expected sourcekit package preflight cache to skip repeated resolve'); + + await fixture.writePackage({ dependencyVersion: '1.1.0', includeDependencies: true }); + const third = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(third && typeof third.byChunkUid === 'object', 'expected third sourcekit run output'); + const thirdCount = await countNonEmptyLines(fixture.counterPath); + assert.equal(thirdCount, 2, 'expected manifest change to invalidate preflight cache'); + + await fs.access(fixture.markerPath); + assert.ok( + logs.some((line) => line.includes('sourcekit package preflight cache hit')), + 'expected cache-hit log after repeated run' + ); + }); } finally { - if (originalPath == null) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalCounter == null) { - delete process.env.POC_SWIFT_PREFLIGHT_COUNTER; - } else { - process.env.POC_SWIFT_PREFLIGHT_COUNTER = originalCounter; - } + await fixture.restorePath(); } console.log('sourcekit package preflight cache test passed'); + diff --git a/tests/tooling/lsp/sourcekit-package-preflight-failure.test.js b/tests/tooling/lsp/sourcekit-package-preflight-failure.test.js index 01493f972..1105eff7a 100644 --- a/tests/tooling/lsp/sourcekit-package-preflight-failure.test.js +++ b/tests/tooling/lsp/sourcekit-package-preflight-failure.test.js @@ -1,155 +1,57 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'sourcekit-package-preflight-failure'); -const fixtureBinDir = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); -const counterPath = path.join(tempRoot, 'swift-preflight.counter'); -const binDir = path.join(tempRoot, 'bin'); -const swiftCmdPath = path.join(binDir, 'swift.cmd'); -const swiftPosixPath = path.join(binDir, 'swift'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.mkdir(binDir, { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'src', 'one.swift'), 'func alpha() -> Int { return 1 }\n', 'utf8'); -await fs.writeFile( - path.join(tempRoot, 'Package.swift'), - [ - '// swift-tools-version: 6.0', - 'import PackageDescription', - 'let package = Package(', - ' name: "Sample",', - ' dependencies: [', - ' .package(url: "https://example.com/demo.git", from: "1.0.0")', - ' ],', - ' targets: [', - ' .target(name: "Sample")', - ' ]', - ')', - '' - ].join('\n'), - 'utf8' -); -await fs.writeFile( - swiftCmdPath, - [ - '@echo off', - 'if "%1"=="--version" (', - ' echo Swift stub', - ' exit /b 0', - ')', - 'if "%1"=="--help" (', - ' echo Swift stub help', - ' exit /b 0', - ')', - 'if "%1"=="package" if "%2"=="resolve" (', - ' if not "%POC_SWIFT_PREFLIGHT_COUNTER%"=="" echo resolve>>"%POC_SWIFT_PREFLIGHT_COUNTER%"', - ' echo forced preflight failure 1>&2', - ' exit /b 7', - ')', - 'exit /b 1', - '' - ].join('\r\n'), - 'utf8' -); -await fs.writeFile( - swiftPosixPath, - [ - '#!/usr/bin/env sh', - 'if [ "$1" = "--version" ]; then', - ' echo "Swift stub"', - ' exit 0', - 'fi', - 'if [ "$1" = "--help" ]; then', - ' echo "Swift stub help"', - ' exit 0', - 'fi', - 'if [ "$1" = "package" ] && [ "$2" = "resolve" ]; then', - ' if [ -n "$POC_SWIFT_PREFLIGHT_COUNTER" ]; then', - ' printf "resolve\\n" >> "$POC_SWIFT_PREFLIGHT_COUNTER"', - ' fi', - ' echo "forced preflight failure" 1>&2', - ' exit 7', - 'fi', - 'exit 1', - '' - ].join('\n'), - 'utf8' -); -try { - await fs.chmod(swiftPosixPath, 0o755); -} catch {} - -const originalPath = process.env.PATH; -const originalCounter = process.env.POC_SWIFT_PREFLIGHT_COUNTER; +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-failure', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 7, + resolveStderr: 'forced preflight failure' +}); const logs = []; -process.env.PATH = [binDir, fixtureBinDir, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter); -process.env.POC_SWIFT_PREFLIGHT_COUNTER = counterPath; - try { - registerDefaultToolingProviders(); - const provider = getToolingProvider('sourcekit'); - assert.ok(provider, 'expected sourcekit provider'); - - const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: (line) => logs.push(String(line || '')), - strict: true - }; - const document = { - virtualPath: 'src/one.swift', - effectiveExt: '.swift', - languageId: 'swift', - text: 'func alpha() -> Int { return 1 }\n', - docHash: 'doc-1', - containerPath: 'src/one.swift' - }; - const target = { - virtualPath: 'src/one.swift', - languageId: 'swift', - chunkRef: { - chunkUid: 'ck:test:sourcekit:preflight-failure:1', - file: 'src/one.swift', - start: 0, - end: 12 - } - }; - - const output = await provider.run(ctx, { documents: [document], targets: [target] }); - assert.deepEqual(output.byChunkUid || {}, {}, 'expected sourcekit to skip enrichment after preflight failure'); - const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; - assert.ok( - checks.some((check) => check?.name === 'sourcekit_package_preflight_failed'), - 'expected sourcekit preflight failure check in diagnostics' - ); - assert.ok( - logs.some((line) => line.includes('sourcekit skipped because package preflight did not complete safely')), - 'expected sourcekit skip log after preflight failure' - ); - const counterAfterRun = await fs.readFile(counterPath, 'utf8'); - const count = counterAfterRun.split(/\r?\n/).filter(Boolean).length; - assert.equal(count, 1, 'expected one preflight resolve attempt'); + await withSourcekitPreflightProvider({ fixture, logs }, async ({ provider, ctx, document, target }) => { + const output = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.deepEqual(output.byChunkUid || {}, {}, 'expected sourcekit to skip enrichment after preflight failure'); + const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; + assert.ok( + checks.some((check) => check?.name === 'sourcekit_package_preflight_failed'), + 'expected sourcekit preflight failure check in diagnostics' + ); + assert.equal(output?.diagnostics?.preflight?.workspaceKind, 'package_managed_workspace'); + assert.equal(output?.diagnostics?.preflight?.preflightState, 'blocked_dependency'); + assert.equal(output?.diagnostics?.preflight?.reasonCode, 'sourcekit_blocked_dependency'); + assert.equal(output?.diagnostics?.admission?.startupMode, 'dependency_blocked', 'expected explicit dependency-blocked admission mode'); + assert.equal(output?.diagnostics?.fidelity?.preflight?.workspaceKind, 'package_managed_workspace'); + assert.equal(output?.diagnostics?.fidelity?.preflight?.dependencyState, 'required'); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.runtimeIssues) + && output.diagnostics.fidelity.runtimeIssues.includes('package_resolution_blocked') + && output.diagnostics.fidelity.runtimeIssues.includes('dependency_resolution_required'), + true, + 'expected fidelity contract to preserve blocked dependency classification' + ); + assert.equal( + logs.some((line) => line.includes('sourcekit skipped because package preflight did not complete safely')), + true, + 'expected sourcekit preflight failure to fail closed and skip provider execution' + ); + const count = await countNonEmptyLines(fixture.counterPath); + assert.equal(count, 1, 'expected one preflight resolve attempt'); + }); } finally { - if (originalPath == null) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalCounter == null) { - delete process.env.POC_SWIFT_PREFLIGHT_COUNTER; - } else { - process.env.POC_SWIFT_PREFLIGHT_COUNTER = originalCounter; - } + await fixture.restorePath(); } console.log('sourcekit package preflight failure test passed'); + diff --git a/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable-fail-open.test.js b/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable-fail-open.test.js new file mode 100644 index 000000000..904d2e771 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable-fail-open.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { + ensureSourcekitPackageResolutionPreflight, + resolveSourcekitPreflightLockPath +} from '../../../src/index/tooling/preflight/sourcekit-package-resolution.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { createSourcekitPreflightFixture } from '../../helpers/sourcekit-preflight-fixture.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-lock-unavailable-fail-open', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); +const logs = []; +const { ctx } = fixture.contextFor(logs); +const preflightLockPath = resolveSourcekitPreflightLockPath(ctx.repoRoot); + +let heldLock = null; +try { + heldLock = await acquireFileLock({ + lockPath: preflightLockPath, + waitMs: 0, + pollMs: 25, + staleMs: 5 * 60 * 1000, + forceStaleCleanup: true, + metadata: { scope: 'sourcekit-preflight-lock-unavailable-fail-open-test' } + }); + assert.ok(heldLock, 'expected test to acquire sourcekit preflight lock'); + + await withTemporaryEnv({ POC_SWIFT_PREFLIGHT_COUNTER: fixture.counterPath }, async () => { + const result = await ensureSourcekitPackageResolutionPreflight({ + repoRoot: ctx.repoRoot, + log: (line) => logs.push(String(line || '')), + sourcekitConfig: { + preflightFailOpen: true, + preflightLockWaitMs: 0, + preflightLockPollMs: 25 + } + }); + + assert.equal(result.blockSourcekit, false, 'expected fail-open mode to preserve sourcekit on lock timeout'); + assert.equal(result.check?.name, 'sourcekit_package_preflight_lock_unavailable'); + assert.ok( + logs.some((line) => line.includes('sourcekit package preflight skipped because lock acquisition timed out')), + 'expected lock-timeout log' + ); + const count = await countNonEmptyLines(fixture.counterPath); + assert.equal(count, 0, 'expected no preflight resolve attempt when lock is unavailable'); + }); +} finally { + if (heldLock?.release) { + await heldLock.release(); + } + await fixture.restorePath(); +} + +console.log('sourcekit package preflight lock-unavailable fail-open test passed'); diff --git a/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable.test.js b/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable.test.js new file mode 100644 index 000000000..232fa754d --- /dev/null +++ b/tests/tooling/lsp/sourcekit-package-preflight-lock-unavailable.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveSourcekitPreflightLockPath } from '../../../src/index/tooling/sourcekit-provider.js'; +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-lock-unavailable', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); +const logs = []; +const { ctx, document, target } = fixture.contextFor(logs); +const preflightLockPath = resolveSourcekitPreflightLockPath(ctx.repoRoot); +ctx.toolingConfig = { + sourcekit: { + preflightLockWaitMs: 0, + preflightLockPollMs: 25 + } +}; + +let heldLock = null; +try { + heldLock = await acquireFileLock({ + lockPath: preflightLockPath, + waitMs: 0, + pollMs: 25, + staleMs: 5 * 60 * 1000, + forceStaleCleanup: true, + metadata: { scope: 'sourcekit-preflight-lock-unavailable-test' } + }); + assert.ok(heldLock, 'expected test to acquire sourcekit preflight lock'); + + await withSourcekitPreflightProvider({ fixture, logs, context: { ctx, document, target } }, async ({ provider }) => { + const output = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.deepEqual(output.byChunkUid || {}, {}, 'expected sourcekit to skip enrichment after lock timeout'); + + const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; + assert.ok( + checks.some((check) => check?.name === 'sourcekit_package_preflight_lock_unavailable'), + 'expected sourcekit preflight lock timeout check in diagnostics' + ); + assert.ok( + logs.some((line) => line.includes('sourcekit package preflight skipped because lock acquisition timed out')), + 'expected lock-timeout skip log' + ); + assert.equal( + logs.some((line) => line.includes('sourcekit skipped because package preflight did not complete safely.')), + true, + 'expected provider to fail closed when preflight lock is unavailable' + ); + const count = await countNonEmptyLines(fixture.counterPath); + assert.equal(count, 0, 'expected no preflight resolve attempt when lock is unavailable'); + }); +} finally { + if (heldLock?.release) { + await heldLock.release(); + } + await fixture.restorePath(); +} + +console.log('sourcekit package preflight lock-unavailable test passed'); diff --git a/tests/tooling/lsp/sourcekit-package-preflight-not-needed.test.js b/tests/tooling/lsp/sourcekit-package-preflight-not-needed.test.js index b2e3f2555..2dbbd64d8 100644 --- a/tests/tooling/lsp/sourcekit-package-preflight-not-needed.test.js +++ b/tests/tooling/lsp/sourcekit-package-preflight-not-needed.test.js @@ -1,145 +1,46 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { countNonEmptyLines } from '../../helpers/lsp-signature-fixtures.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'sourcekit-package-preflight-not-needed'); -const fixtureBinDir = path.join(root, 'tests', 'fixtures', 'lsp', 'bin'); -const counterPath = path.join(tempRoot, 'swift-preflight.counter'); -const binDir = path.join(tempRoot, 'bin'); -const swiftCmdPath = path.join(binDir, 'swift.cmd'); -const swiftPosixPath = path.join(binDir, 'swift'); - -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.mkdir(binDir, { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'src', 'one.swift'), 'func alpha() -> Int { return 1 }\n', 'utf8'); -await fs.writeFile( - path.join(tempRoot, 'Package.swift'), - [ - '// swift-tools-version: 6.0', - 'import PackageDescription', - 'let package = Package(', - ' name: "Sample",', - ' dependencies: [],', - ' targets: [', - ' .target(name: "Sample")', - ' ]', - ')', - '' - ].join('\n'), - 'utf8' -); -await fs.writeFile( - swiftCmdPath, - [ - '@echo off', - 'if "%1"=="--version" exit /b 0', - 'if "%1"=="--help" exit /b 0', - 'if "%1"=="package" if "%2"=="resolve" (', - ' if not "%POC_SWIFT_PREFLIGHT_COUNTER%"=="" echo resolve>>"%POC_SWIFT_PREFLIGHT_COUNTER%"', - ' exit /b 0', - ')', - 'exit /b 1', - '' - ].join('\r\n'), - 'utf8' -); -await fs.writeFile( - swiftPosixPath, - [ - '#!/usr/bin/env sh', - 'if [ "$1" = "--version" ]; then exit 0; fi', - 'if [ "$1" = "--help" ]; then exit 0; fi', - 'if [ "$1" = "package" ] && [ "$2" = "resolve" ]; then', - ' if [ -n "$POC_SWIFT_PREFLIGHT_COUNTER" ]; then', - ' printf "resolve\\n" >> "$POC_SWIFT_PREFLIGHT_COUNTER"', - ' fi', - ' exit 0', - 'fi', - 'exit 1', - '' - ].join('\n'), - 'utf8' -); -try { - await fs.chmod(swiftPosixPath, 0o755); -} catch {} - -const originalPath = process.env.PATH; -const originalCounter = process.env.POC_SWIFT_PREFLIGHT_COUNTER; +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-preflight-not-needed', + includeDependencies: false, + resolveExitCode: 0 +}); const logs = []; -process.env.PATH = [binDir, fixtureBinDir, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter); -process.env.POC_SWIFT_PREFLIGHT_COUNTER = counterPath; - try { - registerDefaultToolingProviders(); - const provider = getToolingProvider('sourcekit'); - assert.ok(provider, 'expected sourcekit provider'); - - const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: (line) => logs.push(String(line || '')), - strict: true - }; - const document = { - virtualPath: 'src/one.swift', - effectiveExt: '.swift', - languageId: 'swift', - text: 'func alpha() -> Int { return 1 }\n', - docHash: 'doc-1', - containerPath: 'src/one.swift' - }; - const target = { - virtualPath: 'src/one.swift', - languageId: 'swift', - chunkRef: { - chunkUid: 'ck:test:sourcekit:preflight-not-needed:1', - file: 'src/one.swift', - start: 0, - end: 12 - } - }; - - const output = await provider.run(ctx, { documents: [document], targets: [target] }); - assert.ok(output && typeof output.byChunkUid === 'object', 'expected sourcekit output'); - const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; - assert.equal( - checks.some((check) => String(check?.name || '').startsWith('sourcekit_package_preflight_')), - false, - 'expected no preflight diagnostics when package resolution is not needed' - ); - let counterExists = true; - try { - await fs.access(counterPath); - } catch { - counterExists = false; - } - assert.equal(counterExists, false, 'expected no swift package resolve invocation'); - assert.equal( - logs.some((line) => line.includes('sourcekit package preflight: running')), - false, - 'expected no preflight-run log when manifest has no package dependencies' - ); + await withSourcekitPreflightProvider({ fixture, logs }, async ({ provider, ctx, document, target }) => { + const output = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.ok(output && typeof output.byChunkUid === 'object', 'expected sourcekit output'); + assert.equal(output?.diagnostics?.preflight?.workspaceKind, 'package_managed_workspace'); + assert.equal(output?.diagnostics?.preflight?.dependencyState, 'not_needed'); + assert.equal(output?.diagnostics?.preflight?.preflightState, 'ready'); + const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; + assert.equal( + checks.some((check) => String(check?.name || '').startsWith('sourcekit_package_preflight_')), + false, + 'expected no preflight diagnostics when package resolution is not needed' + ); + const count = await countNonEmptyLines(fixture.counterPath); + assert.equal(count, 0, 'expected no swift package resolve invocation'); + assert.equal( + logs.some((line) => line.includes('sourcekit package preflight: running')), + false, + 'expected no preflight-run log when manifest has no package dependencies' + ); + }); } finally { - if (originalPath == null) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalCounter == null) { - delete process.env.POC_SWIFT_PREFLIGHT_COUNTER; - } else { - process.env.POC_SWIFT_PREFLIGHT_COUNTER = originalCounter; - } + await fixture.restorePath(); } console.log('sourcekit package preflight not-needed test passed'); + diff --git a/tests/tooling/lsp/sourcekit-package-workspace-high-cost-request-suppression.test.js b/tests/tooling/lsp/sourcekit-package-workspace-high-cost-request-suppression.test.js new file mode 100644 index 000000000..15138c928 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-package-workspace-high-cost-request-suppression.test.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { fileURLToPath } from 'node:url'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { + createSourcekitPreflightFixture, + withSourcekitPreflightProvider +} from '../../helpers/sourcekit-preflight-fixture.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const fixture = await createSourcekitPreflightFixture({ + root, + name: 'sourcekit-package-workspace-high-cost-request-suppression', + includeDependencies: true, + dependencyVersion: '1.0.0', + resolveExitCode: 0 +}); +const logs = []; +const { ctx } = fixture.contextFor(logs); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(fixture.tempRoot, 'stub-launcher.js'); +const modePath = path.join(fixture.tempRoot, 'mode.txt'); +const tracePath = path.join(fixture.tempRoot, 'trace.jsonl'); + +await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'sourcekit';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' +); +await fs.writeFile(modePath, 'all-capabilities', 'utf8'); + +const document = { + virtualPath: 'src/one.swift', + effectiveExt: '.swift', + languageId: 'swift', + text: 'func add(a: Int, b: Int) -> Int { return a + b }\n', + docHash: 'doc-sourcekit-package-workspace', + containerPath: 'src/one.swift' +}; +const target = { + virtualPath: 'src/one.swift', + languageId: 'swift', + chunkRef: { + chunkUid: 'ck:test:sourcekit:package-workspace-high-cost', + chunkId: 'chunk_sourcekit_package_workspace_high_cost', + file: 'src/one.swift', + start: 0, + end: document.text.length + }, + virtualRange: { + start: 0, + end: document.text.length + }, + symbolHint: { + name: 'add', + kind: 'function' + } +}; + +try { + await withSourcekitPreflightProvider({ + fixture, + logs, + env: { POC_LSP_TRACE: tracePath }, + context: { ctx } + }, async ({ provider }) => { + const output = await provider.run({ + ...ctx, + toolingConfig: { + sourcekit: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + hoverEnabled: true, + hoverTimeoutMs: 150, + timeoutMs: 500, + retries: 0, + breakerThreshold: 1, + hostConcurrencyGate: false + } + } + }, { + documents: [document], + targets: [target] + }); + + assert.equal(output?.diagnostics?.preflight?.workspaceKind, 'package_managed_workspace'); + assert.equal(output?.diagnostics?.preflight?.dependencyState, 'required'); + assert.equal( + Array.isArray(output?.diagnostics?.checks) + && output.diagnostics.checks.some((check) => check?.name === 'sourcekit_package_workspace_high_cost_request_suppression'), + true, + 'expected explicit package-workspace suppression check' + ); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.runtimeIssues) + && output.diagnostics.fidelity.runtimeIssues.includes('package_workspace_high_cost_requests_suppressed'), + true, + 'expected fidelity runtime issue for package-workspace suppression' + ); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.requestSuppression?.suppressedRequestClasses) + && output.diagnostics.fidelity.requestSuppression.suppressedRequestClasses.includes('semanticTokens') + && output.diagnostics.fidelity.requestSuppression.suppressedRequestClasses.includes('inlayHints'), + true, + 'expected package-workspace suppression to record the skipped request classes' + ); + + const events = await parseJsonLinesFile(tracePath); + const semanticTokenRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/semanticTokens/full').length; + const inlayHintRequests = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/inlayHint').length; + assert.equal(semanticTokenRequests, 0, 'expected semantic tokens to be suppressed for package-managed high-cost workspaces'); + assert.equal(inlayHintRequests, 0, 'expected inlay hints to be suppressed for package-managed high-cost workspaces'); + }); +} finally { + await fixture.restorePath(); + const cleanup = await removePathWithRetry(fixture.tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} + +console.log('sourcekit package workspace high-cost request suppression test passed'); diff --git a/tests/tooling/lsp/sourcekit-provider-host-lock-unavailable.test.js b/tests/tooling/lsp/sourcekit-provider-host-lock-unavailable.test.js new file mode 100644 index 000000000..6353f982e --- /dev/null +++ b/tests/tooling/lsp/sourcekit-provider-host-lock-unavailable.test.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { resolveSourcekitHostLockPath } from '../../../src/index/tooling/sourcekit-provider.js'; +import { acquireFileLock } from '../../../src/shared/locks/file-lock.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const tempRoot = resolveTestCachePath(root, 'sourcekit-provider-host-lock-unavailable'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + +const fixtureCmd = path.join( + root, + 'tests', + 'fixtures', + 'lsp', + 'bin', + process.platform === 'win32' ? 'sourcekit-lsp.cmd' : 'sourcekit-lsp' +); +await fs.access(fixtureCmd); + +const hostLockPath = resolveSourcekitHostLockPath(tempRoot); + +const ctx = { + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + sourcekit: { + cmd: fixtureCmd, + args: [], + hostConcurrencyGate: true, + hostConcurrencyWaitMs: 0 + } + }, + logger: () => {}, + strict: true +}; + +const document = { + virtualPath: 'src/one.swift', + effectiveExt: '.swift', + languageId: 'swift', + text: 'func alpha() -> Int { return 1 }\n', + docHash: 'doc-sourcekit-lock-unavailable', + containerPath: 'src/one.swift' +}; + +const chunkUid = 'ck:test:sourcekit:host-lock-unavailable:1'; +const target = { + virtualPath: 'src/one.swift', + languageId: 'swift', + chunkRef: { + chunkUid, + chunkId: 'chunk_sourcekit_host_lock_unavailable', + file: 'src/one.swift', + start: 0, + end: document.text.length + }, + virtualRange: { + start: 0, + end: document.text.length + }, + symbolHint: { + name: 'alpha', + kind: 'function' + } +}; + +registerDefaultToolingProviders(); +const provider = getToolingProvider('sourcekit'); +assert.ok(provider, 'expected sourcekit provider'); + +const heldLock = await acquireFileLock({ + lockPath: hostLockPath, + waitMs: 0, + pollMs: 25, + staleMs: 5 * 60 * 1000, + forceStaleCleanup: true, + metadata: { scope: 'sourcekit-host-lock-unavailable-test' } +}); +assert.ok(heldLock, 'expected test to acquire sourcekit host lock'); +try { + const output = await provider.run(ctx, { documents: [document], targets: [target] }); + assert.deepEqual(output.byChunkUid || {}, {}, 'expected sourcekit provider to skip run when host lock is unavailable'); + const checks = Array.isArray(output?.diagnostics?.checks) ? output.diagnostics.checks : []; + assert.ok( + checks.some((check) => check?.name === 'sourcekit_host_lock_unavailable'), + 'expected sourcekit host lock unavailable check' + ); + assert.equal(output?.diagnostics?.admission?.startupMode, 'host_lock_unavailable', 'expected explicit host-lock admission mode'); + assert.equal(output?.diagnostics?.fidelity?.state, 'blocked', 'expected fidelity contract to classify host lock failure as blocked'); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.runtimeIssues) + && output.diagnostics.fidelity.runtimeIssues.includes('host_lock_unavailable'), + true, + 'expected fidelity contract to preserve host lock classification' + ); +} finally { + await heldLock.release(); +} + +console.log('sourcekit provider host lock unavailable test passed'); diff --git a/tests/tooling/lsp/sourcekit-provider-output-shape.test.js b/tests/tooling/lsp/sourcekit-provider-output-shape.test.js deleted file mode 100644 index a0320ff22..000000000 --- a/tests/tooling/lsp/sourcekit-provider-output-shape.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const tempRoot = resolveTestCachePath(root, 'sourcekit-provider-output-shape'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); -await fs.writeFile(path.join(tempRoot, 'src', 'one.swift'), 'func alpha() -> Int { return 1 }\n', 'utf8'); - -registerDefaultToolingProviders(); -const provider = getToolingProvider('sourcekit'); -assert.ok(provider, 'expected sourcekit provider'); - -const ctx = { - repoRoot: tempRoot, - buildRoot: tempRoot, - toolingConfig: {}, - logger: () => {}, - strict: true -}; - -const document = { - virtualPath: 'src/one.swift', - effectiveExt: '.swift', - languageId: 'swift', - text: 'func alpha() -> Int { return 1 }\n', - docHash: 'doc-1', - containerPath: 'src/one.swift' -}; - -const target = { - virtualPath: 'src/one.swift', - languageId: 'swift', - chunkRef: { - chunkUid: 'ck:test:sourcekit:1', - file: 'src/one.swift', - start: 0, - end: 10 - } -}; - -const output = await provider.run(ctx, { documents: [document], targets: [target, target] }); -assert.ok(output && typeof output === 'object', 'expected output object'); -assert.ok(output.byChunkUid && typeof output.byChunkUid === 'object', 'expected byChunkUid output'); -assert.ok(!('byFile' in output), 'unexpected byFile key in output'); -const checks = output.diagnostics?.checks || []; -const duplicate = checks.find((check) => check.name === 'duplicate_chunk_uid'); -assert.ok(duplicate, 'expected duplicate chunkUid warning'); -assert.ok( - Array.isArray(duplicate.samples) && duplicate.samples[0]?.startsWith('ck:'), - 'expected duplicate chunkUid samples to be chunk-style ids' -); - -console.log('sourcekit provider output shape test passed'); diff --git a/tests/tooling/lsp/sourcekit-semantic-tokens-timeout.test.js b/tests/tooling/lsp/sourcekit-semantic-tokens-timeout.test.js new file mode 100644 index 000000000..47e3e70d6 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-semantic-tokens-timeout.test.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; +import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `sourcekit-semantic-timeout-${process.pid}-${Date.now()}`); +const stubServerPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const launcherPath = path.join(tempRoot, 'stub-launcher.js'); +const modePath = path.join(tempRoot, 'mode.txt'); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(tempRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'src', 'one.swift'), 'func alpha() -> Int { return 1 }\n', 'utf8'); + await fs.writeFile( + launcherPath, + `import fs from 'node:fs';\n` + + `import { spawn } from 'node:child_process';\n` + + `const modePath = process.argv[2];\n` + + `const stubPath = process.argv[3];\n` + + `const mode = fs.readFileSync(modePath, 'utf8').trim() || 'sourcekit';\n` + + `const child = spawn(process.execPath, [stubPath, '--mode', mode], { stdio: 'inherit' });\n` + + `child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));\n`, + 'utf8' + ); + await fs.writeFile(modePath, 'stall-semantic-tokens', 'utf8'); + + registerDefaultToolingProviders(); + const provider = getToolingProvider('sourcekit'); + assert.ok(provider, 'expected sourcekit provider'); + + const output = await provider.run({ + repoRoot: tempRoot, + buildRoot: tempRoot, + toolingConfig: { + sourcekit: { + cmd: process.execPath, + args: [launcherPath, modePath, stubServerPath], + hoverEnabled: false, + hoverTimeoutMs: 150, + timeoutMs: 500, + retries: 0, + breakerThreshold: 1, + hostConcurrencyGate: false + } + }, + logger: () => {}, + strict: true + }, { + documents: [{ + virtualPath: 'src/one.swift', + effectiveExt: '.swift', + languageId: 'swift', + text: 'func alpha() -> Int { return 1 }\n', + docHash: 'doc-sourcekit-semantic-timeout', + containerPath: 'src/one.swift' + }], + targets: [{ + virtualPath: 'src/one.swift', + languageId: 'swift', + chunkRef: { + chunkUid: 'ck:test:sourcekit:semantic-timeout', + chunkId: 'chunk_sourcekit_semantic_timeout', + file: 'src/one.swift', + start: 0, + end: 32 + }, + virtualRange: { + start: 0, + end: 32 + }, + symbolHint: { + name: 'alpha', + kind: 'function' + } + }] + }); + + assert.equal(output?.diagnostics?.runtime?.hoverMetrics?.requested ?? 0, 0, 'expected no hover requests'); + assert.equal(output?.diagnostics?.runtime?.hoverMetrics?.hoverTimedOut ?? 0, 0, 'expected no hover timeouts'); + assert.equal(output?.diagnostics?.runtime?.hoverMetrics?.semanticTokensTimedOut ?? 0, 1, 'expected semantic token timeout to be isolated'); + assert.equal(output?.diagnostics?.fidelity?.state, 'degraded', 'expected semantic timeout run to remain degraded'); + assert.equal(output?.diagnostics?.admission?.startupMode, 'runtime_timeout_storm', 'expected explicit timeout-storm admission mode'); + assert.equal( + Array.isArray(output?.diagnostics?.fidelity?.runtimeIssues) + && output.diagnostics.fidelity.runtimeIssues.includes('semantic_tokens_timeout') + && output.diagnostics.fidelity.runtimeIssues.includes('circuit_breaker_tripped') + && output.diagnostics.fidelity.runtimeIssues.includes('timeout_storm_truncated'), + true, + 'expected fidelity runtime issues to expose the timeout storm state' + ); + assert.equal( + Array.isArray(output?.diagnostics?.checks) + && output.diagnostics.checks.some((check) => check?.name === 'tooling_semantic_tokens_timeout'), + true, + 'expected semantic token timeout warning' + ); + + console.log('sourcekit semantic tokens timeout test passed'); +} finally { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/sourcekit-workspace-classification.test.js b/tests/tooling/lsp/sourcekit-workspace-classification.test.js new file mode 100644 index 000000000..08a79afb8 --- /dev/null +++ b/tests/tooling/lsp/sourcekit-workspace-classification.test.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { ensureSourcekitPackageResolutionPreflight } from '../../../src/index/tooling/preflight/sourcekit-package-resolution.js'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `sourcekit-workspace-classification-${process.pid}-${Date.now()}`); +const packageRoot = path.join(tempRoot, 'package-workspace'); +const xcodeRoot = path.join(tempRoot, 'xcode-workspace'); +const mixedRoot = path.join(tempRoot, 'mixed-workspace'); +const mixedDependenciesRoot = path.join(tempRoot, 'mixed-workspace-dependencies'); + +try { + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.mkdir(xcodeRoot, { recursive: true }); + await fs.mkdir(mixedRoot, { recursive: true }); + await fs.mkdir(mixedDependenciesRoot, { recursive: true }); + + await fs.writeFile( + path.join(packageRoot, 'Package.swift'), + [ + '// swift-tools-version: 6.0', + 'import PackageDescription', + 'let package = Package(', + ' name: "PackageOnly",', + ' dependencies: [],', + ' targets: [', + ' .target(name: "PackageOnly")', + ' ]', + ')', + '' + ].join('\n'), + 'utf8' + ); + await fs.mkdir(path.join(xcodeRoot, 'Demo.xcodeproj'), { recursive: true }); + await fs.writeFile( + path.join(mixedRoot, 'Package.swift'), + [ + '// swift-tools-version: 6.0', + 'import PackageDescription', + 'let package = Package(', + ' name: "Mixed",', + ' dependencies: [],', + ' targets: [', + ' .target(name: "Mixed")', + ' ]', + ')', + '' + ].join('\n'), + 'utf8' + ); + await fs.mkdir(path.join(mixedRoot, 'Demo.xcodeproj'), { recursive: true }); + await fs.writeFile( + path.join(mixedDependenciesRoot, 'Package.swift'), + [ + '// swift-tools-version: 6.0', + 'import PackageDescription', + 'let package = Package(', + ' name: "MixedDependencies",', + ' dependencies: [', + ' .package(url: "https://example.com/demo.git", from: "1.0.0")', + ' ],', + ' targets: [', + ' .target(name: "MixedDependencies")', + ' ]', + ')', + '' + ].join('\n'), + 'utf8' + ); + await fs.mkdir(path.join(mixedDependenciesRoot, 'Demo.xcodeproj'), { recursive: true }); + + const packageResult = await ensureSourcekitPackageResolutionPreflight({ + repoRoot: packageRoot, + log: () => {}, + sourcekitConfig: {} + }); + const xcodeResult = await ensureSourcekitPackageResolutionPreflight({ + repoRoot: xcodeRoot, + log: () => {}, + sourcekitConfig: {} + }); + const mixedResult = await ensureSourcekitPackageResolutionPreflight({ + repoRoot: mixedRoot, + log: () => {}, + sourcekitConfig: {} + }); + const mixedDependenciesResult = await ensureSourcekitPackageResolutionPreflight({ + repoRoot: mixedDependenciesRoot, + log: () => {}, + sourcekitConfig: {} + }); + + assert.equal(packageResult.workspaceKind, 'package_managed_workspace'); + assert.equal(packageResult.dependencyState, 'not_needed'); + assert.equal(packageResult.preflightState, 'ready'); + + assert.equal(xcodeResult.workspaceKind, 'xcode_workspace'); + assert.equal(xcodeResult.dependencyState, 'not_applicable'); + assert.equal(xcodeResult.preflightState, 'ready'); + + assert.equal(mixedResult.workspaceKind, 'mixed_workspace'); + assert.equal(mixedResult.dependencyState, 'not_needed'); + assert.equal(mixedResult.preflightState, 'ready'); + + assert.equal(mixedDependenciesResult.workspaceKind, 'mixed_workspace'); + assert.equal(mixedDependenciesResult.dependencyState, 'optional'); + assert.equal(mixedDependenciesResult.preflightState, 'ready'); + assert.equal(mixedDependenciesResult.reasonCode, 'sourcekit_mixed_workspace_dependencies_optional'); + + console.log('sourcekit workspace classification test passed'); +} finally { + const cleanup = await removePathWithRetry(tempRoot, { + attempts: 6, + baseDelayMs: 100, + maxDelayMs: 100 + }); + if (!cleanup.ok) throw cleanup.error; +} diff --git a/tests/tooling/lsp/spawn-process-override-contract.test.js b/tests/tooling/lsp/spawn-process-override-contract.test.js new file mode 100644 index 000000000..3ab4b72ee --- /dev/null +++ b/tests/tooling/lsp/spawn-process-override-contract.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; + +const createEventEmitterLike = () => ({ + on() {}, + once() {}, + off() {} +}); + +{ + const badChild = { + ...createEventEmitterLike(), + exitCode: null, + killed: false + }; + const client = createLspClient({ + cmd: process.execPath, + args: [], + log: () => {}, + spawnProcess: () => badChild + }); + assert.throws( + () => client.start(), + /stdin\/stdout stream objects/i, + 'expected spawn override validation to reject missing stdin/stdout streams' + ); +} + +{ + const badChild = { + ...createEventEmitterLike(), + exitCode: null, + killed: false, + stdin: { + write() {}, + end() {}, + on() {}, + once() {}, + off() {} + }, + stdout: { + on() {}, + once() {}, + off() {}, + destroy() {} + }, + stderr: {} + }; + const client = createLspClient({ + cmd: process.execPath, + args: [], + log: () => {}, + spawnProcess: () => badChild + }); + assert.throws( + () => client.start(), + /stderr stream/i, + 'expected spawn override validation to reject invalid stderr stream object' + ); +} + +console.log('lsp spawn-process override contract test passed'); diff --git a/tests/tooling/lsp/stage-budget-shared-cap.test.js b/tests/tooling/lsp/stage-budget-shared-cap.test.js new file mode 100644 index 000000000..c25562776 --- /dev/null +++ b/tests/tooling/lsp/stage-budget-shared-cap.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-stage-budget-shared-cap-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); +const docText = 'const sentinel = 1;\n'; +const virtualPath = '.poc-vfs/src/sample.cpp#seg:shared-cap.cpp'; +const chunkUid = 'ck64:v1:test:src/sample.cpp:shared-cap'; + +const parseSignature = (detailText) => { + const detail = String(detailText || '').trim(); + if (detail !== 'add') return null; + return { + signature: detail, + returnType: 'unknown', + paramTypes: {}, + paramNames: ['a', 'b'] + }; +}; + +let result = null; +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + result = await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents: [{ + virtualPath, + text: docText, + languageId: 'cpp', + effectiveExt: '.cpp' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_shared_cap', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } + }], + cmd: process.execPath, + args: [serverPath, '--mode', 'signature-help'], + parseSignature, + hoverMaxPerFile: 1, + definitionEnabled: false, + typeDefinitionEnabled: false, + referencesEnabled: false + }); +}); + +assert.equal(Number(result?.hoverMetrics?.requested || 0), 1, 'expected one hover request'); +assert.equal(Number(result?.hoverMetrics?.signatureHelpRequested || 0), 1, 'expected separate signatureHelp budget to allow the later stage'); +assert.equal(Number(result?.hoverMetrics?.skippedByBudget || 0), 0, 'expected no shared-cap suppression after per-stage budget cutover'); +assert.equal( + Number(result?.runtime?.requestBudgets?.byKind?.hover?.maxRequests || 0) >= 1, + true, + 'expected runtime request budget envelope for hover' +); +assert.equal( + Number(result?.runtime?.requestBudgets?.byKind?.signatureHelp?.maxRequests || 0) >= 1, + true, + 'expected runtime request budget envelope for signatureHelp' +); + +const events = await parseJsonLinesFile(tracePath); +const hoverCalls = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/hover').length; +const signatureHelpCalls = events.filter((entry) => entry.kind === 'request' && entry.method === 'textDocument/signatureHelp').length; +assert.equal(hoverCalls, 1, 'expected one hover request trace'); +assert.equal(signatureHelpCalls, 1, 'expected one signatureHelp trace under the per-stage budget plan'); + +console.log('LSP stage budget shared cap test passed'); diff --git a/tests/tooling/lsp/swift-signature-parse.test.js b/tests/tooling/lsp/swift-signature-parse.test.js deleted file mode 100644 index af9f1f1e7..000000000 --- a/tests/tooling/lsp/swift-signature-parse.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { parseSwiftSignature } from '../../../src/index/tooling/signature-parse/swift.js'; - -const cases = [ - { - detail: 'func greet(name: String) -> String', - expectedReturn: 'String', - expectedParams: { name: 'String' } - }, - { - detail: 'func process(_ value: T, using block: @escaping (T) -> Void) -> Result', - expectedReturn: 'Result', - expectedParams: { value: 'T', block: '(T) -> Void' } - }, - { - detail: 'func load() async throws -> [String: Int]', - expectedReturn: '[String: Int]', - expectedParams: {} - }, - { - detail: 'init?(rawValue: Int)', - expectedReturn: 'Self', - expectedParams: { rawValue: 'Int' } - }, - { - detail: 'var title: Swift.String { get }', - expectedReturn: 'String', - expectedParams: {} - }, - { - detail: `render(view:) -func render(view: View) -> Swift.Int`, - expectedReturn: 'Int', - expectedParams: { view: 'View' } - } -]; - -for (const testCase of cases) { - const parsed = parseSwiftSignature(testCase.detail); - assert.ok(parsed, `expected parser output for: ${testCase.detail}`); - assert.equal(parsed.returnType, testCase.expectedReturn, `unexpected return type for: ${testCase.detail}`); - for (const [name, type] of Object.entries(testCase.expectedParams)) { - assert.equal(parsed.paramTypes?.[name], type, `unexpected param type for ${name} in: ${testCase.detail}`); - } -} - -const invalid = parseSwiftSignature('not a signature'); -assert.equal(invalid, null, 'expected null for non-signature detail'); - -console.log('swift signature parse test passed'); diff --git a/tests/tooling/lsp/target-index.test.js b/tests/tooling/lsp/target-index.test.js new file mode 100644 index 000000000..da0223b44 --- /dev/null +++ b/tests/tooling/lsp/target-index.test.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + buildTargetLookupIndex, + findTargetForOffsets, + findTargetForOffsetsLinear +} from '../../../src/integrations/tooling/providers/lsp/target-index.js'; + +const targets = [ + { + id: 'outer', + virtualRange: { start: 0, end: 300 }, + symbolHint: { name: 'Container', kind: 'class' } + }, + { + id: 'inner-alpha', + virtualRange: { start: 20, end: 80 }, + symbolHint: { name: 'alpha', kind: 'function' } + }, + { + id: 'inner-beta', + virtualRange: { start: 90, end: 160 }, + symbolHint: { name: 'beta', kind: 'function' } + }, + { + id: 'inner-beta-shadow', + virtualRange: { start: 90, end: 160 }, + symbolHint: { name: 'gamma', kind: 'function' } + }, + { + id: 'tail', + virtualRange: { start: 220, end: 260 }, + symbolHint: { name: 'tail', kind: 'function' } + } +]; + +const lookup = buildTargetLookupIndex(targets); + +const queries = [ + { offsets: { start: 25, end: 35 }, nameHint: 'alpha' }, + { offsets: { start: 110, end: 118 }, nameHint: 'beta' }, + { offsets: { start: 110, end: 118 }, nameHint: 'gamma' }, + { offsets: { start: 230, end: 240 }, nameHint: 'tail' }, + { offsets: { start: 10, end: 280 }, nameHint: null }, + { offsets: { start: 161, end: 170 }, nameHint: null }, + { offsets: { start: 301, end: 305 }, nameHint: null } +]; + +for (const query of queries) { + const indexed = findTargetForOffsets(lookup, query.offsets, query.nameHint); + const linear = findTargetForOffsetsLinear(targets, query.offsets, query.nameHint); + assert.equal( + indexed?.id || null, + linear?.id || null, + `expected indexed lookup to match linear lookup for ${JSON.stringify(query)}` + ); +} + +const tieTargets = [ + { + id: 'first', + virtualRange: { start: 0, end: 100 }, + symbolHint: { name: 'dup', kind: 'function' } + }, + { + id: 'second', + virtualRange: { start: 0, end: 100 }, + symbolHint: { name: 'dup', kind: 'function' } + } +]; + +const tieLookup = buildTargetLookupIndex(tieTargets); +const tieMatch = findTargetForOffsets(tieLookup, { start: 10, end: 20 }, 'dup'); +assert.equal(tieMatch?.id, 'first', 'expected original target order to break exact ties'); + +console.log('LSP target index test passed'); diff --git a/tests/tooling/lsp/tooling-lsp.test.js b/tests/tooling/lsp/tooling-lsp.test.js deleted file mode 100644 index 64597677b..000000000 --- a/tests/tooling/lsp/tooling-lsp.test.js +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { PassThrough } from 'node:stream'; -import { buildLineIndex } from '../../../src/shared/lines.js'; -import { createFramedJsonRpcParser, writeFramedJsonRpc } from '../../../src/shared/jsonrpc.js'; -import { flattenSymbols } from '../../../src/integrations/tooling/lsp/symbols.js'; -import { rangeToOffsets } from '../../../src/integrations/tooling/lsp/positions.js'; - -const messages = []; -const errors = []; -const parser = createFramedJsonRpcParser({ - onMessage: (msg) => messages.push(msg), - onError: (err) => errors.push(err) -}); - -const waitFor = async (count) => { - for (let i = 0; i < 50; i += 1) { - if (messages.length >= count) return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - throw new Error(`Timed out waiting for ${count} messages.`); -}; - -const msgOne = { jsonrpc: '2.0', id: 1, result: 'ok' }; -const msgTwo = { jsonrpc: '2.0', method: 'notify', params: { ok: true } }; - -const frame = (payload) => { - const body = Buffer.from(JSON.stringify(payload), 'utf8'); - const header = `Content-Length: ${body.length}\r\n\r\n`; - return Buffer.concat([Buffer.from(header, 'utf8'), body]); -}; - -const combined = Buffer.concat([frame(msgOne), frame(msgTwo)]); -parser.push(combined.slice(0, 12)); -parser.push(combined.slice(12)); - -await waitFor(2); -assert.equal(errors.length, 0); -assert.equal(messages.length, 2); -assert.deepEqual(messages[0], msgOne); -assert.deepEqual(messages[1], msgTwo); - -const capture = new PassThrough(); -const capturedChunks = []; -capture.on('data', (chunk) => capturedChunks.push(chunk)); -await writeFramedJsonRpc(capture, msgOne); -const parserTwo = createFramedJsonRpcParser({ - onMessage: (msg) => messages.push(msg), - onError: (err) => errors.push(err) -}); -parserTwo.push(Buffer.concat(capturedChunks)); -await waitFor(3); -assert.deepEqual(messages[messages.length - 1], msgOne); - -const largeMessages = []; -const largeErrors = []; -const parserLarge = createFramedJsonRpcParser({ - onMessage: (msg) => largeMessages.push(msg), - onError: (err) => largeErrors.push(err) -}); -const largePayload = { - jsonrpc: '2.0', - id: 99, - result: 'x'.repeat(512 * 1024) -}; -const largeFrame = frame(largePayload); -for (let i = 0; i < largeFrame.length; i += 1024) { - parserLarge.push(largeFrame.slice(i, i + 1024)); -} -for (let i = 0; i < 50; i += 1) { - if (largeMessages.length) break; - await new Promise((resolve) => setTimeout(resolve, 0)); -} -assert.equal(largeErrors.length, 0); -assert.equal(largeMessages.length, 1); -assert.equal(largeMessages[0].id, 99); - -const docSymbols = [ - { - name: 'Widget', - kind: 5, - detail: 'class Widget', - range: { start: { line: 0, character: 0 }, end: { line: 4, character: 0 } }, - selectionRange: { start: { line: 0, character: 6 }, end: { line: 0, character: 12 } }, - children: [ - { - name: 'render', - kind: 6, - detail: 'func render()', - range: { start: { line: 1, character: 2 }, end: { line: 2, character: 0 } }, - selectionRange: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } } - } - ] - } -]; - -const flattenedDoc = flattenSymbols(docSymbols); -assert.equal(flattenedDoc.length, 2); -assert.equal(flattenedDoc[1].fullName, 'Widget.render'); - -const infoSymbols = [ - { - name: 'makeWidget', - kind: 12, - containerName: 'Factory', - location: { - uri: 'file:///tmp/example.swift', - range: { start: { line: 5, character: 0 }, end: { line: 7, character: 0 } } - } - } -]; - -const flattenedInfo = flattenSymbols(infoSymbols); -assert.equal(flattenedInfo.length, 1); -assert.equal(flattenedInfo[0].fullName, 'Factory.makeWidget'); - -const text = 'alpha\nbeta\ngamma'; -const lineIndex = buildLineIndex(text); -const offsets = rangeToOffsets(lineIndex, { - start: { line: 0, character: 1 }, - end: { line: 1, character: 2 } -}); -assert.equal(offsets.start, 1); -assert.equal(offsets.end, lineIndex[1] + 2); - -const crlfText = 'alpha\r\nbeta\r\ngamma'; -const crlfIndex = buildLineIndex(crlfText); -assert.equal(crlfIndex[1], 7); -assert.equal(crlfIndex[2], 13); -const crlfOffsets = rangeToOffsets(crlfIndex, { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 } -}); -assert.equal(crlfOffsets.start, crlfIndex[1] + 1); -assert.equal(crlfOffsets.end, crlfIndex[1] + 4); - -const emojiText = 'a😀b'; -const emojiIndex = buildLineIndex(emojiText); -const emojiOffsets = rangeToOffsets(emojiIndex, { - start: { line: 0, character: 3 }, - end: { line: 0, character: 4 } -}); -assert.equal(emojiOffsets.start, 3); -assert.equal(emojiOffsets.end, 4); - -console.log('tooling LSP utils test passed'); diff --git a/tests/tooling/lsp/tooling.test.js b/tests/tooling/lsp/tooling.test.js new file mode 100644 index 000000000..1cb25dce6 --- /dev/null +++ b/tests/tooling/lsp/tooling.test.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; +import { buildLineIndex } from '../../../src/shared/lines.js'; +import { createFramedJsonRpcParser, writeFramedJsonRpc } from '../../../src/shared/jsonrpc.js'; +import { flattenSymbols } from '../../../src/integrations/tooling/lsp/symbols.js'; +import { + rangeToOffsets, + resolveInitializeResultPositionEncoding, + resolveLspPositionEncoding +} from '../../../src/integrations/tooling/lsp/positions.js'; + +const messages = []; +const errors = []; +const parser = createFramedJsonRpcParser({ + onMessage: (msg) => messages.push(msg), + onError: (err) => errors.push(err) +}); + +const waitFor = async (count) => { + for (let i = 0; i < 50; i += 1) { + if (messages.length >= count) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error(`Timed out waiting for ${count} messages.`); +}; + +const msgOne = { jsonrpc: '2.0', id: 1, result: 'ok' }; +const msgTwo = { jsonrpc: '2.0', method: 'notify', params: { ok: true } }; + +const frame = (payload) => { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + const header = `Content-Length: ${body.length}\r\n\r\n`; + return Buffer.concat([Buffer.from(header, 'utf8'), body]); +}; + +const combined = Buffer.concat([frame(msgOne), frame(msgTwo)]); +parser.push(combined.slice(0, 12)); +parser.push(combined.slice(12)); + +await waitFor(2); +assert.equal(errors.length, 0); +assert.equal(messages.length, 2); +assert.deepEqual(messages[0], msgOne); +assert.deepEqual(messages[1], msgTwo); + +const capture = new PassThrough(); +const capturedChunks = []; +capture.on('data', (chunk) => capturedChunks.push(chunk)); +await writeFramedJsonRpc(capture, msgOne); +const parserTwo = createFramedJsonRpcParser({ + onMessage: (msg) => messages.push(msg), + onError: (err) => errors.push(err) +}); +parserTwo.push(Buffer.concat(capturedChunks)); +await waitFor(3); +assert.deepEqual(messages[messages.length - 1], msgOne); + +const largeMessages = []; +const largeErrors = []; +const parserLarge = createFramedJsonRpcParser({ + onMessage: (msg) => largeMessages.push(msg), + onError: (err) => largeErrors.push(err) +}); +const largePayload = { + jsonrpc: '2.0', + id: 99, + result: 'x'.repeat(512 * 1024) +}; +const largeFrame = frame(largePayload); +for (let i = 0; i < largeFrame.length; i += 1024) { + parserLarge.push(largeFrame.slice(i, i + 1024)); +} +for (let i = 0; i < 50; i += 1) { + if (largeMessages.length) break; + await new Promise((resolve) => setTimeout(resolve, 0)); +} +assert.equal(largeErrors.length, 0); +assert.equal(largeMessages.length, 1); +assert.equal(largeMessages[0].id, 99); + +const docSymbols = [ + { + name: 'Widget', + kind: 5, + detail: 'class Widget', + range: { start: { line: 0, character: 0 }, end: { line: 4, character: 0 } }, + selectionRange: { start: { line: 0, character: 6 }, end: { line: 0, character: 12 } }, + children: [ + { + name: 'render', + kind: 6, + detail: 'func render()', + range: { start: { line: 1, character: 2 }, end: { line: 2, character: 0 } }, + selectionRange: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } } + } + ] + } +]; + +const flattenedDoc = flattenSymbols(docSymbols); +assert.equal(flattenedDoc.length, 2); +assert.equal(flattenedDoc[1].fullName, 'Widget.render'); +assert.equal(flattenedDoc[0].kind, 5); +assert.equal(flattenedDoc[1].kind, 6); + +const infoSymbols = [ + { + name: 'makeWidget', + kind: 12, + containerName: 'Factory', + location: { + uri: 'file:///tmp/example.swift', + range: { start: { line: 5, character: 0 }, end: { line: 7, character: 0 } } + } + } +]; + +const flattenedInfo = flattenSymbols(infoSymbols); +assert.equal(flattenedInfo.length, 1); +assert.equal(flattenedInfo[0].fullName, 'Factory.makeWidget'); +assert.equal(flattenedInfo[0].kind, 12); + +const text = 'alpha\nbeta\ngamma'; +const lineIndex = buildLineIndex(text); +const offsets = rangeToOffsets(lineIndex, { + start: { line: 0, character: 1 }, + end: { line: 1, character: 2 } +}); +assert.equal(offsets.start, 1); +assert.equal(offsets.end, lineIndex[1] + 2); + +const crlfText = 'alpha\r\nbeta\r\ngamma'; +const crlfIndex = buildLineIndex(crlfText); +assert.equal(crlfIndex[1], 7); +assert.equal(crlfIndex[2], 13); +const crlfOffsets = rangeToOffsets(crlfIndex, { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 } +}); +assert.equal(crlfOffsets.start, crlfIndex[1] + 1); +assert.equal(crlfOffsets.end, crlfIndex[1] + 4); + +const emojiText = 'a😀b'; +const emojiIndex = buildLineIndex(emojiText); +const emojiOffsets = rangeToOffsets(emojiIndex, { + start: { line: 0, character: 3 }, + end: { line: 0, character: 4 } +}); +assert.equal(emojiOffsets.start, 3); +assert.equal(emojiOffsets.end, 4); + +const emojiUtf8Offsets = rangeToOffsets(emojiIndex, { + start: { line: 0, character: 5 }, + end: { line: 0, character: 6 } +}, { + text: emojiText, + positionEncoding: 'utf-8' +}); +assert.equal(emojiUtf8Offsets.start, 3); +assert.equal(emojiUtf8Offsets.end, 4); + +const emojiUtf32Offsets = rangeToOffsets(emojiIndex, { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 } +}, { + text: emojiText, + positionEncoding: 'utf-32' +}); +assert.equal(emojiUtf32Offsets.start, 3); +assert.equal(emojiUtf32Offsets.end, 4); + +const clampedUtf16Offsets = rangeToOffsets(lineIndex, { + start: { line: 0, character: 999 }, + end: { line: 1, character: 999 } +}, { + text +}); +assert.equal(clampedUtf16Offsets.start, lineIndex[1]); +assert.equal(clampedUtf16Offsets.end, lineIndex[2]); + +assert.equal( + resolveLspPositionEncoding(['utf-x-custom', 'utf-8']), + 'utf-8', + 'expected array negotiation to skip unknown encodings before choosing a known one' +); +assert.equal( + resolveInitializeResultPositionEncoding({ + capabilities: { + positionEncoding: 'utf-x-custom', + offsetEncoding: ['utf-x-custom', 'utf-32'] + } + }), + 'utf-32', + 'expected initialize result fallback to recognized offsetEncoding when positionEncoding is unknown' +); + +console.log('tooling LSP utils test passed'); diff --git a/tests/tooling/lsp/typescript-contract-matrix.test.js b/tests/tooling/lsp/typescript-contract-matrix.test.js new file mode 100644 index 000000000..2bd4a564d --- /dev/null +++ b/tests/tooling/lsp/typescript-contract-matrix.test.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createTypeScriptProvider } from '../../../src/index/tooling/typescript-provider.js'; + +const provider = createTypeScriptProvider(); +const root = process.cwd(); +const baseToolingConfig = { + typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } +}; + +const cases = [ + { + name: 'vue vfs segments preserve file identity', + strict: true, + documents: [{ + virtualPath: '.poc-vfs/src/App.vue#seg:stub.ts', + text: 'export function greet(name: string) { return name; }\n', + languageId: 'typescript', + effectiveExt: '.ts' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/App.vue:deadbeef', + chunkId: 'chunk_deadbeef', + file: 'src/App.vue', + segmentUid: 'seg-stub', + segmentId: 'seg-stub', + range: { start: 0, end: 'export function greet(name: string) { return name; }\n'.length } + }, + virtualPath: '.poc-vfs/src/App.vue#seg:stub.ts', + virtualRange: { start: 0, end: 'export function greet(name: string) { return name; }\n'.length }, + symbolHint: { name: 'greet', kind: 'function' } + }], + assertResult(result) { + const entry = result.byChunkUid?.['ck64:v1:test:src/App.vue:deadbeef']; + assert.ok(entry, 'expected TypeScript provider to return an entry for VFS target'); + assert.equal(entry.payload.returnType, 'string'); + } + }, + { + name: 'node matching uses target ranges to disambiguate duplicates', + strict: true, + setup() { + const docText = ['class A { dup() { return 1; } }', 'class B { dup() { return "x"; } }', ''].join('\n'); + const virtualPath = '.poc-vfs/src/dups.ts#seg:stub.ts'; + const firstMethod = 'dup() { return 1; }'; + const secondMethod = 'dup() { return "x"; }'; + const firstStart = docText.indexOf(firstMethod); + const secondStart = docText.indexOf(secondMethod); + return { + documents: [{ + virtualPath, + text: docText, + languageId: 'typescript', + effectiveExt: '.ts' + }], + targets: [ + { + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/dups.ts:first', + chunkId: 'chunk_first', + file: 'src/dups.ts', + segmentUid: null, + segmentId: null, + range: { start: firstStart, end: firstStart + firstMethod.length } + }, + virtualPath, + virtualRange: { start: firstStart, end: firstStart + firstMethod.length }, + symbolHint: { name: 'dup', kind: 'method' } + }, + { + chunkRef: { + docId: 1, + chunkUid: 'ck64:v1:test:src/dups.ts:second', + chunkId: 'chunk_second', + file: 'src/dups.ts', + segmentUid: null, + segmentId: null, + range: { start: secondStart, end: secondStart + secondMethod.length } + }, + virtualPath, + virtualRange: { start: secondStart, end: secondStart + secondMethod.length }, + symbolHint: { name: 'dup', kind: 'method' } + } + ] + }; + }, + assertResult(result) { + const first = result.byChunkUid?.['ck64:v1:test:src/dups.ts:first']; + const second = result.byChunkUid?.['ck64:v1:test:src/dups.ts:second']; + assert.ok(first && second, 'expected both targets to resolve'); + assert.equal(first.payload.returnType, 'number'); + assert.equal(second.payload.returnType, 'string'); + } + }, + { + name: 'ambiguous fallback refuses to guess', + strict: false, + setup() { + const docText = ['class A { dup() { return 1; } }', 'class B { dup() { return "x"; } }', ''].join('\n'); + const virtualPath = '.poc-vfs/src/dups.ts#seg:stub.ts'; + return { + documents: [{ + virtualPath, + text: docText, + languageId: 'typescript', + effectiveExt: '.ts' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/dups.ts:ambiguous', + chunkId: 'chunk_ambiguous', + file: 'src/dups.ts', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'dup', kind: 'method' } + }] + }; + }, + assertResult(result) { + assert.ok(!result.byChunkUid?.['ck64:v1:test:src/dups.ts:ambiguous'], 'expected ambiguous fallback to avoid guessing'); + } + }, + { + name: 'destructured parameter names are normalized', + strict: true, + setup() { + const docText = 'function f({ a, b }, [c]) { return a + c; }\n'; + const virtualPath = '.poc-vfs/src/destructure.ts#seg:stub.ts'; + const start = docText.indexOf('function f'); + const end = docText.length; + return { + documents: [{ + virtualPath, + text: docText, + languageId: 'typescript', + effectiveExt: '.ts' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/destructure.ts:one', + chunkId: 'chunk_one', + file: 'src/destructure.ts', + segmentUid: null, + segmentId: null, + range: { start, end } + }, + virtualPath, + virtualRange: { start, end }, + symbolHint: { name: 'f', kind: 'function' } + }] + }; + }, + assertResult(result) { + const entry = result.byChunkUid?.['ck64:v1:test:src/destructure.ts:one']; + assert.ok(entry, 'expected tooling entry'); + const paramTypes = entry.payload?.paramTypes || {}; + assert.ok(paramTypes['{a,b}'], 'expected normalized object pattern param name'); + assert.ok(paramTypes['[c]'], 'expected normalized array pattern param name'); + } + } +]; + +for (const testCase of cases) { + const setup = typeof testCase.setup === 'function' + ? testCase.setup() + : { documents: testCase.documents, targets: testCase.targets }; + const result = await provider.run({ + repoRoot: root, + buildRoot: root, + toolingConfig: baseToolingConfig, + strict: testCase.strict, + logger: () => {} + }, setup); + testCase.assertResult(result); +} + +console.log('TypeScript tooling contract matrix test passed'); diff --git a/tests/tooling/lsp/typescript-provider-output-shape.test.js b/tests/tooling/lsp/typescript-provider-output-shape.test.js deleted file mode 100644 index e16944a22..000000000 --- a/tests/tooling/lsp/typescript-provider-output-shape.test.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { registerDefaultToolingProviders } from '../../../src/index/tooling/providers/index.js'; -import { getToolingProvider } from '../../../src/index/tooling/provider-registry.js'; - -registerDefaultToolingProviders(); -const provider = getToolingProvider('typescript'); -assert.ok(provider, 'expected typescript provider'); - -const ctx = { - repoRoot: process.cwd(), - buildRoot: process.cwd(), - toolingConfig: {}, - logger: () => {}, - strict: true -}; - -const document = { - virtualPath: 'src/one.ts', - effectiveExt: '.ts', - languageId: 'typescript', - text: 'export function alpha(): number { return 1; }\n', - docHash: 'doc-1', - containerPath: 'src/one.ts' -}; - -const target = { - virtualPath: 'src/one.ts', - languageId: 'typescript', - chunkRef: { - chunkUid: 'ck:test:typescript:1', - file: 'src/one.ts', - start: 0, - end: 10 - }, - symbolHint: { name: 'alpha', kind: 'function' } -}; - -const output = await provider.run(ctx, { documents: [document], targets: [target, target] }); -assert.ok(output && typeof output === 'object', 'expected output object'); -assert.ok(output.byChunkUid && typeof output.byChunkUid === 'object', 'expected byChunkUid output'); -assert.ok(!('byFile' in output), 'unexpected byFile key in output'); -const checks = output.diagnostics?.checks || []; -const duplicate = checks.find((check) => check.name === 'duplicate_chunk_uid'); -assert.ok(duplicate, 'expected duplicate chunkUid warning'); -assert.ok( - Array.isArray(duplicate.samples) && duplicate.samples[0]?.startsWith('ck:'), - 'expected duplicate chunkUid samples to be chunk-style ids' -); - -console.log('typescript provider output shape test passed'); diff --git a/tests/tooling/lsp/typescript/typescript-js-parity-basic.test.js b/tests/tooling/lsp/typescript/js-parity-basic.test.js similarity index 100% rename from tests/tooling/lsp/typescript/typescript-js-parity-basic.test.js rename to tests/tooling/lsp/typescript/js-parity-basic.test.js diff --git a/tests/tooling/lsp/typescript/prototype-param-names.test.js b/tests/tooling/lsp/typescript/prototype-param-names.test.js new file mode 100644 index 000000000..326acd18b --- /dev/null +++ b/tests/tooling/lsp/typescript/prototype-param-names.test.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; + +const docText = 'function sink(toString: string, constructor: number, __proto__: boolean) { return toString; }\n'; +const virtualPath = '.poc-vfs/src/prototype-params.ts#seg:stub.ts'; +const documents = [{ + virtualPath, + text: docText, + languageId: 'typescript', + effectiveExt: '.ts' +}]; + +const start = docText.indexOf('function sink'); +const end = docText.length; +const targets = [{ + chunkRef: { + docId: 0, + chunkUid: 'ck64:v1:test:src/prototype-params.ts:one', + chunkId: 'chunk_prototype_params', + file: 'src/prototype-params.ts', + segmentUid: null, + segmentId: null, + range: { start, end } + }, + virtualPath, + virtualRange: { start, end }, + symbolHint: { name: 'sink', kind: 'function' } +}]; + +const provider = createTypeScriptProvider(); +const result = await provider.run({ + repoRoot: process.cwd(), + buildRoot: process.cwd(), + toolingConfig: { + typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } + }, + strict: true, + logger: () => {} +}, { documents, targets }); + +const entry = result.byChunkUid?.[targets[0].chunkRef.chunkUid]; +assert.ok(entry, 'expected tooling entry'); +const paramTypes = entry.payload?.paramTypes; +assert.ok(paramTypes && typeof paramTypes === 'object', 'expected paramTypes object'); +assert.equal(Object.getPrototypeOf(paramTypes), null, 'expected paramTypes map to be null-prototype'); + +for (const key of ['toString', 'constructor', '__proto__']) { + assert.equal(Object.hasOwn(paramTypes, key), true, `expected paramTypes to include key ${key}`); + assert.ok(Array.isArray(paramTypes[key]), `expected paramTypes.${key} to be an array`); + assert.ok(paramTypes[key].length > 0, `expected paramTypes.${key} to include inferred entries`); +} + +assert.equal(paramTypes.toString[0]?.type, 'string'); +assert.equal(paramTypes.constructor[0]?.type, 'number'); +assert.equal(paramTypes.__proto__[0]?.type, 'boolean'); + +console.log('TypeScript prototype param names test passed'); diff --git a/tests/tooling/lsp/typescript/typescript-ambiguous-fallback-does-not-guess.test.js b/tests/tooling/lsp/typescript/typescript-ambiguous-fallback-does-not-guess.test.js deleted file mode 100644 index 9c6e80572..000000000 --- a/tests/tooling/lsp/typescript/typescript-ambiguous-fallback-does-not-guess.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; - -const docText = [ - 'class A { dup() { return 1; } }', - 'class B { dup() { return "x"; } }', - '' -].join('\n'); -const virtualPath = '.poc-vfs/src/dups.ts#seg:stub.ts'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'typescript', - effectiveExt: '.ts' -}]; - -const chunkUid = 'ck64:v1:test:src/dups.ts:ambiguous'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_ambiguous', - file: 'src/dups.ts', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'dup', kind: 'method' } -}]; - -const provider = createTypeScriptProvider(); -const result = await provider.run({ - repoRoot: process.cwd(), - buildRoot: process.cwd(), - toolingConfig: { - typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } - }, - strict: false, - logger: () => {} -}, { documents, targets }); - -assert.ok(!result.byChunkUid?.[chunkUid], 'expected ambiguous fallback to avoid guessing'); - -console.log('TypeScript ambiguous fallback test passed'); diff --git a/tests/tooling/lsp/typescript/typescript-destructured-param-names.test.js b/tests/tooling/lsp/typescript/typescript-destructured-param-names.test.js deleted file mode 100644 index 6acf7a811..000000000 --- a/tests/tooling/lsp/typescript/typescript-destructured-param-names.test.js +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; - -const docText = 'function f({ a, b }, [c]) { return a + c; }\n'; -const virtualPath = '.poc-vfs/src/destructure.ts#seg:stub.ts'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'typescript', - effectiveExt: '.ts' -}]; - -const start = docText.indexOf('function f'); -const end = docText.length; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid: 'ck64:v1:test:src/destructure.ts:one', - chunkId: 'chunk_one', - file: 'src/destructure.ts', - segmentUid: null, - segmentId: null, - range: { start, end } - }, - virtualPath, - virtualRange: { start, end }, - symbolHint: { name: 'f', kind: 'function' } -}]; - -const provider = createTypeScriptProvider(); -const result = await provider.run({ - repoRoot: process.cwd(), - buildRoot: process.cwd(), - toolingConfig: { - typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } - }, - strict: true, - logger: () => {} -}, { documents, targets }); - -const entry = result.byChunkUid?.[targets[0].chunkRef.chunkUid]; -assert.ok(entry, 'expected tooling entry'); -const paramTypes = entry.payload?.paramTypes || {}; -assert.ok(paramTypes['{a,b}'], 'expected normalized object pattern param name'); -assert.ok(paramTypes['[c]'], 'expected normalized array pattern param name'); - -console.log('TypeScript destructured param name test passed'); diff --git a/tests/tooling/lsp/typescript/typescript-node-matching-range.test.js b/tests/tooling/lsp/typescript/typescript-node-matching-range.test.js deleted file mode 100644 index 3788d1980..000000000 --- a/tests/tooling/lsp/typescript/typescript-node-matching-range.test.js +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; - -const docText = [ - 'class A { dup() { return 1; } }', - 'class B { dup() { return "x"; } }', - '' -].join('\n'); -const virtualPath = '.poc-vfs/src/dups.ts#seg:stub.ts'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'typescript', - effectiveExt: '.ts' -}]; - -const firstMethod = 'dup() { return 1; }'; -const secondMethod = 'dup() { return "x"; }'; -const firstStart = docText.indexOf(firstMethod); -const secondStart = docText.indexOf(secondMethod); -const targets = [ - { - chunkRef: { - docId: 0, - chunkUid: 'ck64:v1:test:src/dups.ts:first', - chunkId: 'chunk_first', - file: 'src/dups.ts', - segmentUid: null, - segmentId: null, - range: { start: firstStart, end: firstStart + firstMethod.length } - }, - virtualPath, - virtualRange: { start: firstStart, end: firstStart + firstMethod.length }, - symbolHint: { name: 'dup', kind: 'method' } - }, - { - chunkRef: { - docId: 1, - chunkUid: 'ck64:v1:test:src/dups.ts:second', - chunkId: 'chunk_second', - file: 'src/dups.ts', - segmentUid: null, - segmentId: null, - range: { start: secondStart, end: secondStart + secondMethod.length } - }, - virtualPath, - virtualRange: { start: secondStart, end: secondStart + secondMethod.length }, - symbolHint: { name: 'dup', kind: 'method' } - } -]; - -const provider = createTypeScriptProvider(); -const result = await provider.run({ - repoRoot: process.cwd(), - buildRoot: process.cwd(), - toolingConfig: { - typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } - }, - strict: true, - logger: () => {} -}, { documents, targets }); - -const first = result.byChunkUid?.[targets[0].chunkRef.chunkUid]; -const second = result.byChunkUid?.[targets[1].chunkRef.chunkUid]; -assert.ok(first && second, 'expected both targets to resolve'); -assert.equal(first.payload.returnType, 'number'); -assert.equal(second.payload.returnType, 'string'); - -console.log('TypeScript node range matching test passed'); diff --git a/tests/tooling/lsp/typescript/typescript-vfs-segment-vue.test.js b/tests/tooling/lsp/typescript/typescript-vfs-segment-vue.test.js deleted file mode 100644 index 611b13ee5..000000000 --- a/tests/tooling/lsp/typescript/typescript-vfs-segment-vue.test.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createTypeScriptProvider } from '../../../../src/index/tooling/typescript-provider.js'; - -const docText = 'export function greet(name: string) { return name; }\n'; -const virtualPath = '.poc-vfs/src/App.vue#seg:stub.ts'; -const documents = [{ - virtualPath, - text: docText, - languageId: 'typescript', - effectiveExt: '.ts' -}]; - -const chunkUid = 'ck64:v1:test:src/App.vue:deadbeef'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/App.vue', - segmentUid: 'seg-stub', - segmentId: 'seg-stub', - range: { start: 0, end: docText.length } - }, - virtualPath, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'greet', kind: 'function' } -}]; - -const provider = createTypeScriptProvider(); -const result = await provider.run({ - repoRoot: process.cwd(), - buildRoot: process.cwd(), - toolingConfig: { - typescript: { allowJs: true, checkJs: true, includeJsx: true, useTsconfig: false } - }, - strict: true, - logger: () => {} -}, { documents, targets }); - -const entry = result.byChunkUid?.[chunkUid]; -assert.ok(entry, 'expected TypeScript provider to return an entry for VFS target'); -assert.equal(entry.payload.returnType, 'string'); - -console.log('TypeScript VFS segment test passed'); diff --git a/tests/tooling/lsp/vfs-didopen.test.js b/tests/tooling/lsp/vfs-didopen.test.js new file mode 100644 index 000000000..5921f61d2 --- /dev/null +++ b/tests/tooling/lsp/vfs-didopen.test.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +import { createStubLspCollectFixture } from './helpers/stub-lsp-collect-fixture.js'; + +const { collect, tempRoot } = await createStubLspCollectFixture('lsp-vfs-didopen'); +const tracePath = path.join(tempRoot, 'trace.jsonl'); + +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + await collect('clangd'); +}); + +const events = await parseJsonLinesFile(tracePath); +const didOpenIndex = events.findIndex((evt) => evt.kind === 'notification' && evt.method === 'textDocument/didOpen'); +const documentSymbolIndex = events.findIndex((evt) => evt.kind === 'request' && evt.method === 'textDocument/documentSymbol'); + +assert.ok(didOpenIndex !== -1, 'expected didOpen notification to be recorded'); +assert.ok(documentSymbolIndex !== -1, 'expected documentSymbol request to be recorded'); +assert.ok(didOpenIndex < documentSymbolIndex, 'expected didOpen before documentSymbol'); + +console.log('LSP VFS didOpen ordering test passed'); diff --git a/tests/tooling/lsp/windows-wrapper-argv-forwarding.test.js b/tests/tooling/lsp/windows-wrapper-argv-forwarding.test.js new file mode 100644 index 000000000..cd5d8ee0b --- /dev/null +++ b/tests/tooling/lsp/windows-wrapper-argv-forwarding.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { createLspClient } from '../../../src/integrations/tooling/lsp/client.js'; + +if (process.platform !== 'win32') { + console.log('LSP Windows wrapper argv forwarding test skipped on non-Windows.'); + process.exit(0); +} + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-lsp-win-wrapper-')); +const wrapperPath = path.join(tempRoot, 'stub-lsp.cmd'); +const scriptPath = path.join(tempRoot, 'stub-lsp.js'); +await fs.writeFile(wrapperPath, '@echo off\r\nnode "%~dp0\\stub-lsp.js" --mode wrapper %*\r\n', 'utf8'); +await fs.writeFile(scriptPath, '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8'); + +const spawned = []; +const makeChild = () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const handlers = new Map(); + return { + pid: 1234, + exitCode: null, + killed: false, + stdin, + stdout, + stderr, + on(event, handler) { + const list = handlers.get(event) || []; + list.push(handler); + handlers.set(event, list); + return this; + }, + once(event, handler) { + const wrapped = (...args) => { + this.off(event, wrapped); + handler(...args); + }; + return this.on(event, wrapped); + }, + off(event, handler) { + const list = handlers.get(event) || []; + handlers.set(event, list.filter((entry) => entry !== handler)); + return this; + }, + emit(event, ...args) { + for (const handler of handlers.get(event) || []) handler(...args); + }, + kill() { + this.killed = true; + this.exitCode = 0; + this.emit('exit', 0, null); + this.emit('close', 0, null); + return true; + } + }; +}; + +const client = createLspClient({ + cmd: wrapperPath, + args: ['--stdio'], + cwd: tempRoot, + log: () => {}, + spawnProcess({ cmd, args, options }) { + spawned.push({ cmd, args, options }); + return makeChild(); + } +}); + +try { + client.start(); + assert.equal(spawned.length, 1, 'expected one wrapper-backed child spawn'); + assert.ok(Array.isArray(spawned[0].args), 'expected resolved argv to be forwarded to spawn'); + assert.equal(spawned[0].args.includes('--mode'), true, 'expected fixed wrapper args to be forwarded'); + assert.equal(spawned[0].args.includes('--stdio'), true, 'expected caller args to be forwarded for %* wrappers'); +} finally { + await Promise.resolve(client.kill()); + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('LSP Windows wrapper argv forwarding test passed'); diff --git a/tests/tooling/lsp/workspace-routing.test.js b/tests/tooling/lsp/workspace-routing.test.js new file mode 100644 index 000000000..b297ee02b --- /dev/null +++ b/tests/tooling/lsp/workspace-routing.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveLspWorkspaceRouting } from '../../../src/index/tooling/lsp-workspace-routing.js'; +import { normalizeWorkspaceRootRel } from '../../../src/index/tooling/workspace-model.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `lsp-workspace-routing-${process.pid}-${Date.now()}`); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(path.join(tempRoot, 'svc-a', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'svc-b', 'src'), { recursive: true }); +await fs.mkdir(path.join(tempRoot, 'nested', 'project', 'src'), { recursive: true }); +await fs.writeFile(path.join(tempRoot, 'svc-a', 'app.csproj'), '\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'svc-b', 'app.csproj'), '\n', 'utf8'); +await fs.writeFile(path.join(tempRoot, 'nested', 'project', 'app.csproj'), '\n', 'utf8'); + +assert.equal(normalizeWorkspaceRootRel(''), '.', 'expected empty workspace root to normalize to repo root'); +assert.equal(normalizeWorkspaceRootRel('.'), '.', 'expected dot workspace root to stay repo root'); +assert.equal(normalizeWorkspaceRootRel('\\nested\\project\\'), 'nested/project', 'expected backslash roots to normalize'); +assert.equal(normalizeWorkspaceRootRel('/nested//project/'), 'nested/project', 'expected slashes to normalize'); + +const multiRoot = resolveLspWorkspaceRouting({ + repoRoot: tempRoot, + providerId: 'csharp-ls', + documents: [ + { virtualPath: '.poc-vfs/svc-a/src/a.cs#seg:a', languageId: 'csharp' }, + { virtualPath: '.poc-vfs/svc-b/src/b.cs#seg:b', languageId: 'csharp' } + ], + targets: [ + { + virtualPath: '.poc-vfs/svc-a/src/a.cs#seg:a', + chunkRef: { chunkUid: 'chunk-a' } + }, + { + virtualPath: '.poc-vfs/svc-b/src/b.cs#seg:b', + chunkRef: { chunkUid: 'chunk-b' } + } + ], + workspaceMarkerOptions: { extensionNames: ['.csproj', '.sln'] }, + requireWorkspaceModel: true, + workspaceModelPolicy: 'block' +}); + +assert.equal(multiRoot.state, 'ready', 'expected ready routing state for multi-root selection'); +assert.equal(multiRoot.partitions.length, 2, 'expected two deterministic workspace partitions'); +assert.deepEqual( + multiRoot.partitions.map((entry) => entry.rootRel), + ['svc-a', 'svc-b'], + 'expected deterministic partition roots' +); +assert.equal(multiRoot.workspaceModel.partitioned, true, 'expected partitioned workspace summary'); +assert.equal( + multiRoot.checks.some((check) => check?.name === 'csharp-ls_workspace_partition_multi_root'), + true, + 'expected multi-root info check' +); + +const nested = resolveLspWorkspaceRouting({ + repoRoot: tempRoot, + providerId: 'csharp-ls', + documents: [{ virtualPath: '.poc-vfs/nested/project/src/main.cs#seg:nested', languageId: 'csharp' }], + targets: [{ virtualPath: '.poc-vfs/nested/project/src/main.cs#seg:nested', chunkRef: { chunkUid: 'chunk-nested' } }], + workspaceMarkerOptions: { extensionNames: ['.csproj', '.sln'] }, + requireWorkspaceModel: true, + workspaceModelPolicy: 'block' +}); + +assert.equal(nested.state, 'ready', 'expected nested workspace routing to remain ready'); +assert.equal(nested.partitions.length, 1, 'expected one nested workspace partition'); +assert.equal(nested.partitions[0].rootRel, 'nested/project', 'expected nested workspace root to be selected'); +assert.equal( + nested.checks.some((check) => check?.name === 'csharp-ls_workspace_partition_narrowed'), + true, + 'expected narrowed workspace info check' +); + +const incomplete = resolveLspWorkspaceRouting({ + repoRoot: tempRoot, + providerId: 'csharp-ls', + documents: [ + { virtualPath: '.poc-vfs/svc-a/src/a.cs#seg:a', languageId: 'csharp' }, + { virtualPath: '.poc-vfs/unmatched/src/c.cs#seg:c', languageId: 'csharp' } + ], + targets: [ + { virtualPath: '.poc-vfs/svc-a/src/a.cs#seg:a', chunkRef: { chunkUid: 'chunk-a' } }, + { virtualPath: '.poc-vfs/unmatched/src/c.cs#seg:c', chunkRef: { chunkUid: 'chunk-c' } } + ], + workspaceMarkerOptions: { extensionNames: ['.csproj', '.sln'] }, + requireWorkspaceModel: true, + workspaceModelPolicy: 'block' +}); + +assert.equal(incomplete.state, 'degraded', 'expected unmatched documents to degrade routing state'); +assert.equal(incomplete.partitions.length, 1, 'expected matched workspace partition to remain runnable'); +assert.equal(incomplete.workspaceModel.unmatchedDocumentCount, 1, 'expected unmatched document count in summary'); +assert.equal( + incomplete.checks.some((check) => check?.name === 'csharp-ls_workspace_partition_incomplete'), + true, + 'expected incomplete routing warning check' +); + +console.log('LSP workspace routing test passed'); diff --git a/tests/tooling/lsp/writer-closed-restart-reaps-stale-process.test.js b/tests/tooling/lsp/writer-closed-restart-reaps-stale-process.test.js new file mode 100644 index 000000000..7b64c935b --- /dev/null +++ b/tests/tooling/lsp/writer-closed-restart-reaps-stale-process.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createStaleProcessRestartHarness, + sleep +} from './helpers/stale-process-restart-harness.js'; + +const { client, lifecycleEvents, spawnedChildren, startWithBackoffRetry } = + createStaleProcessRestartHarness(); + +try { + client.start(); + assert.equal(spawnedChildren.length, 1, 'expected initial fake child spawn'); + + const firstChild = spawnedChildren[0]; + firstChild.stdin.emit('close'); + await sleep(25); + + await startWithBackoffRetry(); + assert.equal(spawnedChildren.length, 2, 'expected replacement child spawn after stale writer close'); + assert.ok( + firstChild.killed || firstChild.exitCode !== null || firstChild.signalCode !== null, + 'expected stale writer-closed child to be considered terminated before restart' + ); + + const staleReapEvent = lifecycleEvents.find( + (event) => ( + String(event.reason || '').startsWith('writer_closed') + && (event.kind === 'reap' || event.kind === 'kill_diagnostics') + ) + ); + if (staleReapEvent) { + assert.ok( + staleReapEvent.kind === 'reap' || staleReapEvent.kind === 'kill_diagnostics', + 'expected writer-closed lifecycle event to represent stale-process cleanup' + ); + } +} finally { + await Promise.resolve(client.kill()); +} + +console.log('LSP writer-closed restart stale-process reap test passed'); diff --git a/tests/tooling/navigation-query.test.js b/tests/tooling/navigation-query.test.js new file mode 100644 index 000000000..7294b2a78 --- /dev/null +++ b/tests/tooling/navigation-query.test.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getRepoCacheRoot } from '../../tools/dict-utils/paths/repo.js'; +import { queryNavigationData } from '../../tools/tooling/navigation.js'; +import { runNode } from '../helpers/run-node.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-navigation-query-')); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const outsideRoot = path.join(tempRoot, 'outside'); +const srcDir = path.join(repoRoot, 'src'); +await fs.mkdir(srcDir, { recursive: true }); +await fs.mkdir(outsideRoot, { recursive: true }); +await fs.writeFile( + path.join(repoRoot, '.pairofcleats.json'), + JSON.stringify({ + cache: { + root: cacheRoot + } + }, null, 2) +); + +const defsSource = 'export function WidgetBuilder() {\n return 1;\n}\nWidgetBuilder();\n'; +const refsSource = 'import { WidgetBuilder } from "./defs.js";\nWidgetBuilder();\n'; +await fs.writeFile(path.join(srcDir, 'defs.js'), defsSource); +await fs.writeFile(path.join(srcDir, 'refs.js'), refsSource); + +const repoCacheRoot = getRepoCacheRoot(repoRoot); +const indexDir = path.join(repoCacheRoot, 'index-code'); +await fs.mkdir(indexDir, { recursive: true }); + +const secondRefStart = refsSource.lastIndexOf('WidgetBuilder'); +const secondRefEnd = secondRefStart + 'WidgetBuilder'.length; + +await fs.writeFile( + path.join(indexDir, 'chunk_meta.json'), + JSON.stringify([ + { + id: 1, + start: 0, + end: defsSource.length, + file: path.join(srcDir, 'defs.js'), + virtualPath: 'src/defs.js', + startLine: 1, + endLine: 3, + kind: 'FunctionDeclaration', + name: 'WidgetBuilder', + chunkUid: 'chunk-defs' + }, + { + id: 2, + start: 0, + end: refsSource.length, + file: path.join(srcDir, 'refs.js'), + virtualPath: 'src/refs.js', + startLine: 1, + endLine: 2, + kind: 'CallExpression', + name: 'WidgetBuilder', + chunkUid: 'chunk-refs' + } + ], null, 2) +); + +await fs.writeFile( + path.join(indexDir, 'symbols.json'), + JSON.stringify([ + { + v: 1, + symbolId: 'sym:WidgetBuilder', + scopedId: 'scope:WidgetBuilder', + symbolKey: 'WidgetBuilder', + qualifiedName: 'demo.WidgetBuilder', + kindGroup: 'function', + file: path.join(srcDir, 'defs.js'), + virtualPath: 'src/defs.js', + chunkUid: 'chunk-defs', + kind: 'FunctionDeclaration', + name: 'WidgetBuilder' + }, + { + v: 1, + symbolId: 'sym:helper', + scopedId: 'scope:helper', + symbolKey: 'helper', + qualifiedName: 'demo.helper', + kindGroup: 'function', + file: path.join(srcDir, 'refs.js'), + virtualPath: 'src/refs.js', + chunkUid: 'chunk-refs', + kind: 'FunctionDeclaration', + name: 'helper' + } + ], null, 2) +); + +await fs.writeFile( + path.join(indexDir, 'symbol_occurrences.json'), + JSON.stringify([ + { + v: 1, + host: { + file: path.join(srcDir, 'refs.js'), + chunkUid: 'chunk-refs' + }, + role: 'call', + ref: { + status: 'resolved', + resolved: { + symbolId: 'sym:WidgetBuilder', + scopedId: 'scope:WidgetBuilder', + symbolKey: 'WidgetBuilder' + } + }, + range: { + start: secondRefStart, + end: secondRefEnd + } + } + ], null, 2) +); + +const definitions = await queryNavigationData({ + repoRoot, + kind: 'definitions', + query: 'WidgetBuilder', + filePath: path.join(srcDir, 'refs.js'), + limit: 10 +}); +assert.equal(definitions.ok, true); +assert.equal(definitions.results.length, 1); +assert.equal(definitions.results[0].virtualPath, 'src/defs.js'); +assert.equal(definitions.results[0].startLine, 1); + +const references = await queryNavigationData({ + repoRoot, + kind: 'references', + query: 'WidgetBuilder', + filePath: path.join(srcDir, 'refs.js'), + limit: 10 +}); +assert.equal(references.ok, true); +assert.equal(references.results.length, 1); +assert.equal(references.results[0].virtualPath, 'src/refs.js'); +assert.equal(references.results[0].startLine, 2); +assert.equal(references.results[0].startCol, 1); + +const documentSymbols = await queryNavigationData({ + repoRoot, + kind: 'document-symbols', + filePath: path.join(srcDir, 'defs.js'), + limit: 10 +}); +assert.equal(documentSymbols.ok, true); +assert.equal(documentSymbols.results.length, 1); +assert.equal(documentSymbols.results[0].name, 'WidgetBuilder'); +assert.equal(documentSymbols.results[0].virtualPath, 'src/defs.js'); + +const repoRelativeDocumentSymbols = await queryNavigationData({ + repoRoot, + kind: 'document-symbols', + filePath: 'src/defs.js', + limit: 10 +}); +assert.equal(repoRelativeDocumentSymbols.ok, true); +assert.equal(repoRelativeDocumentSymbols.results.length, 1); +assert.equal(repoRelativeDocumentSymbols.results[0].virtualPath, 'src/defs.js'); + +const completions = await queryNavigationData({ + repoRoot, + kind: 'completions', + query: 'Wid', + filePath: path.join(srcDir, 'refs.js'), + limit: 10 +}); +assert.equal(completions.ok, true); +assert.equal(completions.results.length, 1); +assert.equal(completions.results[0].name, 'WidgetBuilder'); +assert.equal(completions.results[0].virtualPath, 'src/defs.js'); + +const cliPath = path.join(process.cwd(), 'bin', 'pairofcleats.js'); +const cliReferences = runNode( + [cliPath, 'tooling', 'navigate', '--repo', repoRoot, '--kind', 'references', '--symbol', 'WidgetBuilder', '--file', 'src/refs.js', '--top', '10', '--json'], + 'tooling navigate references CLI', + outsideRoot, + process.env, + { stdio: 'pipe', allowFailure: true } +); +assert.equal(cliReferences.status, 0, cliReferences.stderr || 'expected CLI tooling navigate to succeed'); +const cliPayload = JSON.parse(cliReferences.stdout || '{}'); +assert.equal(cliPayload.ok, true); +assert.equal(cliPayload.results.length, 1); +assert.equal(cliPayload.results[0].virtualPath, 'src/refs.js'); +assert.equal(cliPayload.results[0].startLine, 2); +assert.equal(cliPayload.results[0].startCol, 1); + +console.log('navigation query test passed'); diff --git a/tests/tooling/path-within-root.test.js b/tests/tooling/path-within-root.test.js index 0231a1a2a..1547697da 100644 --- a/tests/tooling/path-within-root.test.js +++ b/tests/tooling/path-within-root.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import path from 'node:path'; -import { isPathWithinRoot } from '../../tools/shared/path-within-root.js'; +import { isPathWithinRoot } from '../../src/shared/file-paths.js'; const root = path.resolve('tmp-root', 'cache'); const child = path.join(root, 'builds', 'run-1'); diff --git a/tests/tooling/paths/current-build-repo-root-pointer-prefers-active-root.test.js b/tests/tooling/paths/current-build-repo-root-pointer-prefers-active-root.test.js new file mode 100644 index 000000000..012f5e176 --- /dev/null +++ b/tests/tooling/paths/current-build-repo-root-pointer-prefers-active-root.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + getCurrentBuildInfo, + getRepoCacheRoot, + resolveCurrentBuildModeRoot, + resolveIndexRoot +} from '../../../tools/shared/dict-utils.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'current-build-repo-root-pointer-prefers-active-root'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +const userConfig = { cache: { root: cacheRoot } }; + +const normalizePath = (value) => { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(repoRoot, { recursive: true }); + +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const buildsRoot = path.join(repoCacheRoot, 'builds'); +const buildId = '20260321T000000Z_active'; +const activeRoot = path.join(buildsRoot, buildId); + +await fs.mkdir(path.join(activeRoot, 'index-code'), { recursive: true }); +await fs.writeFile(path.join(activeRoot, 'index-code', 'chunk_meta.jsonl.gz'), '', 'utf8'); +await fs.writeFile( + path.join(buildsRoot, 'current.json'), + JSON.stringify({ + buildId, + buildRoot: '.', + buildRootsByMode: { + code: '.' + } + }, null, 2), + 'utf8' +); + +const currentInfo = getCurrentBuildInfo(repoRoot, userConfig, { mode: 'code' }); +assert.ok(currentInfo, 'expected current build info to resolve'); +assert.equal( + normalizePath(currentInfo.activeRoot), + normalizePath(activeRoot), + 'expected activeRoot to recover the generation-local build root' +); + +const resolvedIndexRoot = resolveIndexRoot(repoRoot, userConfig, { mode: 'code' }); +assert.equal( + normalizePath(resolvedIndexRoot), + normalizePath(activeRoot), + 'expected resolveIndexRoot to prefer activeRoot over repo-root pointers' +); + +const modeResolution = resolveCurrentBuildModeRoot(repoRoot, userConfig, { + mode: 'code', + requireArtifacts: true, + disallowRepoRootFallback: true +}); +assert.equal(modeResolution.ok, true, 'expected structured mode resolution to succeed'); +assert.equal(modeResolution.source, 'active-root', 'expected mode resolution to attribute selection to activeRoot'); +assert.equal(modeResolution.scope, 'active-generation', 'expected active generation scope'); +assert.equal( + normalizePath(modeResolution.root), + normalizePath(activeRoot), + 'expected structured mode resolution to return the generation-local active root' +); + +console.log('current build repo-root pointer prefers active-root test passed'); diff --git a/tests/tooling/paths/windows-paths-smoke.test.js b/tests/tooling/paths/windows-paths-smoke.test.js deleted file mode 100644 index 49b7bd880..000000000 --- a/tests/tooling/paths/windows-paths-smoke.test.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { isUncPath } from '../../../src/shared/files.js'; -import { normalizePathForPlatform } from '../../../src/shared/path-normalize.js'; - -const mixed = 'c:/workspace\\repo//src\\index.js'; -const normalized = normalizePathForPlatform(mixed, { platform: 'win32' }); -assert.equal(normalized, 'C:\\workspace\\repo\\src\\index.js', 'expected mixed separators to normalize for win32'); - -const unc = '\\\\server/share\\repo//index.json'; -const normalizedUnc = normalizePathForPlatform(unc, { platform: 'win32' }); -assert.ok(normalizedUnc.startsWith('\\\\server\\share\\repo'), 'expected UNC prefix to be preserved'); -assert.equal(isUncPath(normalizedUnc), true, 'expected UNC detection to succeed'); - -console.log('windows paths smoke test passed'); diff --git a/tests/tooling/paths/windows-smoke.test.js b/tests/tooling/paths/windows-smoke.test.js new file mode 100644 index 000000000..0db6cd36a --- /dev/null +++ b/tests/tooling/paths/windows-smoke.test.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { isUncPath } from '../../../src/shared/file-paths.js'; +import { normalizePathForPlatform } from '../../../src/shared/path-normalize.js'; + +const mixed = 'c:/workspace\\repo//src\\index.js'; +const normalized = normalizePathForPlatform(mixed, { platform: 'win32' }); +assert.equal(normalized, 'C:\\workspace\\repo\\src\\index.js', 'expected mixed separators to normalize for win32'); + +const unc = '\\\\server/share\\repo//index.json'; +const normalizedUnc = normalizePathForPlatform(unc, { platform: 'win32' }); +assert.ok(normalizedUnc.startsWith('\\\\server\\share\\repo'), 'expected UNC prefix to be preserved'); +assert.equal(isUncPath(normalizedUnc), true, 'expected UNC detection to succeed'); + +console.log('windows paths smoke test passed'); diff --git a/tests/tooling/paths/paths-with-spaces.test.js b/tests/tooling/paths/with-spaces.test.js similarity index 100% rename from tests/tooling/paths/paths-with-spaces.test.js rename to tests/tooling/paths/with-spaces.test.js diff --git a/tests/tooling/providers/provider-output-contract-normalization.test.js b/tests/tooling/providers/provider-output-contract-normalization.test.js new file mode 100644 index 000000000..30089c49e --- /dev/null +++ b/tests/tooling/providers/provider-output-contract-normalization.test.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + createProviderPayloadRecord, + MAX_PARAM_CANDIDATES, + normalizeProviderPayload +} from '../../../src/index/tooling/provider-output-contract.js'; + +const observations = []; +const dropped = normalizeProviderPayload('not-an-object', { + observations, + providerId: 'shape-contract', + chunkUid: 'chunk-a' +}); +assert.deepEqual(dropped, createProviderPayloadRecord(), 'expected non-object payloads to normalize to empty object'); +assert.equal(Object.getPrototypeOf(dropped), null, 'expected normalized payload record to use null prototype'); +assert.equal( + observations.some((entry) => entry?.code === 'tooling_payload_shape_invalid'), + true, + 'expected invalid payload shape observation' +); + +const invalidParamTypesObs = []; +const invalidParamTypes = normalizeProviderPayload({ + returnType: ' number ', + signature: ' add(a:number): number ', + paramTypes: ['bad'] +}, { + observations: invalidParamTypesObs, + providerId: 'shape-contract', + chunkUid: 'chunk-b' +}); +assert.equal(invalidParamTypes.returnType, 'number'); +assert.equal(invalidParamTypes.signature, 'add(a:number): number'); +assert.equal( + invalidParamTypesObs.some((entry) => entry?.code === 'tooling_payload_paramtypes_invalid'), + true, + 'expected invalid paramTypes observation' +); + +const overflowingTypes = Array.from({ length: MAX_PARAM_CANDIDATES + 3 }, (_, idx) => ({ + type: `T${idx + 1}`, + confidence: idx / 10, + source: 'shape-contract' +})); +const cappedObs = []; +const capped = normalizeProviderPayload({ + paramTypes: { + zed: [{ type: 'string', source: 'shape-contract' }], + arg: overflowingTypes + } +}, { + observations: cappedObs, + providerId: 'shape-contract', + chunkUid: 'chunk-c' +}); +assert.equal( + Object.getPrototypeOf(capped.paramTypes), + null, + 'expected paramTypes map to use null prototype' +); +assert.equal( + Array.isArray(capped.paramTypes.arg), + true, + 'expected normalized param type list' +); +assert.equal( + capped.paramTypes.arg[0]?.originalText, + 'T1', + 'expected original type text preservation' +); +assert.equal( + typeof capped.paramTypes.arg[0]?.normalizedType, + 'string', + 'expected canonical type text on normalized entry' +); +assert.deepEqual( + Object.keys(capped.paramTypes), + ['arg', 'zed'], + 'expected paramTypes keys to be ordered deterministically' +); +assert.equal( + capped.paramTypes.arg.length, + MAX_PARAM_CANDIDATES, + 'expected param type list to be capped deterministically' +); +assert.equal( + cappedObs.some((entry) => entry?.code === 'tooling_param_types_truncated'), + true, + 'expected truncation observation' +); + +console.log('provider output contract normalization test passed'); diff --git a/tests/tooling/providers/provider-registry-array-records.test.js b/tests/tooling/providers/provider-registry-array-records.test.js index 831d360a1..c0180a0c3 100644 --- a/tests/tooling/providers/provider-registry-array-records.test.js +++ b/tests/tooling/providers/provider-registry-array-records.test.js @@ -5,9 +5,21 @@ import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js' TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'throws-provider', + version: '1.0.0', + priority: 1, + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'throws-provider-hash', + async run() { + throw new Error('simulated provider failure'); + } +}); + registerToolingProvider({ id: 'array-records', version: '1.0.0', + priority: 2, capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, getConfigHash: () => 'array-records-hash', async run() { @@ -52,5 +64,17 @@ const result = await runToolingProviders({ const merged = result.byChunkUid.get(chunkUid); assert.ok(merged, 'expected array tuple byChunkUid payload to resolve to actual chunkUid'); assert.equal(merged.payload.returnType, 'number'); +assert.equal( + Array.isArray(result.degradedProviders) + && result.degradedProviders.some((entry) => entry?.providerId === 'throws-provider'), + true, + 'expected thrown provider to be reported in degraded providers' +); +assert.equal( + Array.isArray(result.observations) + && result.observations.some((entry) => entry?.code === 'tooling_provider_execution_failed'), + true, + 'expected provider execution failure observation' +); console.log('tooling provider array-record payload test passed'); diff --git a/tests/tooling/providers/provider-registry-cache-build-root-key.test.js b/tests/tooling/providers/provider-registry-cache-build-root-key.test.js new file mode 100644 index 000000000..360eb8da8 --- /dev/null +++ b/tests/tooling/providers/provider-registry-cache-build-root-key.test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +const tempRoot = await makeTempDir('poc-tooling-cache-build-root-'); +const cacheDir = path.join(tempRoot, 'tooling-cache'); + +const target = { + chunkRef: { + chunkUid: 'chunk-build-root', + chunkId: 'chunk-build-root-id', + file: 'src/sample.js', + start: 0, + end: 10 + }, + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } +}; + +const makeCtx = (buildRoot) => ({ + strict: true, + repoRoot: tempRoot, + buildRoot, + mode: 'code', + toolingConfig: {}, + cache: { + enabled: true, + dir: cacheDir, + maxEntries: 100, + maxBytes: 4 * 1024 * 1024 + } +}); + +const documents = [{ + virtualPath: 'src/sample.js', + docHash: 'doc-hash-build-root', + languageId: 'javascript', + text: 'function a() {}' +}]; + +let runCount = 0; +TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'stub', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'cfg-v1', + async run() { + runCount += 1; + return { + byChunkUid: { + 'chunk-build-root': { + payload: { returnType: `T_${runCount}` } + } + } + }; + } +}); + +try { + const buildRootA = path.join(tempRoot, 'builds', 'run-a', 'index-code'); + const buildRootB = path.join(tempRoot, 'builds', 'run-b', 'index-code'); + + await runToolingProviders(makeCtx(buildRootA), { + documents, + targets: [target], + kinds: ['types'] + }); + await runToolingProviders(makeCtx(buildRootB), { + documents, + targets: [target], + kinds: ['types'] + }); + + assert.equal(runCount, 2, 'expected cache key to vary across build roots'); + + console.log('tooling provider cache build-root key test passed'); +} finally { + TOOLING_PROVIDERS.clear(); + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/providers/provider-registry-cache-runtime-envelope-split.test.js b/tests/tooling/providers/provider-registry-cache-runtime-envelope-split.test.js new file mode 100644 index 000000000..701784430 --- /dev/null +++ b/tests/tooling/providers/provider-registry-cache-runtime-envelope-split.test.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', 'provider-cache-runtime-envelope-split'); +const cacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(cacheDir, { recursive: true }); + +const chunkUid = 'chunk-runtime-split'; +const target = { + chunkRef: { + chunkUid, + chunkId: 'chunk-runtime-split-id', + file: 'src/sample.js', + start: 0, + end: 24 + }, + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 24 }, + symbolHint: { + name: 'alpha', + kind: 'function' + } +}; + +const baseCtx = { + strict: true, + toolingConfig: {}, + cache: { + enabled: true, + dir: cacheDir, + maxEntries: 100, + maxBytes: 4 * 1024 * 1024 + } +}; + +const documents = [{ + virtualPath: 'src/sample.js', + docHash: 'doc-hash-runtime-split', + languageId: 'javascript', + text: 'function alpha() { return 1; }\n' +}]; + +let runCount = 0; +TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'stub', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'cfg-v1', + async run() { + runCount += 1; + return { + byChunkUid: { + [chunkUid]: { + payload: { returnType: 'number' } + } + }, + diagnostics: { + checks: [{ + name: 'stub_deterministic_check', + status: 'info', + message: 'deterministic-check' + }], + runtime: { + command: `stub-runtime-${runCount}`, + requests: { requests: runCount } + } + } + }; + } +}); + +try { + const first = await runToolingProviders(baseCtx, { + documents, + targets: [target], + kinds: ['types'] + }); + assert.equal(runCount, 1, 'expected first run to execute provider'); + assert.equal( + first.diagnostics?.stub?.runtime?.command, + 'stub-runtime-1', + 'expected live runtime envelope on first run' + ); + assert.equal( + first.diagnostics?.stub?.diagnosticsSource, + 'live', + 'expected live diagnostics source marker on first run' + ); + + const second = await runToolingProviders(baseCtx, { + documents, + targets: [target], + kinds: ['types'] + }); + assert.equal(runCount, 1, 'expected second run to use cache'); + assert.equal( + second.diagnostics?.stub?.runtime == null, + true, + 'expected cached diagnostics to exclude stale runtime envelope' + ); + assert.equal( + second.diagnostics?.stub?.checks?.[0]?.name, + 'stub_deterministic_check', + 'expected deterministic checks to remain available on cache hits' + ); + assert.equal( + second.diagnostics?.stub?.diagnosticsSource, + 'cache-suppressed', + 'expected cache-hit diagnostics source marker' + ); + + console.log('tooling provider cache runtime envelope split test passed'); +} finally { + TOOLING_PROVIDERS.clear(); + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/providers/provider-registry-cache-target-symbol-sensitivity.test.js b/tests/tooling/providers/provider-registry-cache-target-symbol-sensitivity.test.js new file mode 100644 index 000000000..57ab171b5 --- /dev/null +++ b/tests/tooling/providers/provider-registry-cache-target-symbol-sensitivity.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +const root = process.cwd(); +const tempRoot = path.join(root, '.testLogs', 'provider-cache-target-symbol-sensitivity'); +const cacheDir = path.join(tempRoot, 'tooling-cache'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(cacheDir, { recursive: true }); + +const chunkUid = 'chunk-symbol-sensitive'; +const makeTarget = (symbolName) => ({ + chunkRef: { + chunkUid, + chunkId: 'chunk-symbol-sensitive-id', + file: 'src/sample.js', + start: 0, + end: 32 + }, + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 32 }, + symbolHint: { + name: symbolName, + kind: 'function' + } +}); + +const baseCtx = { + strict: true, + toolingConfig: {}, + cache: { + enabled: true, + dir: cacheDir, + maxEntries: 100, + maxBytes: 4 * 1024 * 1024 + } +}; + +const documents = [{ + virtualPath: 'src/sample.js', + docHash: 'doc-hash-1', + languageId: 'javascript', + text: 'function alpha() { return 1; }\n' +}]; + +let runCount = 0; +TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'stub', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'cfg-v1', + async run(_ctx, inputs) { + runCount += 1; + const firstTarget = Array.isArray(inputs?.targets) ? inputs.targets[0] : null; + const symbolName = String(firstTarget?.symbolHint?.name || 'unknown'); + return { + byChunkUid: { + [chunkUid]: { + payload: { + returnType: `T_${symbolName}` + } + } + } + }; + } +}); + +try { + const first = await runToolingProviders(baseCtx, { + documents, + targets: [makeTarget('alpha')], + kinds: ['types'] + }); + assert.equal( + first.byChunkUid.get(chunkUid)?.payload?.returnType, + 'T_alpha', + 'expected first symbol payload' + ); + + const second = await runToolingProviders(baseCtx, { + documents, + targets: [makeTarget('beta')], + kinds: ['types'] + }); + + assert.equal(runCount, 2, 'expected symbol-sensitive cache key to force rerun'); + assert.equal( + second.byChunkUid.get(chunkUid)?.payload?.returnType, + 'T_beta', + 'expected second symbol payload without stale cache reuse' + ); + + console.log('tooling provider cache target symbol sensitivity test passed'); +} finally { + TOOLING_PROVIDERS.clear(); + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/providers/provider-registry-cache-targets-key.test.js b/tests/tooling/providers/provider-registry-cache-targets-key.test.js new file mode 100644 index 000000000..f2963224c --- /dev/null +++ b/tests/tooling/providers/provider-registry-cache-targets-key.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +const tempRoot = await makeTempDir('poc-tooling-cache-targets-'); +const cacheDir = path.join(tempRoot, 'tooling-cache'); + +const makeTarget = (chunkUid, chunkId) => ({ + chunkRef: { + chunkUid, + chunkId, + file: 'src/sample.js', + start: 0, + end: 10 + }, + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } +}); + +const baseCtx = { + strict: true, + toolingConfig: {}, + cache: { + enabled: true, + dir: cacheDir, + maxEntries: 100, + maxBytes: 4 * 1024 * 1024 + } +}; + +const documents = [{ + virtualPath: 'src/sample.js', + docHash: 'doc-hash-1', + languageId: 'javascript', + text: 'function a() {}' +}]; + +let runCount = 0; +TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'stub', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'cfg-v1', + async run(_ctx, inputs) { + runCount += 1; + const byChunkUid = Object.create(null); + for (const target of inputs.targets || []) { + const chunkUid = target?.chunkRef?.chunkUid; + if (!chunkUid) continue; + byChunkUid[chunkUid] = { + payload: { + returnType: `T_${chunkUid}` + } + }; + } + return { byChunkUid }; + } +}); + +try { + const first = await runToolingProviders(baseCtx, { + documents, + targets: [makeTarget('chunk-a', 'chunk-a-id')], + kinds: ['types'] + }); + assert.ok(first.byChunkUid.has('chunk-a'), 'expected first run to include chunk-a'); + + const second = await runToolingProviders(baseCtx, { + documents, + targets: [makeTarget('chunk-b', 'chunk-b-id')], + kinds: ['types'] + }); + + assert.equal(runCount, 2, 'expected provider to re-run for different target sets'); + assert.ok(second.byChunkUid.has('chunk-b'), 'expected second run to include chunk-b'); + assert.ok(!second.byChunkUid.has('chunk-a'), 'expected cached output for chunk-a not to leak into chunk-b run'); + + console.log('tooling provider cache targets key test passed'); +} finally { + TOOLING_PROVIDERS.clear(); + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/providers/provider-registry-contract-matrix.test.js b/tests/tooling/providers/provider-registry-contract-matrix.test.js new file mode 100644 index 000000000..0d3919dbb --- /dev/null +++ b/tests/tooling/providers/provider-registry-contract-matrix.test.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; +import { awaitToolingProviderPreflight } from '../../../src/index/tooling/preflight-manager.js'; +import { + TOOLING_PROVIDERS, + registerToolingProvider, + selectToolingProviders +} from '../../../src/index/tooling/provider-registry.js'; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const buildInputs = (chunkUid = 'ck64:v1:test:src/sample.js:deadbeef') => ({ + documents: [{ + virtualPath: 'src/sample.js', + languageId: 'javascript', + effectiveExt: '.js', + docHash: 'doc-hash-1', + text: 'function demo() {}' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_deadbeef', + file: 'src/sample.js', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 10 } + }, + name: 'greet', + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } + }] +}); + +const resetProviders = () => TOOLING_PROVIDERS.clear(); + +const runSelectionCases = () => { + resetProviders(); + const makeProvider = (id, priority, extra = {}) => registerToolingProvider({ + id, + version: '1.0.0', + priority, + capabilities: { supportsVirtualDocuments: true }, + getConfigHash: () => id, + async run() { + return { byChunkUid: {} }; + }, + ...extra + }); + + makeProvider('beta', 10); + makeProvider('alpha', 5); + makeProvider('gamma', 5); + makeProvider('typed', 7, { kinds: ['types'] }); + makeProvider('untyped', 8); + + const inputs = buildInputs('ck64:v1:test:src/sample.js:demo'); + + const defaultPlans = selectToolingProviders({ toolingConfig: {}, ...inputs }); + assert.deepEqual(defaultPlans.map((plan) => plan.provider.id), ['alpha', 'gamma', 'typed', 'untyped', 'beta']); + + const overridePlans = selectToolingProviders({ + toolingConfig: { providerOrder: ['beta', 'alpha'] }, + ...inputs + }); + assert.deepEqual(overridePlans.slice(0, 2).map((plan) => plan.provider.id), ['beta', 'alpha']); + + const gatedPlans = selectToolingProviders({ + toolingConfig: { enabledTools: ['beta'], disabledTools: ['alpha'] }, + ...inputs + }); + assert.equal(gatedPlans.length, 1); + assert.equal(gatedPlans[0].provider.id, 'beta'); + + const kindFilteredPlans = selectToolingProviders({ + toolingConfig: { enabledTools: ['typed', 'untyped'] }, + kinds: ['types'], + ...inputs + }); + assert.deepEqual(kindFilteredPlans.map((plan) => plan.provider.id), ['typed', 'untyped']); +}; + +const runMergeAndLegacyCases = async () => { + resetProviders(); + const chunkUid = 'ck64:v1:test:src/sample.js:deadbeef'; + registerToolingProvider({ + id: 'alpha', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash-alpha', + async run() { + return { + byChunkUid: { + [chunkUid]: { + payload: { returnType: 'number' } + } + } + }; + } + }); + registerToolingProvider({ + id: 'beta', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash-beta', + async run() { + return { + byChunkUid: { + [chunkUid]: { + payload: { + returnType: 'string', + paramTypes: { + x: [{ type: 'number', confidence: 0.8, source: 'tooling' }] + } + } + } + } + }; + } + }); + registerToolingProvider({ + id: 'legacy-stub', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash-legacy', + async run() { + return { + byLegacyKey: { + 'src/sample.js::greet': { payload: { returnType: 'ignored' } } + } + }; + } + }); + + const result = await runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false } + }, buildInputs(chunkUid), ['alpha', 'beta', 'legacy-stub']); + + const merged = result.byChunkUid.get(chunkUid); + assert.ok(merged, 'expected merged tooling entry'); + assert.equal(merged.payload.returnType, 'number'); + assert.ok(Array.isArray(merged.payload.paramTypes?.x)); + assert.equal(result.byChunkUid.has('src/sample.js::greet'), false); +}; + +const runStrictMissingChunkUidCase = async () => { + resetProviders(); + registerToolingProvider({ + id: 'stub', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash', + async run() { + return { byChunkUid: {} }; + } + }); + + await assert.rejects( + () => runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false } + }, { + documents: [], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: null, + chunkId: 'chunk_deadbeef', + file: 'src/sample.js', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 10 } + }, + name: 'greet', + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } + }] + }), + /./, + 'expected strict mode to reject missing chunkUid' + ); +}; + +const runPreflightOverlapCase = async () => { + resetProviders(); + const registerFixtureProvider = (id, priority) => { + const provider = { + id, + version: '1.0.0', + priority, + preflightId: `${id}.workspace-model`, + preflightClass: 'workspace', + capabilities: { + supportsVirtualDocuments: true, + supportsSegmentRouting: true + }, + getConfigHash() { + return `${id}-hash`; + }, + async preflight() { + await wait(60); + return { state: 'ready' }; + }, + async run(ctx, inputs) { + await awaitToolingProviderPreflight(ctx, { + provider, + inputs, + waveToken: typeof inputs?.toolingPreflightWaveToken === 'string' + ? inputs.toolingPreflightWaveToken + : null + }); + return { byChunkUid: {} }; + } + }; + registerToolingProvider(provider); + }; + + registerFixtureProvider('preflight-alpha', 5); + registerFixtureProvider('preflight-beta', 10); + + const logs = []; + const result = await runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false }, + logger: (line) => logs.push(String(line || '')) + }, { + documents: [{ + virtualPath: 'src/sample.fixture', + languageId: 'fixture', + docHash: 'doc-hash-1' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'chunk-1', + chunkId: 'chunk-1', + file: 'src/sample.fixture', + range: { start: 0, end: 1 } + }, + virtualPath: 'src/sample.fixture', + virtualRange: { start: 0, end: 1 } + }] + }); + + assert.equal(Number(result.metrics?.preflights?.total || 0), 2); + const alphaStartIndex = logs.findIndex((line) => line.includes('preflight:start provider=preflight-alpha')); + const betaStartIndex = logs.findIndex((line) => line.includes('preflight:start provider=preflight-beta')); + const alphaDoneIndex = logs.findIndex((line) => line.includes('provider 1/2 done id=preflight-alpha')); + assert.notEqual(alphaStartIndex, -1); + assert.notEqual(betaStartIndex, -1); + assert.notEqual(alphaDoneIndex, -1); + assert.equal(betaStartIndex < alphaDoneIndex, true); +}; + +await runSelectionCases(); +await runMergeAndLegacyCases(); +await runStrictMissingChunkUidCase(); +await runPreflightOverlapCase(); +console.log('tooling provider registry contract matrix test passed'); diff --git a/tests/tooling/providers/provider-registry-gating.test.js b/tests/tooling/providers/provider-registry-gating.test.js deleted file mode 100644 index 81a39bffc..000000000 --- a/tests/tooling/providers/provider-registry-gating.test.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { TOOLING_PROVIDERS, registerToolingProvider, selectToolingProviders } from '../../../src/index/tooling/provider-registry.js'; - -TOOLING_PROVIDERS.clear(); - -const makeProvider = (id) => registerToolingProvider({ - id, - version: '1.0.0', - capabilities: { supportsVirtualDocuments: true }, - getConfigHash: () => id, - async run() { - return { byChunkUid: {} }; - } -}); - -makeProvider('alpha'); -makeProvider('beta'); - -const documents = [{ - virtualPath: 'src/sample.js', - languageId: 'javascript', - effectiveExt: '.js', - text: 'function demo() {}' -}]; -const targets = [{ - virtualPath: 'src/sample.js', - languageId: 'javascript', - virtualRange: { start: 0, end: 1 }, - chunkRef: { chunkUid: 'ck64:v1:test:src/sample.js:demo', chunkId: 'chunk_demo', file: 'src/sample.js' } -}]; - -const plans = selectToolingProviders({ - toolingConfig: { enabledTools: ['beta'], disabledTools: ['alpha'] }, - documents, - targets -}); - -assert.equal(plans.length, 1, 'expected one provider plan'); -assert.equal(plans[0].provider.id, 'beta'); - -console.log('tooling provider registry gating test passed'); diff --git a/tests/tooling/providers/provider-registry-merges-deterministically.test.js b/tests/tooling/providers/provider-registry-merges-deterministically.test.js deleted file mode 100644 index e9498d3be..000000000 --- a/tests/tooling/providers/provider-registry-merges-deterministically.test.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; - -TOOLING_PROVIDERS.clear(); - -registerToolingProvider({ - id: 'alpha', - version: '1.0.0', - capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, - getConfigHash: () => 'hash-alpha', - async run() { - return { - byChunkUid: { - 'ck64:v1:test:src/sample.js:deadbeef': { - payload: { - returnType: 'number' - } - } - } - }; - } -}); - -registerToolingProvider({ - id: 'beta', - version: '1.0.0', - capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, - getConfigHash: () => 'hash-beta', - async run() { - return { - byChunkUid: { - 'ck64:v1:test:src/sample.js:deadbeef': { - payload: { - returnType: 'string', - paramTypes: { - x: [{ type: 'number', confidence: 0.8, source: 'tooling' }] - } - } - } - } - }; - } -}); - -const chunkUid = 'ck64:v1:test:src/sample.js:deadbeef'; -const inputs = { - documents: [], - targets: [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/sample.js', - segmentUid: null, - segmentId: null, - range: { start: 0, end: 10 } - }, - name: 'greet', - virtualPath: 'src/sample.js', - virtualRange: { start: 0, end: 10 } - }] -}; - -const result = await runToolingProviders({ - strict: true, - toolingConfig: {}, - cache: { enabled: false } -}, inputs); - -const merged = result.byChunkUid.get(chunkUid); -assert.ok(merged, 'expected merged tooling entry'); -assert.equal(merged.payload.returnType, 'number', 'expected deterministic precedence for returnType'); -assert.ok(Array.isArray(merged.payload.paramTypes?.x), 'expected merged paramTypes from second provider'); - -console.log('tooling provider deterministic merge test passed'); diff --git a/tests/tooling/providers/provider-registry-normalizes-legacy-keys.test.js b/tests/tooling/providers/provider-registry-normalizes-legacy-keys.test.js deleted file mode 100644 index 895b25afa..000000000 --- a/tests/tooling/providers/provider-registry-normalizes-legacy-keys.test.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; - -TOOLING_PROVIDERS.clear(); - -registerToolingProvider({ - id: 'legacy-stub', - version: '1.0.0', - capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, - getConfigHash: () => 'hash', - async run() { - return { - byLegacyKey: { - 'src/sample.js::greet': { - payload: { returnType: 'string' } - } - } - }; - } -}); - -const chunkUid = 'ck64:v1:test:src/sample.js:deadbeef'; -const inputs = { - documents: [], - targets: [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/sample.js', - segmentUid: null, - segmentId: null, - range: { start: 0, end: 10 } - }, - name: 'greet', - virtualPath: 'src/sample.js', - virtualRange: { start: 0, end: 10 } - }] -}; - -const result = await runToolingProviders({ - strict: true, - toolingConfig: {}, - cache: { enabled: false } -}, inputs, ['legacy-stub']); - -assert.ok(result.byChunkUid instanceof Map, 'expected byChunkUid to be a Map'); -assert.ok(result.byChunkUid.has(chunkUid), 'expected legacy key to normalize to chunkUid'); - -console.log('tooling provider legacy key normalization test passed'); diff --git a/tests/tooling/providers/provider-registry-ordering.test.js b/tests/tooling/providers/provider-registry-ordering.test.js deleted file mode 100644 index 91286afc9..000000000 --- a/tests/tooling/providers/provider-registry-ordering.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { TOOLING_PROVIDERS, registerToolingProvider, selectToolingProviders } from '../../../src/index/tooling/provider-registry.js'; - -TOOLING_PROVIDERS.clear(); - -const makeProvider = (id, priority) => registerToolingProvider({ - id, - version: '1.0.0', - priority, - capabilities: { supportsVirtualDocuments: true }, - getConfigHash: () => id, - async run() { - return { byChunkUid: {} }; - } -}); - -makeProvider('beta', 10); -makeProvider('alpha', 5); -makeProvider('gamma', 5); - -const documents = [{ - virtualPath: 'src/sample.js', - languageId: 'javascript', - effectiveExt: '.js', - text: 'function demo() {}' -}]; -const targets = [{ - virtualPath: 'src/sample.js', - languageId: 'javascript', - virtualRange: { start: 0, end: 1 }, - chunkRef: { chunkUid: 'ck64:v1:test:src/sample.js:demo', chunkId: 'chunk_demo', file: 'src/sample.js' } -}]; - -const defaultPlans = selectToolingProviders({ toolingConfig: {}, documents, targets }); -const defaultIds = defaultPlans.map((plan) => plan.provider.id); -assert.deepEqual(defaultIds, ['alpha', 'gamma', 'beta']); - -const overridePlans = selectToolingProviders({ - toolingConfig: { providerOrder: ['beta', 'alpha'] }, - documents, - targets -}); -const overrideIds = overridePlans.map((plan) => plan.provider.id); -assert.deepEqual(overrideIds.slice(0, 2), ['beta', 'alpha']); - -console.log('tooling provider registry ordering test passed'); diff --git a/tests/tooling/providers/provider-registry-paramtypes-prototype-keys.test.js b/tests/tooling/providers/provider-registry-paramtypes-prototype-keys.test.js new file mode 100644 index 000000000..fa960c452 --- /dev/null +++ b/tests/tooling/providers/provider-registry-paramtypes-prototype-keys.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +TOOLING_PROVIDERS.clear(); + +registerToolingProvider({ + id: 'prototype-keys', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'prototype-keys-hash', + async run() { + const paramTypes = Object.create(null); + paramTypes.toString = [{ type: 'string', source: 'tooling', confidence: 0.8 }]; + paramTypes.constructor = [{ type: 'number', source: 'tooling', confidence: 0.9 }]; + paramTypes.__proto__ = [{ type: 'boolean', source: 'tooling', confidence: 0.7 }]; + return { + byChunkUid: { + 'ck64:v1:test:src/sample.ts:proto': { + payload: { + paramTypes + }, + provenance: { + provider: 'prototype-keys', + version: '1.0.0', + collectedAt: '2026-02-26T00:00:00.000Z' + } + } + } + }; + } +}); + +const chunkUid = 'ck64:v1:test:src/sample.ts:proto'; +const inputs = { + documents: [], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_proto', + file: 'src/sample.ts', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 16 } + }, + name: 'protoTarget', + virtualPath: 'src/sample.ts', + virtualRange: { start: 0, end: 16 } + }] +}; + +const result = await runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false } +}, inputs); + +const merged = result.byChunkUid.get(chunkUid); +assert.ok(merged, 'expected merged tooling entry'); +const paramTypes = merged.payload?.paramTypes; +assert.ok(paramTypes && typeof paramTypes === 'object', 'expected paramTypes payload'); +assert.equal(Object.getPrototypeOf(paramTypes), null, 'expected null-prototype paramTypes map'); + +for (const key of ['toString', 'constructor', '__proto__']) { + assert.equal(Object.hasOwn(paramTypes, key), true, `expected merged key ${key}`); + assert.ok(Array.isArray(paramTypes[key]), `expected merged paramTypes.${key} array`); + assert.ok(paramTypes[key].length > 0, `expected merged paramTypes.${key} entries`); +} + +console.log('tooling provider param type prototype keys test passed'); diff --git a/tests/tooling/providers/provider-registry-preflight-metrics-summary.test.js b/tests/tooling/providers/provider-registry-preflight-metrics-summary.test.js new file mode 100644 index 000000000..3adf8b397 --- /dev/null +++ b/tests/tooling/providers/provider-registry-preflight-metrics-summary.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { + createToolingProviderLogCollector, + runToolingProviderFixture +} from './provider-run-fixture.js'; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +TOOLING_PROVIDERS.clear(); + +let preflightCalls = 0; +registerToolingProvider({ + id: 'preflight-fixture', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + preflightId: 'preflight-fixture.bootstrap', + getConfigHash: () => 'hash-preflight-fixture', + async preflight() { + preflightCalls += 1; + await wait(25); + return { state: 'ready' }; + }, + async run() { + return { + byChunkUid: {} + }; + } +}); + +const { logs, logger } = createToolingProviderLogCollector(); +const result = await runToolingProviderFixture({ logger }); + +assert.equal(preflightCalls, 1, 'expected kickoff + provider execution to reuse one preflight run'); +assert.ok(result?.metrics?.preflights, 'expected preflight metrics envelope'); +assert.equal(result.metrics.preflights.total, 1, 'expected one tracked preflight'); +assert.equal(result.metrics.preflights.byState.ready, 1, 'expected ready preflight count'); +assert.equal(result.metrics.preflights.byClass.dependency, 1, 'expected dependency class preflight count'); +assert.equal(result.metrics.preflights.teardown?.timedOut, false, 'expected teardown to complete'); +assert.equal( + Number.isFinite(result.metrics.preflights.scheduler?.maxConcurrency), + true, + 'expected scheduler metrics on preflight envelope' +); +assert.equal( + result.metrics.preflights.scheduler?.byClass?.dependency?.started >= 1, + true, + 'expected scheduler byClass counters in preflight envelope' +); +assert.ok(result?.diagnostics?.['preflight-fixture']?.preflight, 'expected provider preflight diagnostics envelope'); +assert.equal( + result.diagnostics['preflight-fixture'].preflight.state, + 'ready', + 'expected preflight diagnostics state to be ready' +); +assert.ok( + logs.some((line) => line.includes('[tooling] preflight summary')), + 'expected preflight summary log line' +); + +console.log('tooling provider preflight metrics summary test passed'); diff --git a/tests/tooling/providers/provider-registry-provenance-retention.test.js b/tests/tooling/providers/provider-registry-provenance-retention.test.js new file mode 100644 index 000000000..fd3c8ec76 --- /dev/null +++ b/tests/tooling/providers/provider-registry-provenance-retention.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +TOOLING_PROVIDERS.clear(); + +registerToolingProvider({ + id: 'prov-a', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'prov-a-hash', + async run() { + return { + byChunkUid: { + 'ck64:v1:test:src/sample.js:provenance': { + payload: { + returnType: 'number' + }, + provenance: { + provider: 'prov-a', + version: '1.0.0', + collectedAt: '2026-03-19T00:00:00.000Z', + source: 'lsp', + stages: { + documentSymbol: true, + hover: { requested: true, succeeded: true } + }, + quality: { + score: 9, + incomplete: false + }, + confidence: { + score: 0.93, + tier: 'high' + } + } + } + } + }; + } +}); + +const chunkUid = 'ck64:v1:test:src/sample.js:provenance'; +const result = await runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false } +}, { + documents: [], + targets: [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_provenance', + file: 'src/sample.js', + segmentUid: null, + segmentId: null, + range: { start: 0, end: 10 } + }, + name: 'add', + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } + }] +}); + +const merged = result.byChunkUid.get(chunkUid); +assert.ok(merged, 'expected merged tooling entry'); +assert.ok(Array.isArray(merged.provenance), 'expected normalized provenance list'); +assert.equal(merged.provenance[0]?.source, 'lsp', 'expected provenance source to be retained'); +assert.equal(merged.provenance[0]?.stages?.hover?.succeeded, true, 'expected nested stage provenance to be retained'); +assert.equal(merged.provenance[0]?.quality?.score, 9, 'expected quality metadata to be retained'); +assert.equal(merged.provenance[0]?.confidence?.tier, 'high', 'expected confidence metadata to be retained'); + +console.log('tooling provider provenance retention test passed'); diff --git a/tests/tooling/providers/provider-registry-strict-missing-chunkuid.test.js b/tests/tooling/providers/provider-registry-strict-missing-chunkuid.test.js deleted file mode 100644 index 301f5d374..000000000 --- a/tests/tooling/providers/provider-registry-strict-missing-chunkuid.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; -import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; - -TOOLING_PROVIDERS.clear(); - -registerToolingProvider({ - id: 'stub', - version: '1.0.0', - capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, - getConfigHash: () => 'hash', - async run() { - return { byChunkUid: {} }; - } -}); - -let threw = false; -try { - await runToolingProviders({ - strict: true, - toolingConfig: {}, - cache: { enabled: false } - }, { - documents: [], - targets: [{ - chunkRef: { - docId: 0, - chunkUid: null, - chunkId: 'chunk_deadbeef', - file: 'src/sample.js', - segmentUid: null, - segmentId: null, - range: { start: 0, end: 10 } - }, - name: 'greet', - virtualPath: 'src/sample.js', - virtualRange: { start: 0, end: 10 } - }] - }); -} catch (err) { - threw = true; -} - -assert.ok(threw, 'expected strict mode to reject missing chunkUid'); - -console.log('tooling provider strict chunkUid test passed'); diff --git a/tests/tooling/providers/provider-registry-strict-unresolved-output-chunkuid.test.js b/tests/tooling/providers/provider-registry-strict-unresolved-output-chunkuid.test.js new file mode 100644 index 000000000..74a3a5f60 --- /dev/null +++ b/tests/tooling/providers/provider-registry-strict-unresolved-output-chunkuid.test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +TOOLING_PROVIDERS.clear(); +registerToolingProvider({ + id: 'stub', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash', + async run() { + return { + byChunkUid: { + 'unknown-chunk': { + payload: { returnType: 'string' } + } + } + }; + } +}); + +await assert.rejects( + () => runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false } + }, { + documents: [{ + virtualPath: 'src/sample.js', + docHash: 'doc-hash-1', + text: 'function a() {}' + }], + targets: [{ + chunkRef: { + chunkUid: 'chunk-a', + chunkId: 'chunk-a-id', + file: 'src/sample.js', + start: 0, + end: 10 + }, + virtualPath: 'src/sample.js', + virtualRange: { start: 0, end: 10 } + }], + kinds: ['types'] + }), + /chunkUid unresolved/, + 'expected strict mode to reject provider output for unknown chunkUid' +); + +TOOLING_PROVIDERS.clear(); +console.log('tooling provider strict unresolved output chunkUid test passed'); diff --git a/tests/tooling/providers/provider-run-fixture.js b/tests/tooling/providers/provider-run-fixture.js new file mode 100644 index 000000000..dd5c1a64c --- /dev/null +++ b/tests/tooling/providers/provider-run-fixture.js @@ -0,0 +1,37 @@ +import { runToolingProviders } from '../../../src/index/tooling/orchestrator.js'; + +export const createToolingProviderFixtureInput = () => ({ + documents: [{ + virtualPath: 'src/sample.fixture', + languageId: 'fixture', + docHash: 'hash-1' + }], + targets: [{ + chunkRef: { + docId: 0, + chunkUid: 'chunk-1', + chunkId: 'chunk-1', + file: 'src/sample.fixture', + range: { start: 0, end: 1 } + }, + name: 'sample', + virtualPath: 'src/sample.fixture', + virtualRange: { start: 0, end: 1 } + }] +}); + +export const createToolingProviderLogCollector = () => { + const logs = []; + return { + logs, + logger: (line) => logs.push(String(line || '')) + }; +}; + +export const runToolingProviderFixture = async (options = {}) => runToolingProviders({ + strict: true, + toolingConfig: {}, + cache: { enabled: false }, + logger: options.logger, + ...(options.orchestratorOptions || {}) +}, options.input || createToolingProviderFixtureInput()); diff --git a/tests/tooling/providers/runtime-progress-logs.test.js b/tests/tooling/providers/runtime-progress-logs.test.js new file mode 100644 index 000000000..f97b9ee6d --- /dev/null +++ b/tests/tooling/providers/runtime-progress-logs.test.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../../src/index/tooling/provider-registry.js'; +import { + createToolingProviderLogCollector, + runToolingProviderFixture +} from './provider-run-fixture.js'; + +TOOLING_PROVIDERS.clear(); + +registerToolingProvider({ + id: 'progress-fixture', + version: '1.0.0', + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'hash-progress-fixture', + async run() { + return { + byChunkUid: {} + }; + } +}); + +const { logs, logger } = createToolingProviderLogCollector(); +await runToolingProviderFixture({ logger }); + +assert.ok( + logs.some((line) => line.includes('[tooling] provider runtime start providers=1')), + 'expected provider runtime start progress log' +); +assert.ok( + logs.some((line) => line.includes('[tooling] provider 1/1 start id=progress-fixture')), + 'expected provider start progress log' +); +assert.ok( + logs.some((line) => line.includes('[tooling] provider 1/1 done id=progress-fixture')), + 'expected provider done progress log' +); +assert.ok( + logs.some((line) => line.includes('[tooling] provider runtime done providers=1')), + 'expected provider runtime done progress log' +); + +console.log('tooling provider runtime progress logs test passed'); diff --git a/tests/tooling/release-check/filtering.test.js b/tests/tooling/release-check/filtering.test.js new file mode 100644 index 000000000..dedf66c59 --- /dev/null +++ b/tests/tooling/release-check/filtering.test.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readPackageVersion } from '../../../tools/release/metadata-support.js'; +import { loadReleaseCheckArtifacts, runReleaseCheckCli } from '../../helpers/release-check-fixture.js'; + +const { version } = readPackageVersion(process.cwd()); + +const { run, reportPath, manifestPath } = await runReleaseCheckCli({ + outDirName: 'release-check-filtering', + extraArgs: ['--surfaces', 'vscode,sublime', '--phases', 'build'] +}); + +assert.equal(run.status, 0, `expected filtered release-check to pass: ${run.stderr || run.stdout}`); + +const { report, manifest } = await loadReleaseCheckArtifacts({ reportPath, manifestPath }); +assert.deepEqual(report.scope, { + surfaces: ['sublime', 'vscode'], + phases: ['build'] +}, 'expected filtered release-check scope metadata'); +assert.equal(report.releaseVersion, version, 'expected release version fallback when changelog phase is skipped'); +assert.deepEqual(report.strict.requiredChecks, ['build'], 'expected filtered required checks'); +assert.deepEqual(Object.keys(report.summary.byPhase), ['build'], 'expected only build phase in summary'); + +const executedIds = report.checks.map((step) => step.id); +assert.deepEqual(executedIds, ['smoke.editor-vscode', 'smoke.editor-sublime'], 'expected only selected build steps'); +assert.equal(manifest.surfaces.some((surface) => surface.id === 'vscode'), true, 'expected full surface registry in manifest'); + +const surfaceOnlyRun = await runReleaseCheckCli({ + outDirName: 'release-check-filtering-surface-only', + extraArgs: ['--surfaces', 'vscode,sublime'] +}); + +assert.equal( + surfaceOnlyRun.run.status, + 0, + `expected surface-only filtered release-check to pass: ${surfaceOnlyRun.run.stderr || surfaceOnlyRun.run.stdout}` +); + +const { report: surfaceOnlyReport } = await loadReleaseCheckArtifacts({ + reportPath: surfaceOnlyRun.reportPath, + manifestPath: surfaceOnlyRun.manifestPath +}); +const executedSurfaceOnlyPhases = Array.from(new Set(surfaceOnlyReport.checks.map((step) => step.phase))); +assert.deepEqual( + surfaceOnlyReport.strict.requiredChecks, + executedSurfaceOnlyPhases, + 'expected surface-only required checks to match the executed phase set' +); +assert.deepEqual( + Object.keys(surfaceOnlyReport.summary.byPhase), + executedSurfaceOnlyPhases, + 'expected surface-only summary to exclude phases with no executed steps' +); + +console.log('release-check filtering test passed'); diff --git a/tests/tooling/release-check/report-schema.test.js b/tests/tooling/release-check/report-schema.test.js index 59782988f..bf1f9475c 100644 --- a/tests/tooling/release-check/report-schema.test.js +++ b/tests/tooling/release-check/report-schema.test.js @@ -30,6 +30,11 @@ if (!Array.isArray(report.checks) || report.checks.length === 0) { process.exit(1); } +if (!Array.isArray(report.shippedSurfaces) || report.shippedSurfaces.length === 0) { + console.error('report-schema test failed: shipped surface metadata missing from report'); + process.exit(1); +} + for (const check of report.checks) { if (!check.id || !check.phase || !check.label) { console.error('report-schema test failed: required check fields missing'); @@ -46,6 +51,21 @@ if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0) { process.exit(1); } +if (!Array.isArray(manifest.surfaces) || manifest.surfaces.length === 0) { + console.error('report-schema test failed: manifest surfaces missing'); + process.exit(1); +} + +if (!report.summary?.byPhase || typeof report.summary.byPhase !== 'object') { + console.error('report-schema test failed: summary.byPhase missing'); + process.exit(1); +} + +if (!manifest.shippedSurfacesRegistryPath || typeof manifest.shippedSurfacesRegistryPath !== 'string') { + console.error('report-schema test failed: manifest registry path missing'); + process.exit(1); +} + const reportRel = path.relative(root, reportPath).replace(/\\/g, '/'); const reportArtifact = manifest.artifacts.find((entry) => entry.path === reportRel); if (!reportArtifact || reportArtifact.exists !== true || !Number.isFinite(reportArtifact.sizeBytes) || !reportArtifact.sha256) { @@ -53,4 +73,14 @@ if (!reportArtifact || reportArtifact.exists !== true || !Number.isFinite(report process.exit(1); } +const cliSurface = manifest.surfaces.find((entry) => entry.id === 'cli'); +if (!cliSurface || !Array.isArray(cliSurface.releaseCheckStepIds) || cliSurface.releaseCheckStepIds.length === 0) { + console.error('report-schema test failed: cli surface release-check metadata missing'); + process.exit(1); +} +if (!cliSurface.releaseCheckStepsByPhase || !Array.isArray(cliSurface.releaseCheckStepsByPhase.boot)) { + console.error('report-schema test failed: cli surface phase grouping missing'); + process.exit(1); +} + console.log('release-check report schema test passed'); diff --git a/tests/tooling/release-check/smoke.test.js b/tests/tooling/release-check/smoke.test.js index 14e60329f..cf9ab5503 100644 --- a/tests/tooling/release-check/smoke.test.js +++ b/tests/tooling/release-check/smoke.test.js @@ -1,5 +1,6 @@ #!/usr/bin/env node import { loadReleaseCheckArtifacts, runReleaseCheckCli } from '../../helpers/release-check-fixture.js'; +import { getReleaseCheckSurfaceSteps } from '../../../tools/release/surfaces.js'; const { run, root, reportPath, manifestPath } = await runReleaseCheckCli({ outDirName: 'release-check-smoke' @@ -27,14 +28,7 @@ const expected = [ 'changelog.entry', 'contracts.drift', 'toolchain.python', - 'smoke.version', - 'smoke.fixture-index-build', - 'smoke.fixture-index-validate-strict', - 'smoke.fixture-search', - 'smoke.editor-sublime', - 'smoke.editor-vscode', - 'smoke.tui-build', - 'smoke.service-mode' + ...getReleaseCheckSurfaceSteps(root).map((step) => step.id) ]; const ids = report.checks.map((step) => step.id); @@ -51,4 +45,14 @@ for (let i = 0; i < expected.length; i += 1) { } } +if (!Array.isArray(report.shippedSurfaces) || report.shippedSurfaces.length < 6) { + console.error('release-check smoke failed: expected shipped surface metadata in report'); + process.exit(1); +} + +if (!Array.isArray(manifest.surfaces) || manifest.surfaces.length !== report.shippedSurfaces.length) { + console.error('release-check smoke failed: manifest/report shipped surface counts differ'); + process.exit(1); +} + console.log('release-check smoke test passed'); diff --git a/tests/tooling/release/file-walk.test.js b/tests/tooling/release/file-walk.test.js new file mode 100644 index 000000000..896f95d47 --- /dev/null +++ b/tests/tooling/release/file-walk.test.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + collectSortedFiles, + requireRepoContainedPath, + resolveRepoContainedOutputPath, + resolveRepoContainedPath +} from '../../../tools/release/file-walk.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const { dir: fixtureDir } = await prepareTestCacheDir('release-file-walk'); +const root = path.join(fixtureDir, 'root'); + +fs.mkdirSync(path.join(root, 'z-dir'), { recursive: true }); +fs.mkdirSync(path.join(root, 'a-dir'), { recursive: true }); +fs.mkdirSync(path.join(root, 'B-dir'), { recursive: true }); +fs.writeFileSync(path.join(root, 'z-dir', 'later.txt'), 'later'); +fs.writeFileSync(path.join(root, 'a-dir', 'first.txt'), 'first'); +fs.writeFileSync(path.join(root, 'B-dir', 'upper.txt'), 'upper'); +fs.writeFileSync(path.join(root, 'middle.txt'), 'middle'); + +assert.deepEqual( + collectSortedFiles(root).map((filePath) => path.relative(root, filePath).replace(/\\/g, '/')), + ['B-dir/upper.txt', 'a-dir/first.txt', 'middle.txt', 'z-dir/later.txt'] +); +assert.deepEqual(collectSortedFiles(path.join(fixtureDir, 'missing')), []); +assert.deepEqual(collectSortedFiles(''), []); + +const contained = resolveRepoContainedPath(root, path.join(root, 'middle.txt'), 'test path'); +assert.equal(contained.ok, true, 'expected absolute path inside root to be accepted'); +assert.equal(contained.relative, 'middle.txt', 'expected contained path to expose POSIX relative path'); + +const escaping = resolveRepoContainedPath(root, path.resolve(root, '..', 'outside.txt'), 'test path'); +assert.equal(escaping.ok, false, 'expected outside path to be rejected'); +assert.match( + escaping.error, + /test path must stay within repo root/, + 'expected outside path error to explain repo-root containment' +); +assert.throws( + () => requireRepoContainedPath(root, '..\\outside.txt', 'required path'), + /required path must stay within repo root/, + 'expected required contained path helper to throw on escaping paths' +); + +const symlinkPath = path.join(root, 'linked-dir'); +try { + fs.symlinkSync(path.join(root, 'a-dir'), symlinkPath, 'junction'); + assert.throws( + () => collectSortedFiles(root), + /release file walk rejects symlink entries: linked-dir/, + 'expected release file walker to reject symlinked entries before hashing artifacts' + ); + const outputThroughSymlink = resolveRepoContainedOutputPath( + root, + path.join(symlinkPath, 'report.json'), + 'output path' + ); + assert.equal(outputThroughSymlink.ok, false, 'expected output paths through symlink ancestors to be rejected'); + assert.match( + outputThroughSymlink.error, + /output path must not use symlink path segment: linked-dir/, + 'expected output path symlink rejection to identify the symlink segment' + ); + assert.throws( + () => collectSortedFiles(symlinkPath), + /release file walk rejects symlink root/, + 'expected release file walker to reject a symlinked root before hashing artifacts' + ); +} catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`release file walk symlink assertion skipped: ${error.code}`); +} + +console.log('release file walk test passed'); diff --git a/tests/tooling/release/readiness-gate-ci-quality.test.js b/tests/tooling/release/readiness-gate-ci-quality.test.js new file mode 100644 index 000000000..6ed8009de --- /dev/null +++ b/tests/tooling/release/readiness-gate-ci-quality.test.js @@ -0,0 +1,611 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { setupReadinessGateFixture } from './readiness-gate-fixture.js'; + +const { + root, + scriptPath, + fixtureDir, + releaseGitSha, + prepareReportPath, + runtimeReportPath, + nodeVerifyReportPath, + tuiVerifyRoot, + trustRoot, + staleTrustRoot, + ciStatusesPath, + staleCiStatusesPath, + malformedShapeCiStatusesPath, + upperCaseCiStatusesPath, + ciSummaryPath, + failedCiSummaryPath, + redoCiSummaryPath, + fractionalCountCiSummaryPath, + invalidRowCiSummaryPath, + coverageDir, + invalidShapeCoverageDir, + semanticInvalidCoverageDir +} = await setupReadinessGateFixture( + 'release-readiness-gate-ci-quality', + { scenarios: ['ciQuality'] } +); + +const invalidCoverageRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + path.resolve(root, '..', 'outside-release-coverage'), + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-coverage-root-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-coverage-root-summary.md') + ], + 'release readiness gate invalid coverage root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidCoverageRun.status, 0, 'expected outside coverage root to fail readiness'); +const invalidCoveragePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-coverage-root-summary.json'), 'utf8') +); +assert.ok( + invalidCoveragePayload.blockers.some((blocker) => blocker.id === 'path.coverage-dir'), + 'expected readiness blockers to reject coverage roots outside the repo' +); + +const invalidShapeCoverageRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + invalidShapeCoverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-shape-coverage-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-shape-coverage-summary.md') + ], + 'release readiness gate invalid shape coverage artifacts', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidShapeCoverageRun.status, 0, 'expected non-coverage JSON file to fail coverage readiness'); +const invalidShapeCoveragePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-shape-coverage-summary.json'), 'utf8') +); +assert.ok( + invalidShapeCoveragePayload.blockers.some((blocker) => blocker.id === 'coverage.invalid-shape'), + 'expected readiness blockers to reject coverage directories without schema-shaped coverage artifacts' +); +assert.equal( + invalidShapeCoveragePayload.blockers.some((blocker) => blocker.id === 'coverage.missing'), + false, + 'invalid coverage shape must not be misclassified with the generic coverage-missing blocker' +); +assert.equal( + invalidShapeCoveragePayload.ci.coverageStatus, + 'invalid-shape', + 'expected JSON summary to classify invalid coverage artifacts as invalid-shape' +); + +const semanticInvalidCoverageRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + semanticInvalidCoverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'semantic-invalid-coverage-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'semantic-invalid-coverage-summary.md') + ], + 'release readiness gate semantic invalid coverage artifacts', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual( + semanticInvalidCoverageRun.status, + 0, + 'expected schema-valid but semantically invalid coverage to fail readiness' +); +const semanticInvalidCoveragePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'semantic-invalid-coverage-summary.json'), 'utf8') +); +assert.ok( + semanticInvalidCoveragePayload.blockers.some((blocker) => blocker.id === 'coverage.invalid-shape' + && /safe repo-relative POSIX path/.test(blocker.detail)), + 'expected readiness blockers to reject unsafe semantic coverage paths' +); +assert.equal( + semanticInvalidCoveragePayload.ci.coverageStatus, + 'invalid-shape', + 'expected JSON summary to classify semantic coverage errors as invalid-shape' +); + + +const failedCiSummaryRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + failedCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'failed-ci-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'failed-ci-summary.md') + ], + 'release readiness gate failed CI summary rows', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(failedCiSummaryRun.status, 0, 'expected valid CI summary with failed rows to block readiness'); +const failedCiSummaryPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'failed-ci-summary.json'), 'utf8') +); +assert.ok( + failedCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary.failed'), + 'expected readiness blockers to reject failed or redo rows in the CI test summary' +); +assert.equal( + failedCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary.invalid-shape'), + false, + 'valid but failing CI summary must not be misclassified as invalid shape' +); +assert.equal( + failedCiSummaryPayload.ci.testSummaryStatus, + 'failed', + 'expected JSON summary to classify valid failing CI test summary rows' +); + +const redoCiSummaryRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + redoCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'redo-ci-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'redo-ci-summary.md') + ], + 'release readiness gate redo CI summary rows', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(redoCiSummaryRun.status, 0, 'expected valid CI summary with redo rows to block readiness'); +const redoCiSummaryPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'redo-ci-summary.json'), 'utf8') +); +assert.ok( + redoCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary.failed' + && /failed or redo/.test(blocker.detail)), + 'expected readiness blockers to reject redo rows in the CI test summary' +); +assert.equal( + redoCiSummaryPayload.ci.testSummaryStatus, + 'failed', + 'expected JSON summary to classify redo CI test summary rows as failed' +); + +const fractionalCountCiSummaryRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + fractionalCountCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'fractional-ci-summary-counts.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'fractional-ci-summary-counts.md') + ], + 'release readiness gate fractional CI summary counts', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(fractionalCountCiSummaryRun.status, 0, 'expected fractional CI summary counts to fail readiness'); +const fractionalCountCiSummaryPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'fractional-ci-summary-counts.json'), 'utf8') +); +assert.ok( + fractionalCountCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary.invalid-shape'), + 'expected readiness blockers to reject fractional CI summary counts' +); +assert.equal( + fractionalCountCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary'), + false, + 'fractional CI summary counts must not be misclassified with the generic CI test-summary blocker' +); +assert.equal( + fractionalCountCiSummaryPayload.ci.testSummaryStatus, + 'invalid-shape', + 'expected JSON summary to classify fractional CI summary counts as invalid-shape' +); + +const invalidRowCiSummaryRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + invalidRowCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-row-ci-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-row-ci-summary.md') + ], + 'release readiness gate invalid CI summary test row', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidRowCiSummaryRun.status, 0, 'expected invalid CI summary test row to fail readiness'); +const invalidRowCiSummaryPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-row-ci-summary.json'), 'utf8') +); +assert.ok( + invalidRowCiSummaryPayload.blockers.some((blocker) => blocker.id === 'ci.test-summary.invalid-shape'), + 'expected readiness blockers to reject invalid CI summary test rows' +); +assert.equal( + invalidRowCiSummaryPayload.ci.testSummaryStatus, + 'invalid-shape', + 'expected JSON summary to classify invalid CI summary rows as invalid-shape' +); + +const staleTrustRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + staleTrustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'stale-trust-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'stale-trust-summary.md') + ], + 'release readiness gate stale trust source commit', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(staleTrustRun.status, 0, 'expected stale trust source commit to fail readiness'); +const staleTrustPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'stale-trust-summary.json'), 'utf8') +); +assert.ok( + staleTrustPayload.blockers.some((blocker) => blocker.id === 'trust.source-commit'), + 'expected readiness blockers to reject trust source commits that do not match release SHA' +); + +const staleCiStatusRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + staleCiStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'stale-ci-status-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'stale-ci-status-summary.md') + ], + 'release readiness gate stale ci status target sha', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(staleCiStatusRun.status, 0, 'expected stale CI status target SHA to fail readiness'); +const staleCiStatusPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'stale-ci-status-summary.json'), 'utf8') +); +assert.ok( + staleCiStatusPayload.blockers.some((blocker) => blocker.id === 'ci.target-sha'), + 'expected readiness blockers to reject CI statuses for a different target SHA' +); +assert.equal( + staleCiStatusPayload.blockers.some((blocker) => blocker.id === 'ci.ci' || blocker.id === 'ci.ci-long'), + false, + 'stale CI provenance must not be misclassified as a workflow conclusion failure' +); +assert.equal(staleCiStatusPayload.ci.releaseGitSha, releaseGitSha); +assert.equal(staleCiStatusPayload.ci.targetSha, 'fedcba9876543210fedcba9876543210fedcba98'); + +const upperCaseCiStatusRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + upperCaseCiStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'uppercase-ci-status-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'uppercase-ci-status-summary.md') + ], + 'release readiness gate uppercase ci status target sha', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(upperCaseCiStatusRun.status, 0, upperCaseCiStatusRun.stderr || upperCaseCiStatusRun.stdout); +const upperCaseCiStatusPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'uppercase-ci-status-summary.json'), 'utf8') +); +assert.equal( + upperCaseCiStatusPayload.ci.targetSha, + releaseGitSha, + 'expected readiness summary to normalize equivalent uppercase CI target SHA' +); + +const malformedCiTargetShaRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + malformedShapeCiStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-ci-target-sha-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-ci-target-sha-summary.md') + ], + 'release readiness gate malformed ci target sha', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedCiTargetShaRun.status, 0, 'expected malformed CI target SHA to fail readiness'); +const malformedCiTargetShaPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-ci-target-sha-summary.json'), 'utf8') +); +assert.ok( + malformedCiTargetShaPayload.blockers.some((blocker) => blocker.id === 'ci.target-sha'), + 'expected readiness blockers to reject malformed CI target SHA values' +); +assert.match( + malformedCiTargetShaPayload.blockers.find((blocker) => blocker.id === 'ci.target-sha')?.detail || '', + /malformed/, + 'expected malformed CI target SHA blocker detail to explain the shape failure' +); + +const malformedReleaseShaRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + 'not-a-git-sha', + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-release-sha-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-release-sha-summary.md') + ], + 'release readiness gate malformed release sha', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedReleaseShaRun.status, 0, 'expected malformed release SHA to fail readiness'); +const malformedReleaseShaPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-release-sha-summary.json'), 'utf8') +); +assert.ok( + malformedReleaseShaPayload.blockers.some((blocker) => blocker.id === 'ci.target-sha'), + 'expected readiness blockers to reject malformed release SHA values' +); +assert.match( + malformedReleaseShaPayload.blockers.find((blocker) => blocker.id === 'ci.target-sha')?.detail || '', + /malformed/, + 'expected malformed release SHA blocker detail to explain the shape failure' +); + + +console.log('release readiness gate CI quality test passed'); diff --git a/tests/tooling/release/readiness-gate-fixture.js b/tests/tooling/release/readiness-gate-fixture.js new file mode 100644 index 000000000..f5d1fbea8 --- /dev/null +++ b/tests/tooling/release/readiness-gate-fixture.js @@ -0,0 +1,533 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const writeJson = (targetPath, payload) => { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`); +}; + +const createReleaseReport = (root, { + id = 'fixture.check', + phase = 'smoke', + label = 'fixture release check', + status = 'passed', + generatedAt = '2026-05-21T18:31:24Z', + startedAt = '2026-05-21T18:31:24Z', + finishedAt = '2026-05-21T18:31:24Z', + checkStartedAt = startedAt, + checkFinishedAt = finishedAt, + runtimeTarget = null, + summaryByPhase = null +} = {}) => ({ + schemaVersion: 1, + generatedAt, + startedAt, + finishedAt, + durationMs: 0, + root: root.replace(/\\/g, '/'), + releaseVersion: '0.3.0', + scope: { + surfaces: null, + phases: null, + runtimeTarget + }, + strict: { + skipModesDisabled: true, + requiredChecks: [phase] + }, + shippedSurfaces: [], + summary: { + total: 1, + passed: status === 'passed' ? 1 : 0, + failed: status === 'passed' ? 0 : 1, + byPhase: summaryByPhase || { + [phase]: 1 + } + }, + checks: [{ + id, + phase, + label, + command: ['fixture:release-check'], + cwd: '.', + status, + overridden: false, + owner: null, + startedAt: checkStartedAt, + finishedAt: checkFinishedAt, + durationMs: 0, + exitCode: status === 'passed' ? 0 : 1, + stdoutTail: '', + stderrTail: '', + artifacts: [] + }], + ok: status === 'passed' +}); + +const createTuiReleaseReport = (root, target, overrides = {}) => createReleaseReport(root, { + id: `tui.${target}.verify`, + phase: 'install', + label: `TUI ${target} verification`, + runtimeTarget: target, + ...overrides +}); + +const createTrustManifest = () => ({ + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + releaseVersion: '0.3.0', + releaseTag: 'v0.3.0', + checksumBundlePath: 'release-checksum-bundle.json', + provenanceSummaryPath: 'provenance-summary.json', + sboms: [ + { id: 'node-root', path: 'node-root.cyclonedx.json', format: 'cyclonedx-json' }, + { id: 'tui', path: 'tui.cyclonedx.json', format: 'cyclonedx-json' } + ] +}); + +const createChecksumBundle = (sourceCommit = '0123456789abcdef0123456789abcdef01234567') => ({ + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + releaseVersion: '0.3.0', + releaseTag: 'v0.3.0', + sourceCommit, + artifacts: [ + { path: 'dist/vscode/pairofcleats.vsix', sizeBytes: 10, sha256: 'a'.repeat(64) }, + { path: 'dist/sublime/pairofcleats.sublime-package', sizeBytes: 20, sha256: 'b'.repeat(64) } + ] +}); + +const createProvenanceSummary = (sha = '0123456789abcdef0123456789abcdef01234567') => ({ + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + attestationProvider: 'github-actions-attest-build-provenance', + workflow: 'Release', + runId: '101', + runAttempt: '1', + repository: 'owner/repo', + ref: 'refs/tags/v0.3.0', + sha, + releaseTag: 'v0.3.0', + subjects: [ + 'dist/release/bundle/release-artifacts.json', + 'dist/release/bundle/release-checksums.txt', + 'dist/release/trust/node-root.cyclonedx.json', + 'dist/release/trust/tui.cyclonedx.json' + ] +}); + +const createCiStatuses = (sha, runIdBase) => ({ + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + targetSha: sha, + workflows: [ + { workflow: 'CI', conclusion: 'success', runId: runIdBase + 1 }, + { workflow: 'CI Long', conclusion: 'success', runId: runIdBase + 2 } + ] +}); + +const createCiSummary = () => ({ + summary: { + total: 1, + passed: 1, + failed: 0, + skipped: 0, + durationMs: 12 + }, + tests: [ + { + id: 'ci/example', + path: 'tests/ci/example.test.js', + lane: 'ci', + status: 'passed', + durationMs: 12 + } + ] +}); + +const createFailedCiSummary = () => ({ + summary: { + total: 2, + passed: 1, + failed: 1, + skipped: 0, + durationMs: 24 + }, + tests: [ + { + id: 'ci/example', + path: 'tests/ci/example.test.js', + lane: 'ci', + status: 'passed', + durationMs: 12 + }, + { + id: 'ci/failing', + path: 'tests/ci/failing.test.js', + lane: 'ci', + status: 'failed', + durationMs: 12 + } + ] +}); + +const createRedoCiSummary = () => ({ + summary: { + total: 2, + passed: 1, + failed: 1, + skipped: 0, + durationMs: 24 + }, + tests: [ + { + id: 'ci/example', + path: 'tests/ci/example.test.js', + lane: 'ci', + status: 'passed', + durationMs: 12 + }, + { + id: 'ci/redo', + path: 'tests/ci/redo.test.js', + lane: 'ci', + status: 'redo', + durationMs: 12 + } + ] +}); + +const writeTrustMaterials = (rootDir, { + includeSboms = true, + manifest = createTrustManifest(), + sourceCommit = '0123456789abcdef0123456789abcdef01234567', + sha = '0123456789abcdef0123456789abcdef01234567' +} = {}) => { + writeJson(path.join(rootDir, 'trust-manifest.json'), manifest); + writeJson(path.join(rootDir, 'provenance-summary.json'), createProvenanceSummary(sha)); + writeJson(path.join(rootDir, 'release-checksum-bundle.json'), createChecksumBundle(sourceCommit)); + if (includeSboms) { + writeJson(path.join(rootDir, 'node-root.cyclonedx.json'), { + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [] + }); + writeJson(path.join(rootDir, 'tui.cyclonedx.json'), { + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [] + }); + } +}; + +export const setupReadinessGateFixture = async ( + cacheName = 'release-readiness-gate', + { scenarios = ['all'] } = {} +) => { + const root = process.cwd(); + const scriptPath = path.join(root, 'tools', 'release', 'readiness-gate.js'); + const { dir: fixtureDir } = await prepareTestCacheDir(cacheName); + const releaseGitSha = '0123456789abcdef0123456789abcdef01234567'; + const scenarioSet = new Set(scenarios); + const shouldPrepare = (scenario) => scenarioSet.has('all') || scenarioSet.has(scenario); + + const writeTuiReleaseReport = (rootDir, target, overrides = {}) => { + writeJson( + path.join(rootDir, target, 'release_check_report.json'), + createTuiReleaseReport(root, target, overrides) + ); + }; + + const prepareReportPath = path.join(fixtureDir, 'prepare', 'release_check_report.json'); + const malformedPrepareReportPath = path.join(fixtureDir, 'prepare-malformed', 'release_check_report.json'); + const minimalPrepareReportPath = path.join(fixtureDir, 'prepare-minimal', 'release_check_report.json'); + const invalidTimestampPrepareReportPath = path.join( + fixtureDir, + 'prepare-invalid-timestamp', + 'release_check_report.json' + ); + const invalidAllTimestampsPrepareReportPath = path.join( + fixtureDir, + 'prepare-invalid-all-timestamps', + 'release_check_report.json' + ); + const invalidMissingByPhasePrepareReportPath = path.join( + fixtureDir, + 'prepare-invalid-missing-by-phase', + 'release_check_report.json' + ); + const runtimeReportPath = path.join(fixtureDir, 'runtime', 'release_check_report.json'); + const nodeVerifyReportPath = path.join(fixtureDir, 'node-verify', 'release_check_report.json'); + const tuiVerifyRoot = path.join(fixtureDir, 'tui'); + const invalidTimestampTuiVerifyRoot = path.join(fixtureDir, 'tui-invalid-timestamp'); + const mismatchedTuiVerifyRoot = path.join(fixtureDir, 'tui-mismatched-target'); + const minimalTuiVerifyRoot = path.join(fixtureDir, 'tui-minimal'); + const malformedTuiVerifyRoot = path.join(fixtureDir, 'tui-malformed-json'); + const trustRoot = path.join(fixtureDir, 'trust'); + const missingSbomTrustRoot = path.join(fixtureDir, 'trust-missing-sboms'); + const malformedTrustRoot = path.join(fixtureDir, 'trust-malformed'); + const malformedShapeTrustRoot = path.join(fixtureDir, 'trust-malformed-shape'); + const staleTrustRoot = path.join(fixtureDir, 'trust-stale-source'); + const ciStatusesPath = path.join(fixtureDir, 'ci-statuses.json'); + const malformedShapeCiStatusesPath = path.join(fixtureDir, 'ci-statuses-malformed-sha.json'); + const invalidShapeCiStatusesPath = path.join(fixtureDir, 'ci-statuses-invalid-shape.json'); + const upperCaseCiStatusesPath = path.join(fixtureDir, 'ci-statuses-uppercase-sha.json'); + const staleCiStatusesPath = path.join(fixtureDir, 'ci-statuses-stale.json'); + const malformedCiStatusesPath = path.join(fixtureDir, 'ci-statuses-malformed.json'); + const ciSummaryPath = path.join(fixtureDir, 'ci-quality', '.diagnostics', 'test-summary.json'); + const malformedCiSummaryPath = path.join(fixtureDir, 'ci-quality-malformed', '.diagnostics', 'test-summary.json'); + const invalidShapeCiSummaryPath = path.join(fixtureDir, 'ci-quality-invalid-shape', '.diagnostics', 'test-summary.json'); + const fractionalCountCiSummaryPath = path.join( + fixtureDir, + 'ci-quality-fractional-counts', + '.diagnostics', + 'test-summary.json' + ); + const invalidRowCiSummaryPath = path.join( + fixtureDir, + 'ci-quality-invalid-row', + '.diagnostics', + 'test-summary.json' + ); + const failedCiSummaryPath = path.join(fixtureDir, 'ci-quality-failed', '.diagnostics', 'test-summary.json'); + const redoCiSummaryPath = path.join(fixtureDir, 'ci-quality-redo', '.diagnostics', 'test-summary.json'); + const coverageDir = path.join(fixtureDir, 'ci-quality', '.diagnostics', 'coverage'); + const invalidShapeCoverageDir = path.join(fixtureDir, 'ci-quality-invalid-coverage', '.diagnostics', 'coverage'); + const semanticInvalidCoverageDir = path.join( + fixtureDir, + 'ci-quality-semantic-invalid-coverage', + '.diagnostics', + 'coverage' + ); + const outJsonPath = path.join(fixtureDir, 'readiness', 'summary.json'); + const outMdPath = path.join(fixtureDir, 'readiness', 'summary.md'); + const outsideOutJsonPath = path.resolve(root, '..', `outside-release-readiness-${process.pid}.json`); + const outsideOutMdPath = path.resolve(root, '..', `outside-release-readiness-${process.pid}.md`); + + writeJson(prepareReportPath, createReleaseReport(root, { id: 'prepare.fixture', phase: 'changelog' })); + writeJson(runtimeReportPath, createReleaseReport(root, { id: 'runtime.fixture', phase: 'smoke' })); + writeJson(nodeVerifyReportPath, createReleaseReport(root, { id: 'node-verify.fixture', phase: 'install' })); + writeTuiReleaseReport(tuiVerifyRoot, 'ubuntu'); + writeTuiReleaseReport(tuiVerifyRoot, 'windows'); + writeTuiReleaseReport(tuiVerifyRoot, 'macos'); + writeTrustMaterials(trustRoot); + writeJson(ciStatusesPath, createCiStatuses(releaseGitSha, 100)); + writeJson(ciSummaryPath, createCiSummary()); + fs.mkdirSync(coverageDir, { recursive: true }); + writeJson(path.join(coverageDir, 'test-coverage-ci.json'), { + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + runId: 'run-ci', + pathPolicy: 'repo-relative-posix', + kind: 'v8-range-summary', + summary: { + files: 1, + coveredRanges: 1, + totalRanges: 1 + }, + entries: [{ + path: 'src/index.js', + coveredRanges: 1, + totalRanges: 1 + }] + }); + if (shouldPrepare('reportShape')) { + fs.mkdirSync(path.dirname(malformedPrepareReportPath), { recursive: true }); + fs.writeFileSync(malformedPrepareReportPath, '{ invalid json'); + writeJson(minimalPrepareReportPath, { ok: true }); + writeJson(invalidTimestampPrepareReportPath, createReleaseReport(root, { + id: 'prepare.invalid-timestamp', + phase: 'changelog', + generatedAt: 'not an ISO timestamp' + })); + writeJson(invalidAllTimestampsPrepareReportPath, createReleaseReport(root, { + id: 'prepare.invalid-all-timestamps', + phase: 'changelog', + startedAt: 'not-started-at', + finishedAt: 'not-finished-at', + checkStartedAt: 'not-check-started-at', + checkFinishedAt: 'not-check-finished-at' + })); + writeJson(invalidMissingByPhasePrepareReportPath, createReleaseReport(root, { + id: 'prepare.missing-by-phase', + phase: 'changelog', + summaryByPhase: {} + })); + writeTuiReleaseReport(invalidTimestampTuiVerifyRoot, 'ubuntu'); + writeTuiReleaseReport(invalidTimestampTuiVerifyRoot, 'windows'); + writeTuiReleaseReport(invalidTimestampTuiVerifyRoot, 'macos', { + checkStartedAt: 'not-check-started-at' + }); + writeTuiReleaseReport(mismatchedTuiVerifyRoot, 'ubuntu'); + writeTuiReleaseReport(mismatchedTuiVerifyRoot, 'windows'); + writeTuiReleaseReport(mismatchedTuiVerifyRoot, 'macos', { + runtimeTarget: 'ubuntu' + }); + writeJson(path.join(minimalTuiVerifyRoot, 'ubuntu', 'release_check_report.json'), { ok: true }); + writeJson(path.join(minimalTuiVerifyRoot, 'windows', 'release_check_report.json'), { ok: true }); + writeJson(path.join(minimalTuiVerifyRoot, 'macos', 'release_check_report.json'), { ok: true }); + writeTuiReleaseReport(malformedTuiVerifyRoot, 'ubuntu'); + writeTuiReleaseReport(malformedTuiVerifyRoot, 'windows'); + fs.mkdirSync(path.join(malformedTuiVerifyRoot, 'macos'), { recursive: true }); + fs.writeFileSync(path.join(malformedTuiVerifyRoot, 'macos', 'release_check_report.json'), '{ invalid json'); + writeTrustMaterials(missingSbomTrustRoot, { includeSboms: false }); + fs.mkdirSync(malformedTrustRoot, { recursive: true }); + fs.writeFileSync(path.join(malformedTrustRoot, 'trust-manifest.json'), '{ invalid json'); + writeJson(path.join(malformedTrustRoot, 'provenance-summary.json'), createProvenanceSummary()); + writeJson(path.join(malformedTrustRoot, 'release-checksum-bundle.json'), createChecksumBundle()); + writeTrustMaterials(malformedShapeTrustRoot, { + manifest: { + schemaVersion: 1 + } + }); + fs.writeFileSync(malformedCiStatusesPath, '{ invalid json'); + writeJson(invalidShapeCiStatusesPath, { + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + targetSha: releaseGitSha, + workflows: [ + { workflow: 'CI', conclusion: 'success', runId: 1 }, + { workflow: 'CI Long', conclusion: 'success', runId: 'not-a-number' } + ] + }); + fs.mkdirSync(path.dirname(malformedCiSummaryPath), { recursive: true }); + fs.writeFileSync(malformedCiSummaryPath, '{ invalid json'); + writeJson(invalidShapeCiSummaryPath, { totals: { passed: 10, failed: 0 } }); + } + + if (shouldPrepare('ciQuality')) { + writeTrustMaterials(staleTrustRoot, { + sha: 'fedcba9876543210fedcba9876543210fedcba98', + sourceCommit: 'fedcba9876543210fedcba9876543210fedcba98' + }); + writeJson(staleCiStatusesPath, createCiStatuses('fedcba9876543210fedcba9876543210fedcba98', 200)); + writeJson(malformedShapeCiStatusesPath, createCiStatuses('not-a-git-sha', 300)); + writeJson(upperCaseCiStatusesPath, createCiStatuses(releaseGitSha.toUpperCase(), 400)); + writeJson(failedCiSummaryPath, createFailedCiSummary()); + writeJson(redoCiSummaryPath, createRedoCiSummary()); + writeJson(fractionalCountCiSummaryPath, { + summary: { + total: 1, + passed: 0.5, + failed: 0.5, + skipped: 0, + durationMs: 12 + }, + tests: [{ + id: 'ci/fractional-counts', + path: 'tests/ci/fractional-counts.test.js', + lane: 'ci', + status: 'failed', + durationMs: 12 + }] + }); + writeJson(invalidRowCiSummaryPath, { + summary: { + total: 1, + passed: 0, + failed: 1, + skipped: 0, + durationMs: 12 + }, + tests: [{ + id: 'ci/invalid-row', + path: '../outside.test.js', + lane: 'ci', + status: 'unknown', + durationMs: -1 + }] + }); + fs.mkdirSync(invalidShapeCoverageDir, { recursive: true }); + fs.writeFileSync(path.join(invalidShapeCoverageDir, 'test-coverage-ci.json'), '{"coverage":true}\n'); + fs.mkdirSync(semanticInvalidCoverageDir, { recursive: true }); + writeJson(path.join(semanticInvalidCoverageDir, 'test-coverage-ci.json'), { + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + runId: 'run-ci', + pathPolicy: 'repo-relative-posix', + kind: 'v8-range-summary', + summary: { + files: 1, + coveredRanges: 1, + totalRanges: 1 + }, + entries: [{ + path: '../outside.js', + coveredRanges: 1, + totalRanges: 1 + }] + }); + } + + const baseArgs = [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested' + ]; + + return { + root, + scriptPath, + fixtureDir, + releaseGitSha, + prepareReportPath, + malformedPrepareReportPath, + minimalPrepareReportPath, + invalidTimestampPrepareReportPath, + invalidAllTimestampsPrepareReportPath, + invalidMissingByPhasePrepareReportPath, + runtimeReportPath, + nodeVerifyReportPath, + tuiVerifyRoot, + invalidTimestampTuiVerifyRoot, + mismatchedTuiVerifyRoot, + minimalTuiVerifyRoot, + malformedTuiVerifyRoot, + trustRoot, + missingSbomTrustRoot, + malformedTrustRoot, + malformedShapeTrustRoot, + staleTrustRoot, + ciStatusesPath, + malformedShapeCiStatusesPath, + invalidShapeCiStatusesPath, + upperCaseCiStatusesPath, + staleCiStatusesPath, + malformedCiStatusesPath, + ciSummaryPath, + malformedCiSummaryPath, + invalidShapeCiSummaryPath, + fractionalCountCiSummaryPath, + invalidRowCiSummaryPath, + failedCiSummaryPath, + redoCiSummaryPath, + coverageDir, + invalidShapeCoverageDir, + semanticInvalidCoverageDir, + outJsonPath, + outMdPath, + outsideOutJsonPath, + outsideOutMdPath, + baseArgs, + writeJson, + writeTuiReleaseReport, + createReleaseReport: (overrides = {}) => createReleaseReport(root, overrides), + createTuiReleaseReport: (target, overrides = {}) => createTuiReleaseReport(root, target, overrides) + }; +}; diff --git a/tests/tooling/release/readiness-gate-report-shape.test.js b/tests/tooling/release/readiness-gate-report-shape.test.js new file mode 100644 index 000000000..f3784b135 --- /dev/null +++ b/tests/tooling/release/readiness-gate-report-shape.test.js @@ -0,0 +1,787 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { setupReadinessGateFixture } from './readiness-gate-fixture.js'; + +const { + root, + scriptPath, + fixtureDir, + releaseGitSha, + baseArgs, + prepareReportPath, + malformedPrepareReportPath, + minimalPrepareReportPath, + invalidTimestampPrepareReportPath, + invalidAllTimestampsPrepareReportPath, + invalidMissingByPhasePrepareReportPath, + runtimeReportPath, + nodeVerifyReportPath, + tuiVerifyRoot, + invalidTimestampTuiVerifyRoot, + mismatchedTuiVerifyRoot, + minimalTuiVerifyRoot, + malformedTuiVerifyRoot, + trustRoot, + missingSbomTrustRoot, + malformedTrustRoot, + malformedShapeTrustRoot, + ciStatusesPath, + malformedCiStatusesPath, + invalidShapeCiStatusesPath, + ciSummaryPath, + malformedCiSummaryPath, + invalidShapeCiSummaryPath, + coverageDir +} = await setupReadinessGateFixture( + 'release-readiness-gate-report-shape', + { scenarios: ['reportShape'] } +); + +const malformedTuiJsonRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + malformedTuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-tui-json-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-tui-json-summary.md') + ], + 'release readiness gate malformed tui json', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedTuiJsonRun.status, 0, 'expected malformed TUI JSON to fail readiness'); +const malformedTuiJsonPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-tui-json-summary.json'), 'utf8') +); +assert.ok( + malformedTuiJsonPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.invalid-json'), + 'expected readiness blockers to report malformed TUI JSON' +); +assert.equal( + malformedTuiJsonPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.failed'), + false, + 'malformed TUI JSON must not be duplicated as a generic TUI failure when all targets are present' +); +assert.deepEqual( + malformedTuiJsonPayload.releaseChecks.tuiVerify.invalidReports, + [ + path.relative(root, path.join(malformedTuiVerifyRoot, 'macos', 'release_check_report.json')).replace(/\\/g, '/') + ], + 'expected readiness summary to expose malformed TUI report paths' +); + +const malformedJsonRun = runNode( + [ + scriptPath, + '--prepare-report', + malformedPrepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-json-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-json-summary.md') + ], + 'release readiness gate malformed input json', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedJsonRun.status, 0, 'expected malformed JSON input to fail readiness'); +const malformedJsonPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-json-summary.json'), 'utf8') +); +assert.ok( + malformedJsonPayload.blockers.some((blocker) => blocker.id === 'prepare.invalid-json'), + 'expected readiness blockers to report malformed prepare JSON' +); +assert.equal( + malformedJsonPayload.blockers.some((blocker) => blocker.id === 'prepare.missing'), + false, + 'malformed prepare JSON must not be misclassified as missing' +); + +const minimalShapeRun = runNode( + [ + scriptPath, + '--prepare-report', + minimalPrepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'minimal-release-report-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'minimal-release-report-summary.md') + ], + 'release readiness gate minimal spoofed release report', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(minimalShapeRun.status, 0, 'expected minimal ok=true release report to fail readiness'); +const minimalShapePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'minimal-release-report-summary.json'), 'utf8') +); +assert.ok( + minimalShapePayload.blockers.some((blocker) => blocker.id === 'prepare.invalid-shape'), + 'expected readiness blockers to reject spoofed ok=true release reports without release-check shape' +); +assert.equal( + minimalShapePayload.blockers.some((blocker) => blocker.id === 'prepare.failed'), + false, + 'invalid release report shape must not be misclassified as a failed release check' +); + +const missingByPhaseRun = runNode( + [ + scriptPath, + '--prepare-report', + invalidMissingByPhasePrepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'missing-by-phase-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'missing-by-phase-summary.md') + ], + 'release readiness gate missing byPhase release report', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(missingByPhaseRun.status, 0, 'expected release reports missing byPhase counts to fail readiness'); +const missingByPhasePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'missing-by-phase-summary.json'), 'utf8') +); +assert.ok( + missingByPhasePayload.blockers.some((blocker) => blocker.id === 'prepare.invalid-shape' + && /summary\.byPhase\.changelog must be present and equal 1/.test(blocker.detail)), + 'expected readiness blockers to require byPhase coverage for every checked phase' +); +assert.equal( + missingByPhasePayload.blockers.some((blocker) => blocker.id === 'prepare.failed'), + false, + 'missing byPhase counts must not be misclassified as a failed release check' +); + +const invalidTimestampRun = runNode( + [ + scriptPath, + '--prepare-report', + invalidTimestampPrepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-release-report-timestamp-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-release-report-timestamp-summary.md') + ], + 'release readiness gate invalid release report timestamp', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidTimestampRun.status, 0, 'expected invalid release report timestamp to fail readiness'); +const invalidTimestampPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-release-report-timestamp-summary.json'), 'utf8') +); +assert.ok( + invalidTimestampPayload.blockers.some((blocker) => blocker.id === 'prepare.invalid-shape' + && /generatedAt must be an ISO-8601 UTC timestamp/.test(blocker.detail)), + 'expected readiness blockers to reject non-ISO release report timestamps' +); + +const invalidAllTimestampsRun = runNode( + [ + scriptPath, + '--prepare-report', + invalidAllTimestampsPrepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-release-report-all-timestamps-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-release-report-all-timestamps-summary.md') + ], + 'release readiness gate invalid release report all timestamp fields', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidAllTimestampsRun.status, 0, 'expected invalid release report timestamps to fail readiness'); +const invalidAllTimestampsPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-release-report-all-timestamps-summary.json'), 'utf8') +); +const invalidAllTimestampsDetail = invalidAllTimestampsPayload.blockers + .find((blocker) => blocker.id === 'prepare.invalid-shape') + ?.detail || ''; +for (const expected of [ + 'startedAt must be an ISO-8601 UTC timestamp', + 'finishedAt must be an ISO-8601 UTC timestamp', + 'checks[0].startedAt must be an ISO-8601 UTC timestamp', + 'checks[0].finishedAt must be an ISO-8601 UTC timestamp' +]) { + assert.match( + invalidAllTimestampsDetail, + new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + `expected readiness blocker detail to include ${expected}` + ); +} + +const minimalTuiShapeRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + minimalTuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'minimal-tui-release-report-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'minimal-tui-release-report-summary.md') + ], + 'release readiness gate minimal spoofed TUI release reports', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(minimalTuiShapeRun.status, 0, 'expected minimal ok=true TUI release reports to fail readiness'); +const minimalTuiShapePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'minimal-tui-release-report-summary.json'), 'utf8') +); +assert.ok( + minimalTuiShapePayload.blockers.some((blocker) => blocker.id === 'tuiVerify.invalid-shape'), + 'expected readiness blockers to reject spoofed TUI ok=true reports without release-check shape' +); +assert.equal( + minimalTuiShapePayload.blockers.some((blocker) => ( + blocker.id === 'tuiVerify.failed' + && /one or more required TUI verification reports/.test(blocker.detail) + )), + false, + 'invalid TUI release report shape must not be misclassified with the generic failed TUI blocker' +); +assert.deepEqual( + minimalTuiShapePayload.releaseChecks.tuiVerify.invalidShapeReports.sort(), + minimalTuiShapePayload.releaseChecks.tuiVerify.reports.map((entry) => entry.path).sort(), + 'expected every minimal TUI report to be recorded as invalid-shape' +); + +const invalidTuiTimestampRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + invalidTimestampTuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-tui-timestamp-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-tui-timestamp-summary.md') + ], + 'release readiness gate invalid TUI report timestamp', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidTuiTimestampRun.status, 0, 'expected invalid TUI timestamp to fail readiness'); +const invalidTuiTimestampPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-tui-timestamp-summary.json'), 'utf8') +); +assert.ok( + invalidTuiTimestampPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.invalid-shape'), + 'expected readiness blockers to reject TUI reports with invalid timestamp fields' +); +assert.deepEqual( + invalidTuiTimestampPayload.releaseChecks.tuiVerify.invalidShapeReports, + [ + path.relative( + root, + path.join(invalidTimestampTuiVerifyRoot, 'macos', 'release_check_report.json') + ).replace(/\\/g, '/') + ], + 'expected readiness summary to expose the TUI report with invalid timestamp fields' +); + +const mismatchedTuiTargetRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + mismatchedTuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'mismatched-tui-target-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'mismatched-tui-target-summary.md') + ], + 'release readiness gate mismatched TUI runtime target', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(mismatchedTuiTargetRun.status, 0, 'expected mismatched TUI runtime target to fail readiness'); +const mismatchedTuiTargetPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'mismatched-tui-target-summary.json'), 'utf8') +); +assert.ok( + mismatchedTuiTargetPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.target-mismatch' + && /expected macos declared ubuntu/.test(blocker.detail)), + 'expected readiness blockers to reject copied or renamed TUI reports for a different runtime target' +); +assert.equal( + mismatchedTuiTargetPayload.blockers.some((blocker) => ( + blocker.id === 'tuiVerify.failed' + && /one or more required TUI verification reports/.test(blocker.detail) + )), + false, + 'mismatched TUI runtime target must not be misclassified with the generic failed TUI blocker' +); +assert.deepEqual( + mismatchedTuiTargetPayload.releaseChecks.tuiVerify.targetMismatchReports, + [{ + path: path.relative( + root, + path.join(mismatchedTuiVerifyRoot, 'macos', 'release_check_report.json') + ).replace(/\\/g, '/'), + expectedTarget: 'macos', + declaredTarget: 'ubuntu' + }], + 'expected readiness summary to expose TUI reports whose declared runtime target does not match their artifact path' +); + +const malformedExpectedTuiTargetsRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + tuiVerifyRoot, + '--tui-verify-targets', + 'ubuntu,,../macos,windows,ubuntu', + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-expected-tui-targets-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-expected-tui-targets-summary.md') + ], + 'release readiness gate malformed expected TUI targets', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual( + malformedExpectedTuiTargetsRun.status, + 0, + 'expected malformed or duplicated expected TUI targets to fail readiness' +); +const malformedExpectedTuiTargetsPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-expected-tui-targets-summary.json'), 'utf8') +); +assert.ok( + malformedExpectedTuiTargetsPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.targets' + && /target entry 2 is empty/.test(blocker.detail) + && /target \.\.\/macos is malformed/.test(blocker.detail) + && /target ubuntu is duplicated/.test(blocker.detail)), + 'expected readiness blockers to reject blank, malformed, and duplicated expected TUI targets' +); +assert.deepEqual( + malformedExpectedTuiTargetsPayload.releaseChecks.tuiVerify.targetErrors, + ['target entry 2 is empty', 'target ../macos is malformed', 'target ubuntu is duplicated'], + 'expected readiness summary to expose blank, malformed, and duplicated expected TUI target inputs' +); +assert.equal( + malformedExpectedTuiTargetsPayload.blockers.some((blocker) => ( + blocker.id === 'tuiVerify.failed' + && /one or more required TUI verification reports/.test(blocker.detail) + )), + false, + 'malformed expected TUI target configuration must not be duplicated as the generic failed TUI blocker' +); + +const duplicateTuiVerifyRoot = path.join(fixtureDir, 'tui-duplicate-artifacts'); +for (const target of ['ubuntu', 'windows', 'macos']) { + fs.mkdirSync(path.join(duplicateTuiVerifyRoot, target), { recursive: true }); + fs.copyFileSync( + path.join(tuiVerifyRoot, target, 'release_check_report.json'), + path.join(duplicateTuiVerifyRoot, target, 'release_check_report.json') + ); +} +fs.mkdirSync(path.join(duplicateTuiVerifyRoot, 'copy', 'verify-tui-ubuntu'), { recursive: true }); +fs.copyFileSync( + path.join(tuiVerifyRoot, 'ubuntu', 'release_check_report.json'), + path.join(duplicateTuiVerifyRoot, 'copy', 'verify-tui-ubuntu', 'release_check_report.json') +); +const duplicateTuiReportsRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + duplicateTuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'duplicate-tui-reports-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'duplicate-tui-reports-summary.md') + ], + 'release readiness gate duplicate TUI verification reports', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(duplicateTuiReportsRun.status, 0, 'expected duplicate TUI verification reports to fail readiness'); +const duplicateTuiReportsPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'duplicate-tui-reports-summary.json'), 'utf8') +); +assert.ok( + duplicateTuiReportsPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.duplicates' + && /ubuntu/.test(blocker.detail)), + 'expected readiness blockers to reject duplicate TUI verification artifacts for a target' +); +assert.deepEqual( + duplicateTuiReportsPayload.releaseChecks.tuiVerify.duplicateTargets.map((entry) => entry.target), + ['ubuntu'], + 'expected readiness summary to identify the duplicated TUI target' +); +assert.deepEqual( + [...duplicateTuiReportsPayload.releaseChecks.tuiVerify.duplicateTargets[0].paths].sort(), + [ + path.relative( + root, + path.join(duplicateTuiVerifyRoot, 'copy', 'verify-tui-ubuntu', 'release_check_report.json') + ).replace(/\\/g, '/'), + path.relative(root, path.join(duplicateTuiVerifyRoot, 'ubuntu', 'release_check_report.json')).replace(/\\/g, '/') + ].sort(), + 'expected readiness summary to expose both duplicate TUI verification artifact paths' +); +assert.equal( + duplicateTuiReportsPayload.blockers.some((blocker) => ( + blocker.id === 'tuiVerify.failed' + && /one or more required TUI verification reports/.test(blocker.detail) + )), + false, + 'duplicate TUI verification reports must not be duplicated as the generic failed TUI blocker' +); + +const malformedSupplementalJsonRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + malformedTrustRoot, + '--ci-statuses', + malformedCiStatusesPath, + '--ci-test-summary', + malformedCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'malformed-supplemental-json-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'malformed-supplemental-json-summary.md') + ], + 'release readiness gate malformed supplemental json', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedSupplementalJsonRun.status, 0, 'expected malformed supplemental JSON to fail readiness'); +const malformedSupplementalJsonPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'malformed-supplemental-json-summary.json'), 'utf8') +); +const malformedSupplementalBlockerIds = new Set( + malformedSupplementalJsonPayload.blockers.map((blocker) => blocker.id) +); +for (const id of ['trust.trustManifest.invalid-json', 'ci.statuses.invalid-json', 'ci.test-summary.invalid-json']) { + assert.ok(malformedSupplementalBlockerIds.has(id), `expected readiness blockers to include ${id}`); +} +for (const id of ['trust.trustManifest', 'ci.ci', 'ci.ci-long', 'ci.test-summary']) { + assert.equal( + malformedSupplementalBlockerIds.has(id), + false, + `malformed JSON must not be misclassified with generic blocker ${id}` + ); +} +assert.equal( + malformedSupplementalJsonPayload.trustChecks.trustManifest, + 'invalid-json', + 'expected JSON summary to classify malformed trust manifest as invalid-json' +); +assert.equal( + malformedSupplementalJsonPayload.ci.testSummaryStatus, + 'invalid-json', + 'expected JSON summary to classify malformed CI test summary as invalid-json' +); +const malformedSupplementalMarkdown = fs.readFileSync( + path.join(fixtureDir, 'readiness', 'malformed-supplemental-json-summary.md'), + 'utf8' +); +assert.match( + malformedSupplementalMarkdown, + /- trust manifest: invalid-json/, + 'expected markdown summary to classify malformed trust manifest as invalid-json' +); +assert.match( + malformedSupplementalMarkdown, + /- test summary: invalid-json/, + 'expected markdown summary to classify malformed CI test summary as invalid-json' +); + +const invalidShapeSupplementalRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + malformedShapeTrustRoot, + '--ci-statuses', + invalidShapeCiStatusesPath, + '--ci-test-summary', + invalidShapeCiSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-shape-supplemental-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-shape-supplemental-summary.md') + ], + 'release readiness gate invalid supplemental shapes', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidShapeSupplementalRun.status, 0, 'expected invalid supplemental shapes to fail readiness'); +const invalidShapeSupplementalPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-shape-supplemental-summary.json'), 'utf8') +); +const invalidShapeSupplementalBlockerIds = new Set( + invalidShapeSupplementalPayload.blockers.map((blocker) => blocker.id) +); +for (const id of [ + 'trust.trustManifest.invalid-shape', + 'ci.statuses.invalid-shape', + 'ci.test-summary.invalid-shape' +]) { + assert.ok(invalidShapeSupplementalBlockerIds.has(id), `expected readiness blockers to include ${id}`); +} +for (const id of ['trust.trustManifest', 'ci.ci', 'ci.ci-long', 'ci.test-summary']) { + assert.equal( + invalidShapeSupplementalBlockerIds.has(id), + false, + `invalid shape must not be misclassified with generic blocker ${id}` + ); +} +assert.equal( + invalidShapeSupplementalPayload.trustChecks.trustManifest, + 'invalid-shape', + 'expected JSON summary to classify malformed trust shape as invalid-shape' +); +assert.equal( + invalidShapeSupplementalPayload.ci.testSummaryStatus, + 'invalid-shape', + 'expected JSON summary to classify malformed CI test summary shape as invalid-shape' +); + +const missingSbomRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + missingSbomTrustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'missing-trust-sboms-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'missing-trust-sboms-summary.md') + ], + 'release readiness gate missing trust SBOM files', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(missingSbomRun.status, 0, 'expected missing trust SBOM files to fail readiness'); +const missingSbomPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'missing-trust-sboms-summary.json'), 'utf8') +); +assert.ok( + missingSbomPayload.blockers.some((blocker) => blocker.id === 'trust.sboms' + && /node-root/.test(blocker.detail) + && /tui/.test(blocker.detail)), + 'expected readiness blockers to require every SBOM listed by the trust manifest' +); +assert.equal( + missingSbomPayload.trustChecks.sboms, + 'missing', + 'expected JSON summary to classify missing trust SBOM files' +); + +console.log('release readiness gate report shape test passed'); diff --git a/tests/tooling/release/readiness-gate.test.js b/tests/tooling/release/readiness-gate.test.js new file mode 100644 index 000000000..69457cda3 --- /dev/null +++ b/tests/tooling/release/readiness-gate.test.js @@ -0,0 +1,387 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { setupReadinessGateFixture } from './readiness-gate-fixture.js'; + +const { + root, + scriptPath, + fixtureDir, + releaseGitSha, + prepareReportPath, + runtimeReportPath, + nodeVerifyReportPath, + tuiVerifyRoot, + trustRoot, + ciStatusesPath, + ciSummaryPath, + coverageDir, + outJsonPath, + outMdPath, + outsideOutJsonPath, + outsideOutMdPath, + baseArgs, + writeJson, + writeTuiReleaseReport, + createTuiReleaseReport +} = await setupReadinessGateFixture('release-readiness-gate', { scenarios: [] }); + +const run = runNode( + [ + ...baseArgs, + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + outJsonPath, + '--out-md', + outMdPath + ], + 'release readiness gate', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(run.status, 0, run.stderr || run.stdout || 'expected readiness gate to pass'); +const payload = JSON.parse(fs.readFileSync(outJsonPath, 'utf8')); +assert.equal(payload.ok, true, 'expected readiness gate to report ok=true'); +assert.deepEqual(payload.blockers, [], 'expected no blockers'); +assert.equal( + Object.hasOwn(payload, 'usrApproval'), + false, + 'readiness payload must not expose non-technical USR approval state' +); +assert.equal(fs.existsSync(outMdPath), true, 'expected markdown readiness report'); +assert.doesNotMatch( + fs.readFileSync(outMdPath, 'utf8'), + /USR approval|usrApproval|approval lock/i, + 'readiness markdown must not expose non-technical USR approval state' +); + +const outsideOutJsonRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + outsideOutJsonPath, + '--out-md', + path.join(fixtureDir, 'readiness', 'outside-json-summary.md') + ], + 'release readiness gate outside json output', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(outsideOutJsonRun.status, 0, 'expected outside readiness JSON output to fail'); +assert.match( + outsideOutJsonRun.stderr, + /out-json must stay within repo root/, + 'expected outside readiness JSON output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideOutJsonPath), false, 'expected readiness gate not to create outside JSON output'); + +const outsideOutMdRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'outside-md-summary.json'), + '--out-md', + outsideOutMdPath + ], + 'release readiness gate outside markdown output', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(outsideOutMdRun.status, 0, 'expected outside readiness markdown output to fail'); +assert.match( + outsideOutMdRun.stderr, + /out-md must stay within repo root/, + 'expected outside readiness markdown output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideOutMdPath), false, 'expected readiness gate not to create outside markdown output'); + +const symlinkReadinessTarget = path.resolve(root, '..', `outside-release-readiness-symlink-${process.pid}`); +const symlinkReadinessDir = path.join(fixtureDir, 'readiness-symlink'); +try { + fs.mkdirSync(symlinkReadinessTarget, { recursive: true }); + fs.rmSync(symlinkReadinessDir, { recursive: true, force: true }); + fs.symlinkSync(symlinkReadinessTarget, symlinkReadinessDir, 'junction'); + const symlinkOutJsonRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(symlinkReadinessDir, 'readiness-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'symlink-json-summary.md') + ], + 'release readiness gate symlink json output', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(symlinkOutJsonRun.status, 0, 'expected symlinked readiness JSON output to fail'); + assert.match( + symlinkOutJsonRun.stderr, + /out-json must not use symlink path segment/, + 'expected readiness gate to reject symlink path segments before writing JSON output' + ); + assert.equal( + fs.existsSync(path.join(symlinkReadinessTarget, 'readiness-summary.json')), + false, + 'expected readiness gate not to write through a symlinked output directory' + ); +} catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`readiness-gate symlink output assertion skipped: ${error.code}`); +} finally { + fs.rmSync(symlinkReadinessDir, { recursive: true, force: true }); + fs.rmSync(symlinkReadinessTarget, { recursive: true, force: true }); +} + +const invalidTuiRootRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + path.resolve(root, '..', 'outside-release-tui'), + '--out-json', + path.join(fixtureDir, 'readiness', 'invalid-tui-root-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'invalid-tui-root-summary.md') + ], + 'release readiness gate invalid tui root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(invalidTuiRootRun.status, 0, 'expected outside TUI root to fail readiness'); +const invalidTuiRootPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'invalid-tui-root-summary.json'), 'utf8') +); +assert.ok( + invalidTuiRootPayload.blockers.some((blocker) => blocker.id === 'path.tui-verify-root'), + 'expected readiness blockers to reject TUI roots outside the repo' +); + +const symlinkInputTarget = path.resolve(root, '..', `outside-release-readiness-input-${process.pid}`); +const symlinkTuiRoot = path.join(fixtureDir, 'tui-symlink-root'); +const symlinkTrustRoot = path.join(fixtureDir, 'trust-symlink-root'); +const symlinkCoverageRoot = path.join(fixtureDir, 'coverage-symlink-root'); +try { + fs.mkdirSync(symlinkInputTarget, { recursive: true }); + for (const symlinkRoot of [symlinkTuiRoot, symlinkTrustRoot, symlinkCoverageRoot]) { + fs.rmSync(symlinkRoot, { recursive: true, force: true }); + fs.symlinkSync(symlinkInputTarget, symlinkRoot, 'junction'); + } + + const symlinkTuiRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + symlinkTuiRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'symlink-tui-root-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'symlink-tui-root-summary.md') + ], + 'release readiness gate symlink TUI root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.notEqual(symlinkTuiRun.status, 0, 'expected symlinked TUI root to fail readiness'); + const symlinkTuiPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'symlink-tui-root-summary.json'), 'utf8') + ); + assert.ok( + symlinkTuiPayload.blockers.some((blocker) => blocker.id === 'path.tui-verify-root' + && /must not use symlink path segment/.test(blocker.detail)), + 'expected readiness blockers to reject symlinked TUI roots' + ); + + const symlinkTrustRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + symlinkTrustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + coverageDir, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'symlink-trust-root-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'symlink-trust-root-summary.md') + ], + 'release readiness gate symlink trust root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.notEqual(symlinkTrustRun.status, 0, 'expected symlinked trust root to fail readiness'); + const symlinkTrustPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'symlink-trust-root-summary.json'), 'utf8') + ); + assert.ok( + symlinkTrustPayload.blockers.some((blocker) => blocker.id === 'path.trust-root' + && /must not use symlink path segment/.test(blocker.detail)), + 'expected readiness blockers to reject symlinked trust roots' + ); + + const symlinkCoverageRun = runNode( + [ + scriptPath, + '--prepare-report', + prepareReportPath, + '--runtime-report', + runtimeReportPath, + '--node-verify-report', + nodeVerifyReportPath, + '--trust-root', + trustRoot, + '--ci-statuses', + ciStatusesPath, + '--ci-test-summary', + ciSummaryPath, + '--release-git-sha', + releaseGitSha, + '--coverage-dir', + symlinkCoverageRoot, + '--attested', + '--tui-verify-root', + tuiVerifyRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'symlink-coverage-root-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'symlink-coverage-root-summary.md') + ], + 'release readiness gate symlink coverage root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.notEqual(symlinkCoverageRun.status, 0, 'expected symlinked coverage root to fail readiness'); + const symlinkCoveragePayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'symlink-coverage-root-summary.json'), 'utf8') + ); + assert.ok( + symlinkCoveragePayload.blockers.some((blocker) => blocker.id === 'path.coverage-dir' + && /must not use symlink path segment/.test(blocker.detail)), + 'expected readiness blockers to reject symlinked coverage roots' + ); +} catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`readiness-gate symlink input assertion skipped: ${error.code}`); +} finally { + fs.rmSync(symlinkTuiRoot, { recursive: true, force: true }); + fs.rmSync(symlinkTrustRoot, { recursive: true, force: true }); + fs.rmSync(symlinkCoverageRoot, { recursive: true, force: true }); + fs.rmSync(symlinkInputTarget, { recursive: true, force: true }); +} + +const missingMacosRoot = path.join(fixtureDir, 'tui-missing-macos'); +writeTuiReleaseReport(missingMacosRoot, 'ubuntu'); +writeTuiReleaseReport(missingMacosRoot, 'windows'); +const missingTargetRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + missingMacosRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'missing-target-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'missing-target-summary.md') + ], + 'release readiness gate missing target', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(missingTargetRun.status, 0, 'expected readiness gate to fail when a TUI target report is missing'); +const missingTargetPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'missing-target-summary.json'), 'utf8') +); +assert.equal(missingTargetPayload.ok, false, 'expected readiness gate to report ok=false when a TUI target is missing'); +assert.equal(missingTargetPayload.releaseChecks.tuiVerify.ok, false, 'expected tui verification gate to fail'); +assert.deepEqual(missingTargetPayload.releaseChecks.tuiVerify.missingTargets, ['macos']); + +const duplicateTuiRoot = path.join(fixtureDir, 'tui-duplicate-target'); +writeJson( + path.join(duplicateTuiRoot, 'current', 'verify-tui-ubuntu', 'release_check_report.json'), + createTuiReleaseReport('ubuntu') +); +writeJson( + path.join(duplicateTuiRoot, 'current', 'verify-tui-windows', 'release_check_report.json'), + createTuiReleaseReport('windows') +); +writeJson( + path.join(duplicateTuiRoot, 'current', 'verify-tui-macos', 'release_check_report.json'), + createTuiReleaseReport('macos') +); +writeJson( + path.join(duplicateTuiRoot, 'stale', 'verify-tui-macos', 'release_check_report.json'), + createTuiReleaseReport('macos', { status: 'failed' }) +); +const duplicateTuiRun = runNode( + [ + ...baseArgs, + '--tui-verify-root', + duplicateTuiRoot, + '--out-json', + path.join(fixtureDir, 'readiness', 'duplicate-tui-summary.json'), + '--out-md', + path.join(fixtureDir, 'readiness', 'duplicate-tui-summary.md') + ], + 'release readiness gate duplicate tui target', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(duplicateTuiRun.status, 0, 'expected duplicate TUI target reports to fail readiness'); +const duplicateTuiPayload = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'readiness', 'duplicate-tui-summary.json'), 'utf8') +); +assert.ok( + duplicateTuiPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.duplicates'), + 'expected readiness blockers to report duplicate TUI target reports' +); +assert.ok( + duplicateTuiPayload.blockers.some((blocker) => blocker.id === 'tuiVerify.failed'), + 'expected readiness blockers to report the failed duplicate TUI report path' +); +assert.deepEqual( + duplicateTuiPayload.releaseChecks.tuiVerify.duplicateTargets.map((entry) => entry.target), + ['macos'], + 'expected readiness summary to expose the duplicated TUI target' +); + + +console.log('release readiness gate test passed'); diff --git a/tests/tooling/release/release-bundle-assembly.test.js b/tests/tooling/release/release-bundle-assembly.test.js new file mode 100644 index 000000000..fffd4725e --- /dev/null +++ b/tests/tooling/release/release-bundle-assembly.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'release', 'assemble-bundle.js'); +const { dir: fixtureDir } = await prepareTestCacheDir('release-bundle-assembly'); +const artifactRoot = path.join(fixtureDir, 'downloads'); +const outDir = path.join(fixtureDir, 'bundle'); +const metadataPath = path.join(fixtureDir, 'metadata.json'); +const outsideArtifactRoot = path.resolve(root, '..', 'outside-release-artifacts'); +const outsideOutDir = path.resolve(root, '..', `outside-release-bundle-output-${process.pid}`); + +fs.mkdirSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode'), { recursive: true }); +fs.mkdirSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'nested'), { recursive: true }); +fs.mkdirSync(path.join(artifactRoot, 'release-prepare'), { recursive: true }); +fs.writeFileSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'Zeta.txt'), 'zeta-upper'); +fs.writeFileSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'alpha.txt'), 'alpha-lower'); +fs.writeFileSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'nested', 'z.txt'), 'nested-z'); +fs.writeFileSync(path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'pairofcleats.vsix'), 'vsix-bytes'); +fs.writeFileSync(path.join(artifactRoot, 'release-prepare', 'notes.md'), 'notes'); +fs.writeFileSync(metadataPath, `${JSON.stringify({ + releaseVersion: '0.3.0', + releaseTag: 'v0.3.0', + gitSha: 'abc123' +}, null, 2)}\n`); + +const run = runNode( + [scriptPath, '--artifact-root', artifactRoot, '--metadata', metadataPath, '--out', outDir], + 'release bundle assembly', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(run.status, 0, run.stderr || run.stdout || 'expected release bundle assembly to succeed'); +const payload = JSON.parse(run.stdout || '{}'); +assert.equal(payload.ok, true, 'expected assembly payload ok=true'); +const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'release-artifacts.json'), 'utf8')); +assert.equal(manifest.releaseTag, 'v0.3.0', 'expected release tag in bundle manifest'); +assert.deepEqual( + manifest.artifacts.map((artifact) => artifact.path), + [ + path.relative(root, path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'Zeta.txt')).replace(/\\/g, '/'), + path.relative(root, path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'alpha.txt')).replace(/\\/g, '/'), + path.relative(root, path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'nested', 'z.txt')).replace(/\\/g, '/'), + path.relative(root, path.join(artifactRoot, 'release-node-packages', 'dist', 'vscode', 'pairofcleats.vsix')).replace(/\\/g, '/'), + path.relative(root, path.join(artifactRoot, 'release-prepare', 'notes.md')).replace(/\\/g, '/') + ], + 'expected deterministic binary artifact order in bundle manifest' +); +assert.equal(fs.existsSync(path.join(outDir, 'release-checksums.txt')), true, 'expected checksums output'); + +const outsideRootRun = runNode( + [scriptPath, '--artifact-root', outsideArtifactRoot, '--metadata', metadataPath, '--out', path.join(fixtureDir, 'outside-bundle')], + 'release bundle outside artifact root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideRootRun.status, 0, 'expected outside artifact root to fail'); +assert.match( + outsideRootRun.stderr, + /artifact root must stay within repo root/, + 'expected outside artifact root failure to explain repo-root boundary' +); + +const outsideOutRun = runNode( + [scriptPath, '--artifact-root', artifactRoot, '--metadata', metadataPath, '--out', outsideOutDir], + 'release bundle outside output directory', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideOutRun.status, 0, 'expected outside output directory to fail'); +assert.match( + outsideOutRun.stderr, + /output directory must stay within repo root/, + 'expected outside output failure to explain repo-root boundary' +); +assert.equal( + fs.existsSync(outsideOutDir), + false, + 'expected release bundle command not to create an outside output directory' +); + +console.log('release bundle assembly test passed'); diff --git a/tests/tooling/release/release-check-output-paths.test.js b/tests/tooling/release/release-check-output-paths.test.js new file mode 100644 index 000000000..60934e114 --- /dev/null +++ b/tests/tooling/release/release-check-output-paths.test.js @@ -0,0 +1,237 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'release', 'check.js'); +const { dir: fixtureDir } = await prepareTestCacheDir('release-check-output-paths'); +const outsideReportPath = path.resolve(root, '..', `outside-release-check-report-${process.pid}.json`); +const outsideManifestPath = path.resolve(root, '..', `outside-release-check-manifest-${process.pid}.json`); +const artifactPath = path.join(root, 'dist', 'vscode', 'pairofcleats.vsix'); +const artifactBackupPath = path.join(fixtureDir, 'pairofcleats.vsix.backup'); +const artifactExisted = fs.existsSync(artifactPath); + +const outsideReportRun = runNode( + [ + scriptPath, + '--dry-run', + '--report', + outsideReportPath, + '--manifest', + path.join(fixtureDir, 'release-manifest.json') + ], + 'release check outside report path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideReportRun.status, 0, 'expected outside release-check report path to fail'); +assert.match( + outsideReportRun.stderr, + /report path must stay within repo root/, + 'expected outside report failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideReportPath), false, 'expected release-check not to create outside report'); + +const outsideManifestRun = runNode( + [ + scriptPath, + '--dry-run', + '--report', + path.join(fixtureDir, 'release-check-report.json'), + '--manifest', + outsideManifestPath + ], + 'release check outside manifest path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideManifestRun.status, 0, 'expected outside release-check manifest path to fail'); +assert.match( + outsideManifestRun.stderr, + /manifest path must stay within repo root/, + 'expected outside manifest failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideManifestPath), false, 'expected release-check not to create outside manifest'); + +const zeroScopeRun = runNode( + [ + scriptPath, + '--dry-run', + '--surfaces', + 'tui', + '--phases', + 'smoke', + '--report', + path.join(fixtureDir, 'zero-scope-report.json'), + '--manifest', + path.join(fixtureDir, 'zero-scope-manifest.json') + ], + 'release check empty explicit surface phase scope', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(zeroScopeRun.status, 0, 'expected empty explicit surface/phase scope to fail'); +assert.match( + zeroScopeRun.stderr, + /selected scope has no executable checks for phase\(s\): smoke/, + 'expected release-check to reject explicit scopes that select zero executable checks' +); + +const runtimeTargetReportPath = path.join(fixtureDir, 'runtime-target-report.json'); +const runtimeTargetRun = runNode( + [ + scriptPath, + '--dry-run', + '--surfaces', + 'tui', + '--phases', + 'install,boot', + '--runtime-target', + 'macos', + '--report', + runtimeTargetReportPath, + '--manifest', + path.join(fixtureDir, 'runtime-target-manifest.json') + ], + 'release check runtime target scope', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(runtimeTargetRun.status, 0, runtimeTargetRun.stderr || runtimeTargetRun.stdout); +assert.equal( + JSON.parse(fs.readFileSync(runtimeTargetReportPath, 'utf8')).scope.runtimeTarget, + 'macos', + 'expected release-check reports to record matrix runtime target metadata' +); + +const malformedRuntimeTargetRun = runNode( + [ + scriptPath, + '--dry-run', + '--runtime-target', + '../macos', + '--report', + path.join(fixtureDir, 'malformed-runtime-target-report.json'), + '--manifest', + path.join(fixtureDir, 'malformed-runtime-target-manifest.json') + ], + 'release check malformed runtime target scope', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(malformedRuntimeTargetRun.status, 0, 'expected malformed runtime target metadata to fail'); +assert.match( + malformedRuntimeTargetRun.stderr, + /runtime target is malformed/, + 'expected release-check to reject unsafe runtime target metadata' +); + +const outsideSymlinkTarget = path.resolve(root, '..', `outside-release-check-output-dir-${process.pid}`); +const symlinkOutputDir = path.join(fixtureDir, 'symlink-output-dir'); +try { + fs.mkdirSync(outsideSymlinkTarget, { recursive: true }); + fs.rmSync(symlinkOutputDir, { recursive: true, force: true }); + fs.symlinkSync(outsideSymlinkTarget, symlinkOutputDir, 'junction'); + const symlinkOutputRun = runNode( + [ + scriptPath, + '--dry-run', + '--phases', + 'changelog', + '--report', + path.join(symlinkOutputDir, 'release-check-report.json'), + '--manifest', + path.join(fixtureDir, 'symlink-output-manifest.json') + ], + 'release check symlink output path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(symlinkOutputRun.status, 0, 'expected symlinked release-check output path to fail'); + assert.match( + symlinkOutputRun.stderr, + /report path must not use symlink path segment/, + 'expected release-check to reject symlink path segments before writing outputs' + ); + assert.equal( + fs.existsSync(path.join(outsideSymlinkTarget, 'release-check-report.json')), + false, + 'expected release-check not to write through a symlinked output directory' + ); +} catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`release-check symlink output assertion skipped: ${error.code}`); +} finally { + fs.rmSync(symlinkOutputDir, { recursive: true, force: true }); + fs.rmSync(outsideSymlinkTarget, { recursive: true, force: true }); +} + +if (artifactExisted) { + fs.mkdirSync(path.dirname(artifactBackupPath), { recursive: true }); + fs.copyFileSync(artifactPath, artifactBackupPath); +} +try { + fs.mkdirSync(path.dirname(artifactPath), { recursive: true }); + fs.rmSync(artifactPath, { force: true }); + const outsideArtifactTarget = path.resolve(root, '..', `outside-release-check-artifact-${process.pid}.txt`); + fs.writeFileSync(outsideArtifactTarget, 'outside artifact target\n'); + try { + fs.symlinkSync(outsideArtifactTarget, artifactPath, 'file'); + const symlinkArtifactRun = runNode( + [ + scriptPath, + '--dry-run', + '--surfaces', + 'vscode', + '--phases', + 'install', + '--report', + path.join(fixtureDir, 'symlink-artifact-report.json'), + '--manifest', + path.join(fixtureDir, 'symlink-artifact-manifest.json') + ], + 'release check symlink manifest artifact', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(symlinkArtifactRun.status, 0, 'expected symlink release-check artifact to fail'); + assert.match( + symlinkArtifactRun.stderr, + /manifest artifact path must not be a symlink/, + 'expected release-check to reject symlink artifacts before hashing' + ); + } catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`release-check symlink artifact assertion skipped: ${error.code}`); + } finally { + fs.rmSync(outsideArtifactTarget, { force: true }); + } +} finally { + fs.rmSync(artifactPath, { force: true }); + if (artifactExisted) { + fs.copyFileSync(artifactBackupPath, artifactPath); + } +} + +console.log('release check output paths test passed'); diff --git a/tests/tooling/release/release-metadata.test.js b/tests/tooling/release/release-metadata.test.js new file mode 100644 index 000000000..47fbc8802 --- /dev/null +++ b/tests/tooling/release/release-metadata.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'release', 'metadata.js'); +const { dir: outDir } = await prepareTestCacheDir('release-metadata'); +const outPath = path.join(outDir, 'metadata.json'); +const notesPath = path.join(outDir, 'notes.md'); +const outsideOutPath = path.resolve(root, '..', `outside-release-metadata-${process.pid}.json`); +const outsideNotesPath = path.resolve(root, '..', `outside-release-notes-${process.pid}.md`); +const releaseGitSha = '0123456789abcdef0123456789abcdef01234567'; + +const run = runNode( + [scriptPath, '--tag', 'v0.3.0', '--git-sha', releaseGitSha, '--out', outPath, '--notes-out', notesPath], + 'release metadata', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(run.status, 0, run.stderr || run.stdout || 'expected release metadata command to succeed'); +const payload = JSON.parse(fs.readFileSync(outPath, 'utf8')); +assert.equal(payload.releaseVersion, '0.3.0', 'expected root package release version'); +assert.equal(payload.releaseTag, 'v0.3.0', 'expected validated release tag'); +assert.equal(payload.gitSha, releaseGitSha, 'expected release metadata to use the checked-out release SHA'); +assert.equal(Array.isArray(payload.shippedSurfaces), true, 'expected shipped surfaces version metadata'); +assert.equal(fs.existsSync(notesPath), true, 'expected release notes output'); +assert.match(fs.readFileSync(notesPath, 'utf8'), /^##\s+v?0\.3\.0/m, 'expected notes to contain current changelog section'); + +const outsideOutRun = runNode( + [scriptPath, '--tag', 'v0.3.0', '--git-sha', releaseGitSha, '--out', outsideOutPath], + 'release metadata outside output path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(outsideOutRun.status, 0, 'expected outside metadata output to fail'); +assert.match( + outsideOutRun.stderr, + /output path must stay within repo root/, + 'expected outside metadata output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideOutPath), false, 'expected metadata command not to create outside output'); + +const outsideNotesRun = runNode( + [scriptPath, '--tag', 'v0.3.0', '--git-sha', releaseGitSha, '--notes-out', outsideNotesPath], + 'release metadata outside notes output path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(outsideNotesRun.status, 0, 'expected outside notes output to fail'); +assert.match( + outsideNotesRun.stderr, + /notes output path must stay within repo root/, + 'expected outside notes output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideNotesPath), false, 'expected metadata command not to create outside notes'); + +console.log('release metadata test passed'); diff --git a/tests/tooling/release/shipped-surface-registry.test.js b/tests/tooling/release/shipped-surface-registry.test.js new file mode 100644 index 000000000..1a5b9829f --- /dev/null +++ b/tests/tooling/release/shipped-surface-registry.test.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { loadShippedSurfaces, getReleaseCheckSurfaceSteps } from '../../../tools/release/surfaces.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const registry = loadShippedSurfaces(root); + +assert.equal(registry.schemaVersion, '1.0.0'); +assert.ok(fs.existsSync(registry.registryPath), 'expected shipped surface registry file'); + +const surfaceIds = registry.surfaces.map((surface) => surface.id); +assert.deepEqual( + surfaceIds, + ['cli', 'api', 'mcp', 'indexer-service', 'vscode', 'sublime', 'tui'] +); + +for (const surface of registry.surfaces) { + assert.ok(surface.name, `expected surface name for ${surface.id}`); + assert.ok(surface.owner, `expected owner for ${surface.id}`); + assert.ok(surface.packagingBoundary, `expected packaging boundary for ${surface.id}`); + assert.ok(surface.publishBoundary, `expected publish boundary for ${surface.id}`); + assert.ok(surface.versionSource, `expected version source for ${surface.id}`); + assert.ok(surface.install.summary, `expected install summary for ${surface.id}`); + assert.ok(surface.smoke.summary, `expected smoke summary for ${surface.id}`); + for (const step of surface.releaseCheck.steps) { + assert.ok(['build', 'install', 'boot', 'smoke'].includes(step.phase), `expected valid phase for ${surface.id}:${step.id}`); + for (const artifact of step.artifacts) { + assert.equal(path.isAbsolute(artifact), false, `expected release-check artifact to be repo-relative for ${surface.id}:${step.id}`); + const artifactPath = path.resolve(root, artifact); + assert.equal( + path.relative(root, artifactPath).startsWith('..'), + false, + `expected release-check artifact to stay under repo root for ${surface.id}:${step.id}: ${artifact}` + ); + } + } + for (const sourcePath of surface.build.sourcePaths) { + assert.equal(path.isAbsolute(sourcePath), false, `expected build source path to be repo-relative for ${surface.id}: ${sourcePath}`); + assert.ok( + fs.existsSync(path.join(root, sourcePath)), + `expected build source path for ${surface.id}: ${sourcePath}` + ); + } + for (const outputPath of surface.build.outputs) { + assert.equal(path.isAbsolute(outputPath), false, `expected build output path to be repo-relative for ${surface.id}: ${outputPath}`); + const resolvedOutputPath = path.resolve(root, outputPath); + assert.equal( + path.relative(root, resolvedOutputPath).startsWith('..'), + false, + `expected build output path to stay under repo root for ${surface.id}: ${outputPath}` + ); + } +} + +const stepIds = getReleaseCheckSurfaceSteps(root).map((step) => step.id); +assert.deepEqual( + stepIds, + [ + 'smoke.version', + 'smoke.fixture-index-build', + 'smoke.fixture-index-validate-strict', + 'smoke.fixture-search', + 'api.boot.server', + 'api.smoke.workflow', + 'mcp.boot.initialize', + 'mcp.smoke.workflow', + 'smoke.service-mode', + 'smoke.editor-vscode', + 'vscode.install.unpack', + 'smoke.editor-sublime', + 'sublime.install.unpack', + 'smoke.tui-build', + 'smoke.tui-install', + 'tui.boot.wrapper' + ] +); + +const { dir: fixtureDir } = await prepareTestCacheDir('release-shipped-surface-registry'); + +const writeRegistry = (registryRoot, overrides = {}) => { + const registryDir = path.join(registryRoot, 'docs', 'tooling'); + fs.mkdirSync(registryDir, { recursive: true }); + const surface = { + id: 'bad', + name: 'Bad Surface', + owner: 'Release', + supportLevel: 'supported', + packagingBoundary: 'test package', + publishBoundary: 'test publish', + versionSource: 'package.json', + runtimeTargets: [], + platforms: [], + build: { + kind: 'test', + sourcePaths: ['package.json'], + outputs: ['dist/bad'], + ...(overrides.build || {}) + }, + install: { + kind: 'test', + summary: 'test install' + }, + smoke: { + summary: 'test smoke' + }, + releaseCheck: { + enabled: true, + steps: [ + { + id: 'bad.step', + phase: 'install', + label: 'Bad step', + command: ['node', '--version'], + artifacts: ['dist/bad/artifact.json'], + ...(overrides.step || {}) + } + ] + } + }; + fs.writeFileSync( + path.join(registryDir, 'shipped-surfaces.json'), + `${JSON.stringify({ schemaVersion: '1.0.0', surfaces: [surface] }, null, 2)}\n` + ); +}; + +const escapingArtifactRoot = path.join(fixtureDir, 'escaping-artifact'); +writeRegistry(escapingArtifactRoot, { step: { artifacts: ['../outside-artifact.json'] } }); +assert.throws( + () => loadShippedSurfaces(escapingArtifactRoot), + /bad:bad\.step artifact must stay within repo root/, + 'expected release-check artifact registry entries to stay under the repo root' +); + +const escapingBuildRoot = path.join(fixtureDir, 'escaping-build-source'); +writeRegistry(escapingBuildRoot, { build: { sourcePaths: ['../outside-source.js'] } }); +assert.throws( + () => loadShippedSurfaces(escapingBuildRoot), + /bad build source path must stay within repo root/, + 'expected build source registry entries to stay under the repo root' +); + +const absoluteOutputRoot = path.join(fixtureDir, 'absolute-build-output'); +writeRegistry(absoluteOutputRoot, { build: { outputs: [path.resolve(root, 'dist', 'bad')] } }); +assert.throws( + () => loadShippedSurfaces(absoluteOutputRoot), + /bad build output path must be repo-relative/, + 'expected build output registry entries to be repo-relative' +); + +console.log('shipped surface registry test passed'); diff --git a/tests/tooling/release/trust-materials.test.js b/tests/tooling/release/trust-materials.test.js new file mode 100644 index 000000000..160771c12 --- /dev/null +++ b/tests/tooling/release/trust-materials.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'release', 'generate-trust-materials.js'); +const { dir: fixtureDir } = await prepareTestCacheDir('release-trust-materials'); +const bundleDir = path.join(fixtureDir, 'bundle'); +const outDir = path.join(fixtureDir, 'trust'); +const metadataPath = path.join(fixtureDir, 'metadata.json'); +const nodeSbomInput = path.join(fixtureDir, 'node-input.cyclonedx.json'); +const cargoSbomInput = path.join(fixtureDir, 'cargo-input.cyclonedx.json'); +const outsideOutDir = path.resolve(root, '..', `outside-release-trust-${process.pid}`); +const releaseGitSha = '0123456789abcdef0123456789abcdef01234567'; + +fs.mkdirSync(bundleDir, { recursive: true }); +const artifacts = [ + { path: 'dist/vscode/pairofcleats.vsix', sizeBytes: 10, sha256: 'a'.repeat(64) }, + { path: 'dist/sublime/pairofcleats.sublime-package', sizeBytes: 20, sha256: 'b'.repeat(64) } +]; +fs.writeFileSync(path.join(bundleDir, 'release-artifacts.json'), `${JSON.stringify({ + schemaVersion: 1, + releaseVersion: '0.3.0', + releaseTag: 'v0.3.0', + gitSha: releaseGitSha, + artifacts +}, null, 2)}\n`); +fs.writeFileSync( + path.join(bundleDir, 'release-checksums.txt'), + `${artifacts.map((artifact) => `${artifact.sha256} ${artifact.path}`).join('\n')}\n` +); +fs.writeFileSync(metadataPath, `${JSON.stringify({ + releaseVersion: '0.3.0', + releaseTag: 'v0.3.0', + gitSha: releaseGitSha +}, null, 2)}\n`); +fs.writeFileSync(nodeSbomInput, '{"bomFormat":"CycloneDX","metadata":{"component":{"name":"node-root"}}}\n'); +fs.writeFileSync(cargoSbomInput, '{"bomFormat":"CycloneDX","metadata":{"component":{"name":"tui"}}}\n'); + +const run = runNode( + [ + scriptPath, + '--bundle-dir', + bundleDir, + '--metadata', + metadataPath, + '--out-dir', + outDir, + '--node-sbom-input', + nodeSbomInput, + '--cargo-sbom-input', + cargoSbomInput + ], + 'release trust materials', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.equal(run.status, 0, run.stderr || run.stdout || 'expected trust materials generation to succeed'); +const payload = JSON.parse(run.stdout || '{}'); +assert.equal(payload.ok, true, 'expected ok=true payload'); +const trustManifest = JSON.parse(fs.readFileSync(path.join(outDir, 'trust-manifest.json'), 'utf8')); +assert.equal(trustManifest.releaseTag, 'v0.3.0', 'expected release tag in trust manifest'); +const provenanceSummary = JSON.parse(fs.readFileSync(path.join(outDir, 'provenance-summary.json'), 'utf8')); +assert.equal(provenanceSummary.attestationProvider, 'github-actions-attest-build-provenance', 'expected attestation provider summary'); +assert.equal(provenanceSummary.sha, releaseGitSha, 'expected provenance summary to use metadata release SHA'); +const checksumBundle = JSON.parse(fs.readFileSync(path.join(outDir, 'release-checksum-bundle.json'), 'utf8')); +assert.equal(checksumBundle.artifacts.length, 2, 'expected artifacts copied into checksum bundle'); +assert.equal(checksumBundle.sourceCommit, releaseGitSha, 'expected checksum bundle to use metadata release SHA'); +assert.equal(fs.existsSync(path.join(outDir, 'node-root.cyclonedx.json')), true, 'expected node sbom output'); +assert.equal(fs.existsSync(path.join(outDir, 'tui.cyclonedx.json')), true, 'expected cargo sbom output'); + +const outsideOutRun = runNode( + [ + scriptPath, + '--bundle-dir', + bundleDir, + '--metadata', + metadataPath, + '--out-dir', + outsideOutDir, + '--node-sbom-input', + nodeSbomInput, + '--cargo-sbom-input', + cargoSbomInput + ], + 'release trust materials outside output directory', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); +assert.notEqual(outsideOutRun.status, 0, 'expected outside trust output directory to fail'); +assert.match( + outsideOutRun.stderr, + /output directory must stay within repo root/, + 'expected outside trust output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideOutDir), false, 'expected trust command not to create outside output directory'); + +console.log('release trust materials test passed'); diff --git a/tests/tooling/release/verify-surface-api-boot-runtime.test.js b/tests/tooling/release/verify-surface-api-boot-runtime.test.js new file mode 100644 index 000000000..4eb85c774 --- /dev/null +++ b/tests/tooling/release/verify-surface-api-boot-runtime.test.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runVerifySurface } from '../../helpers/release-verify-surface.js'; + +const apiBoot = await runVerifySurface('api', 'boot'); +assert.equal(apiBoot.checks?.health, true, 'expected api boot to verify /health'); + +console.log('release verify-surface api boot runtime test passed'); diff --git a/tests/tooling/release/verify-surface-api-smoke-runtime.test.js b/tests/tooling/release/verify-surface-api-smoke-runtime.test.js new file mode 100644 index 000000000..12f015944 --- /dev/null +++ b/tests/tooling/release/verify-surface-api-smoke-runtime.test.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runVerifySurface } from '../../helpers/release-verify-surface.js'; + +const apiSmoke = await runVerifySurface('api', 'smoke'); +assert.equal(apiSmoke.checks?.status, true, 'expected api smoke to verify /status'); +assert.equal(apiSmoke.checks?.capabilities, true, 'expected api smoke to verify /capabilities'); +assert.equal(apiSmoke.checks?.search, true, 'expected api smoke to verify /search'); + +console.log('release verify-surface api smoke runtime test passed'); diff --git a/tests/tooling/release/verify-surface-archive-install.test.js b/tests/tooling/release/verify-surface-archive-install.test.js new file mode 100644 index 000000000..a773a59be --- /dev/null +++ b/tests/tooling/release/verify-surface-archive-install.test.js @@ -0,0 +1,343 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import AdmZip from 'adm-zip'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const verifyScript = path.join(root, 'tools', 'release', 'verify-surface.js'); +const { dir: fixtureDir } = await prepareTestCacheDir('release-verify-archive-install'); +const archivePath = path.join(root, 'dist', 'vscode', 'pairofcleats.vsix'); +const manifestPath = `${archivePath}.manifest.json`; +const backupDir = path.join(fixtureDir, 'backup'); +const archiveBackupPath = path.join(backupDir, 'pairofcleats.vsix'); +const manifestBackupPath = path.join(backupDir, 'pairofcleats.vsix.manifest.json'); +const archiveExisted = fs.existsSync(archivePath); +const manifestExisted = fs.existsSync(manifestPath); +const outPath = path.join(fixtureDir, 'vscode-install-result.json'); +const escapedPath = path.join(path.dirname(outPath), 'archive-entry-escape.txt'); + +const sha256File = (filePath) => { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +}; + +const zipEntryRecords = (zip) => zip.getEntries() + .filter((entry) => !entry.isDirectory) + .map((entry) => ({ + path: entry.entryName, + mode: Math.floor(Number(entry.header?.attr || 0) / 0x10000) || 0o644, + sizeBytes: Number(entry.header?.size || entry.getData().length), + mtime: '2000-01-01T00:00:00.000Z' + })); + +const writeManifestForZip = (zip, overrides = {}) => { + const manifest = { + schemaVersion: 1, + generatedAt: '2026-05-21T18:31:24Z', + archive: path.relative(root, archivePath).replace(/\\/g, '/'), + checksumSha256: sha256File(archivePath), + fixedMtime: '2000-01-01T00:00:00.000Z', + toolchain: { + node: process.versions.node, + archive: 'vsix(zip)' + }, + entries: zipEntryRecords(zip), + ...overrides + }; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +}; + +const backupFile = (sourcePath, backupPath, existed) => { + if (!existed) return; + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(sourcePath, backupPath); +}; + +const restoreFile = (targetPath, backupPath, existed) => { + fs.rmSync(targetPath, { force: true }); + if (!existed) return; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(backupPath, targetPath); +}; + +backupFile(archivePath, archiveBackupPath, archiveExisted); +backupFile(manifestPath, manifestBackupPath, manifestExisted); + +try { + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const zip = new AdmZip(); + zip.addFile('extension/package.json', Buffer.from('{"name":"pairofcleats"}\n')); + zip.addFile('extension/extension.js', Buffer.from('module.exports = {};\n')); + zip.addFile('extension/README.md', Buffer.from('# PairOfCleats\n')); + zip.addFile('archive-entry-escape.txt', Buffer.from('escaped\n')); + zip.getEntries().at(-1).entryName = '../archive-entry-escape.txt'; + zip.writeZip(archivePath); + writeManifestForZip(zip); + + const run = runNode( + [verifyScript, '--surface', 'vscode', '--stage', 'install', '--out', outPath], + 'verify-surface rejects escaping archive entries', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(run.status, 0, 'expected install verification to reject escaping archive entry'); + const payload = JSON.parse(run.stdout || '{}'); + assert.match( + payload.error || '', + /archive entry must stay within unpack root/, + 'expected archive validation to explain the unpack-root boundary' + ); + assert.equal(fs.existsSync(escapedPath), false, 'expected archive install verification not to extract escaping entry'); + + const symlinkZip = new AdmZip(); + symlinkZip.addFile('extension/package.json', Buffer.from('{"name":"pairofcleats"}\n')); + symlinkZip.addFile('extension/extension.js', Buffer.from('module.exports = {};\n')); + symlinkZip.addFile('extension/README.md', Buffer.from('# PairOfCleats\n')); + symlinkZip.addFile('extension/symlink-entry', Buffer.from('extension/package.json')); + symlinkZip.getEntries().at(-1).header.attr = 0o120777 * 0x10000; + symlinkZip.writeZip(archivePath); + writeManifestForZip(symlinkZip); + + const symlinkRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-symlink-result.json') + ], + 'verify-surface rejects symlink archive entries', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(symlinkRun.status, 0, 'expected install verification to reject symlink archive entry'); + const symlinkPayload = JSON.parse(symlinkRun.stdout || '{}'); + assert.match( + symlinkPayload.error || '', + /archive entry must not be a symlink/, + 'expected archive validation to reject symlink entries before extraction' + ); + + const validZip = new AdmZip(); + validZip.addFile('extension/package.json', Buffer.from('{"name":"pairofcleats"}\n')); + validZip.addFile('extension/extension.js', Buffer.from('module.exports = {};\n')); + validZip.addFile('extension/README.md', Buffer.from('# PairOfCleats\n')); + validZip.writeZip(archivePath); + fs.writeFileSync(manifestPath, '{ invalid json'); + + const malformedManifestRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-malformed-manifest-result.json') + ], + 'verify-surface rejects malformed archive manifest', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(malformedManifestRun.status, 0, 'expected install verification to reject malformed manifest'); + const malformedManifestPayload = JSON.parse(malformedManifestRun.stdout || '{}'); + assert.match( + malformedManifestPayload.error || '', + /archive manifest is invalid JSON/, + 'expected archive validation to reject malformed manifests' + ); + + writeManifestForZip(validZip, { + checksumSha256: '0'.repeat(64) + }); + const staleChecksumRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-stale-checksum-result.json') + ], + 'verify-surface rejects stale archive manifest checksum', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(staleChecksumRun.status, 0, 'expected install verification to reject stale manifest checksum'); + const staleChecksumPayload = JSON.parse(staleChecksumRun.stdout || '{}'); + assert.match( + staleChecksumPayload.error || '', + /archive manifest checksum mismatch/, + 'expected archive validation to reject stale manifest checksums' + ); + + writeManifestForZip(validZip, { + entries: zipEntryRecords(validZip).filter((entry) => entry.path !== 'extension/README.md') + }); + const missingEntryRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-missing-entry-manifest-result.json') + ], + 'verify-surface rejects archive manifest missing required entry', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(missingEntryRun.status, 0, 'expected install verification to reject manifest missing required entry'); + const missingEntryPayload = JSON.parse(missingEntryRun.stdout || '{}'); + assert.match( + missingEntryPayload.error || '', + /archive manifest missing archive entry: extension\/README\.md/, + 'expected archive validation to reject manifests that do not match archive entries' + ); + + const sizeMismatchEntries = zipEntryRecords(validZip); + sizeMismatchEntries[0] = { + ...sizeMismatchEntries[0], + sizeBytes: sizeMismatchEntries[0].sizeBytes + 1 + }; + writeManifestForZip(validZip, { entries: sizeMismatchEntries }); + const sizeMismatchRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-size-mismatch-manifest-result.json') + ], + 'verify-surface rejects archive manifest size mismatch', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(sizeMismatchRun.status, 0, 'expected install verification to reject manifest size mismatch'); + const sizeMismatchPayload = JSON.parse(sizeMismatchRun.stdout || '{}'); + assert.match( + sizeMismatchPayload.error || '', + /archive manifest sizeBytes mismatch/, + 'expected archive validation to compare manifest sizeBytes with archive metadata' + ); + + const modeMismatchEntries = zipEntryRecords(validZip); + modeMismatchEntries[0] = { + ...modeMismatchEntries[0], + mode: modeMismatchEntries[0].mode === 0o644 ? 0o755 : 0o644 + }; + writeManifestForZip(validZip, { entries: modeMismatchEntries }); + const modeMismatchRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-mode-mismatch-manifest-result.json') + ], + 'verify-surface rejects archive manifest mode mismatch', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(modeMismatchRun.status, 0, 'expected install verification to reject manifest mode mismatch'); + const modeMismatchPayload = JSON.parse(modeMismatchRun.stdout || '{}'); + assert.match( + modeMismatchPayload.error || '', + /archive manifest mode mismatch/, + 'expected archive validation to compare manifest modes with archive metadata' + ); + + writeManifestForZip(validZip, { + entries: [ + ...zipEntryRecords(validZip), + zipEntryRecords(validZip)[0] + ] + }); + const duplicateManifestRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-duplicate-manifest-entry-result.json') + ], + 'verify-surface rejects duplicate archive manifest entries', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(duplicateManifestRun.status, 0, 'expected install verification to reject duplicate manifest entries'); + const duplicateManifestPayload = JSON.parse(duplicateManifestRun.stdout || '{}'); + assert.match( + duplicateManifestPayload.error || '', + /archive manifest contains duplicate entry/, + 'expected archive validation to reject duplicate manifest paths' + ); + + const duplicateArchiveZip = new AdmZip(); + duplicateArchiveZip.addFile('extension/package.json', Buffer.from('{"name":"pairofcleats"}\n')); + duplicateArchiveZip.addFile('extension/extension.js', Buffer.from('module.exports = {};\n')); + duplicateArchiveZip.addFile('extension/README.md', Buffer.from('# PairOfCleats\n')); + duplicateArchiveZip.addFile('extension/DUPLICATE.md', Buffer.from('duplicate\n')); + duplicateArchiveZip.getEntries().at(-1).entryName = 'extension/README.md'; + duplicateArchiveZip.writeZip(archivePath); + writeManifestForZip(duplicateArchiveZip, { entries: zipEntryRecords(validZip) }); + const duplicateArchiveRun = runNode( + [ + verifyScript, + '--surface', + 'vscode', + '--stage', + 'install', + '--out', + path.join(fixtureDir, 'vscode-install-duplicate-archive-entry-result.json') + ], + 'verify-surface rejects duplicate archive entries', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(duplicateArchiveRun.status, 0, 'expected install verification to reject duplicate archive entries'); + const duplicateArchivePayload = JSON.parse(duplicateArchiveRun.stdout || '{}'); + assert.match( + duplicateArchivePayload.error || '', + /archive contains duplicate entry/, + 'expected archive validation to reject duplicate archive paths' + ); +} finally { + restoreFile(archivePath, archiveBackupPath, archiveExisted); + restoreFile(manifestPath, manifestBackupPath, manifestExisted); +} + +console.log('release verify-surface archive install test passed'); diff --git a/tests/tooling/release/verify-surface-archives.test.js b/tests/tooling/release/verify-surface-archives.test.js new file mode 100644 index 000000000..442757e97 --- /dev/null +++ b/tests/tooling/release/verify-surface-archives.test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; +import { ensureTestingEnv } from '../../helpers/test-env.js'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const verifyScript = path.join(root, 'tools', 'release', 'verify-surface.js'); +const vscodePackageScript = path.join(root, 'tools', 'package-vscode.js'); +const sublimePackageScript = path.join(root, 'tools', 'package-sublime.js'); +const { dir: outDir } = await prepareTestCacheDir('release-verify-archives'); + +runNode([vscodePackageScript, '--smoke'], 'package-vscode smoke', root, process.env, { + stdio: 'pipe', + encoding: 'utf8', + timeoutMs: 30000 +}); +const vscodeOut = path.join(outDir, 'vscode-install.json'); +const vscodeVerify = runNode( + [verifyScript, '--surface', 'vscode', '--stage', 'install', '--out', vscodeOut], + 'verify-surface vscode:install', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', timeoutMs: 30000 } +); +const vscodePayload = JSON.parse(vscodeVerify.stdout || '{}'); +assert.equal(vscodePayload.ok, true, 'expected vscode install verification to succeed'); +assert.equal(fs.existsSync(vscodeOut), true, 'expected vscode verification artifact'); + +runNode([sublimePackageScript, '--smoke'], 'package-sublime smoke', root, process.env, { + stdio: 'pipe', + encoding: 'utf8', + timeoutMs: 30000 +}); +const sublimeOut = path.join(outDir, 'sublime-install.json'); +const sublimeVerify = runNode( + [verifyScript, '--surface', 'sublime', '--stage', 'install', '--out', sublimeOut], + 'verify-surface sublime:install', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', timeoutMs: 30000 } +); +const sublimePayload = JSON.parse(sublimeVerify.stdout || '{}'); +assert.equal(sublimePayload.ok, true, 'expected sublime install verification to succeed'); +assert.equal(fs.existsSync(sublimeOut), true, 'expected sublime verification artifact'); + +console.log('release verify-surface archive test passed'); diff --git a/tests/tooling/release/verify-surface-mcp-boot-runtime.test.js b/tests/tooling/release/verify-surface-mcp-boot-runtime.test.js new file mode 100644 index 000000000..bc1157a75 --- /dev/null +++ b/tests/tooling/release/verify-surface-mcp-boot-runtime.test.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runVerifySurface } from '../../helpers/release-verify-surface.js'; + +const mcpBoot = await runVerifySurface('mcp', 'boot'); +assert.equal(mcpBoot.checks?.initialize, true, 'expected mcp boot to verify initialize'); + +console.log('release verify-surface mcp boot runtime test passed'); diff --git a/tests/tooling/release/verify-surface-mcp-smoke-runtime.test.js b/tests/tooling/release/verify-surface-mcp-smoke-runtime.test.js new file mode 100644 index 000000000..8b04ff0e5 --- /dev/null +++ b/tests/tooling/release/verify-surface-mcp-smoke-runtime.test.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runVerifySurface } from '../../helpers/release-verify-surface.js'; + +const mcpSmoke = await runVerifySurface('mcp', 'smoke'); +assert.equal(mcpSmoke.checks?.toolsList, true, 'expected mcp smoke to verify tools/list'); +assert.equal(mcpSmoke.checks?.indexStatus, true, 'expected mcp smoke to verify index_status'); +assert.equal(mcpSmoke.checks?.search, true, 'expected mcp smoke to verify search'); + +console.log('release verify-surface mcp smoke runtime test passed'); diff --git a/tests/tooling/release/verify-surface-output-paths.test.js b/tests/tooling/release/verify-surface-output-paths.test.js new file mode 100644 index 000000000..8fccf8e96 --- /dev/null +++ b/tests/tooling/release/verify-surface-output-paths.test.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { prepareTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const verifyScript = path.join(root, 'tools', 'release', 'verify-surface.js'); +const { dir: fixtureDir } = await prepareTestCacheDir('release-verify-output-paths'); +const outsideOutPath = path.resolve(root, '..', `outside-release-verify-${process.pid}.json`); +const outsideInstallRoot = path.resolve(root, '..', `outside-release-tui-install-${process.pid}`); +const outsideCaptureDir = path.resolve(root, '..', `outside-release-tui-capture-${process.pid}`); + +const outsideOutRun = runNode( + [verifyScript, '--surface', 'api', '--stage', 'boot', '--out', outsideOutPath], + 'verify-surface outside output path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideOutRun.status, 0, 'expected outside verify-surface output path to fail'); +assert.match( + outsideOutRun.stderr, + /out path must stay within repo root/, + 'expected outside verify output failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideOutPath), false, 'expected verify-surface not to create outside output'); + +const outsideSymlinkTarget = path.resolve(root, '..', `outside-release-verify-symlink-${process.pid}`); +const symlinkOutDir = path.join(fixtureDir, 'symlink-out-dir'); +try { + fs.mkdirSync(outsideSymlinkTarget, { recursive: true }); + fs.rmSync(symlinkOutDir, { recursive: true, force: true }); + fs.symlinkSync(outsideSymlinkTarget, symlinkOutDir, 'junction'); + const symlinkOutRun = runNode( + [ + verifyScript, + '--surface', + 'api', + '--stage', + 'boot', + '--out', + path.join(symlinkOutDir, 'verify-result.json') + ], + 'verify-surface symlink output path', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + + assert.notEqual(symlinkOutRun.status, 0, 'expected symlinked verify-surface output path to fail'); + assert.match( + symlinkOutRun.stderr, + /out path must not use symlink path segment/, + 'expected verify-surface to reject symlink path segments before writing output' + ); + assert.equal( + fs.existsSync(path.join(outsideSymlinkTarget, 'verify-result.json')), + false, + 'expected verify-surface not to write through a symlinked output directory' + ); +} catch (error) { + if (error?.code !== 'EPERM' && error?.code !== 'EACCES') { + throw error; + } + console.warn(`verify-surface symlink output assertion skipped: ${error.code}`); +} finally { + fs.rmSync(symlinkOutDir, { recursive: true, force: true }); + fs.rmSync(outsideSymlinkTarget, { recursive: true, force: true }); +} + +const outsideInstallRun = runNode( + [ + verifyScript, + '--surface', + 'tui', + '--stage', + 'boot', + '--out', + path.join(fixtureDir, 'outside-install-result.json'), + '--install-root', + outsideInstallRoot + ], + 'verify-surface outside tui install root', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideInstallRun.status, 0, 'expected outside TUI install root to fail'); +assert.match( + JSON.parse(outsideInstallRun.stdout || '{}').error || '', + /install-root must stay within repo root/, + 'expected outside TUI install root failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideInstallRoot), false, 'expected verify-surface not to create outside install root'); + +const outsideCaptureRun = runNode( + [ + verifyScript, + '--surface', + 'tui', + '--stage', + 'boot', + '--out', + path.join(fixtureDir, 'outside-capture-result.json'), + '--capture-out-dir', + outsideCaptureDir + ], + 'verify-surface outside tui capture directory', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(outsideCaptureRun.status, 0, 'expected outside TUI capture directory to fail'); +assert.match( + JSON.parse(outsideCaptureRun.stdout || '{}').error || '', + /capture-out-dir must stay within repo root/, + 'expected outside TUI capture directory failure to explain repo-root boundary' +); +assert.equal(fs.existsSync(outsideCaptureDir), false, 'expected verify-surface not to create outside capture dir'); + +console.log('release verify-surface output paths test passed'); diff --git a/tests/tooling/release/workflow-run-selection.test.js b/tests/tooling/release/workflow-run-selection.test.js new file mode 100644 index 000000000..7dbb740e3 --- /dev/null +++ b/tests/tooling/release/workflow-run-selection.test.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { selectLatestWorkflowRunGateState } from '../../../tools/release/workflow-run-selection.js'; + +const successFromLatest = selectLatestWorkflowRunGateState([ + { + databaseId: 100, + status: 'completed', + conclusion: 'success', + createdAt: '2026-03-25T10:00:00Z', + updatedAt: '2026-03-25T10:05:00Z' + }, + { + databaseId: 101, + status: 'completed', + conclusion: 'failure', + createdAt: '2026-03-25T11:00:00Z', + updatedAt: '2026-03-25T11:05:00Z' + } +]); +assert.equal(successFromLatest.kind, 'failed', 'expected latest failed run to beat older success'); +assert.equal(successFromLatest.text, 'failed:101:failure'); + +const pendingFromLatest = selectLatestWorkflowRunGateState([ + { + databaseId: 200, + status: 'completed', + conclusion: 'success', + createdAt: '2026-03-25T10:00:00Z', + updatedAt: '2026-03-25T10:05:00Z' + }, + { + databaseId: 201, + status: 'in_progress', + conclusion: null, + createdAt: '2026-03-25T11:00:00Z', + updatedAt: '2026-03-25T11:01:00Z' + } +]); +assert.equal(pendingFromLatest.kind, 'pending', 'expected latest active run to block older success'); +assert.equal(pendingFromLatest.text, 'pending:201:in_progress'); + +const successWhenLatestIsSuccess = selectLatestWorkflowRunGateState([ + { + databaseId: 300, + status: 'completed', + conclusion: 'failure', + createdAt: '2026-03-25T10:00:00Z', + updatedAt: '2026-03-25T10:05:00Z' + }, + { + databaseId: 301, + status: 'completed', + conclusion: 'success', + createdAt: '2026-03-25T11:00:00Z', + updatedAt: '2026-03-25T11:06:00Z' + } +]); +assert.equal(successWhenLatestIsSuccess.kind, 'success'); +assert.equal(successWhenLatestIsSuccess.text, 'success:301'); + +const rerunOrdering = selectLatestWorkflowRunGateState([ + { + databaseId: 400, + status: 'completed', + conclusion: 'success', + createdAt: '2026-03-25T10:00:00Z', + updatedAt: '2026-03-25T10:05:00Z' + }, + { + databaseId: 400, + status: 'completed', + conclusion: 'failure', + createdAt: '2026-03-25T10:00:00Z', + updatedAt: '2026-03-25T10:07:00Z' + } +]); +assert.equal(rerunOrdering.kind, 'failed', 'expected later rerun update time to win'); +assert.equal(rerunOrdering.text, 'failed:400:failure'); + +const missing = selectLatestWorkflowRunGateState([]); +assert.equal(missing.kind, 'missing'); +assert.equal(missing.text, 'missing'); + +console.log('workflow run selection test passed'); diff --git a/tests/tooling/repo/config-status-durability-warning.test.js b/tests/tooling/repo/config-status-durability-warning.test.js new file mode 100644 index 000000000..74c9d1e6c --- /dev/null +++ b/tests/tooling/repo/config-status-durability-warning.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { configStatus } from '../../../tools/mcp/repo.js'; +import { + atomicWriteText, + resetAtomicWriteRuntimeMetricsForTests +} from '../../../src/shared/io/atomic-write.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'config-status-durability-warning'); +const repoRoot = path.join(tempRoot, 'repo'); +const cacheRoot = path.join(tempRoot, 'cache'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +applyTestEnv({ cacheRoot }); + +resetAtomicWriteRuntimeMetricsForTests(); +const exdevPath = path.join(cacheRoot, 'durability.txt'); +const originalRename = fsPromises.rename; +fsPromises.rename = async (...args) => { + const [, targetPath] = args; + if (String(targetPath || '').includes('durability.txt')) { + const err = new Error('cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + } + return originalRename(...args); +}; +try { + await atomicWriteText(exdevPath, 'degraded'); +} finally { + fsPromises.rename = originalRename; +} + +const originalStatSync = fs.statSync; +fs.statSync = (candidatePath, ...rest) => { + const result = originalStatSync(candidatePath, ...rest); + const resolved = path.resolve(String(candidatePath || '')); + if (resolved.startsWith(path.resolve(repoRoot))) { + Object.defineProperty(result, 'dev', { value: 101, configurable: true }); + return result; + } + if (resolved.startsWith(path.resolve(cacheRoot))) { + Object.defineProperty(result, 'dev', { value: 202, configurable: true }); + return result; + } + return result; +}; + +let payload = null; +try { + payload = await configStatus({ repoPath: repoRoot }); +} finally { + fs.statSync = originalStatSync; + await fsPromises.rm(tempRoot, { recursive: true, force: true }); +} + +const warningCodes = new Set((payload?.warnings || []).map((entry) => entry?.code)); +assert.equal(warningCodes.has('atomic_write_exdev_fallback'), true, 'expected EXDEV fallback warning'); +assert.equal(warningCodes.has('atomic_write_layout_risk'), true, 'expected layout risk warning'); +assert.equal(payload?.durability?.runtime?.degradedDurability, true, 'expected degraded durability runtime flag'); +assert.equal(payload?.durability?.layout?.crossDeviceRisk, true, 'expected cross-device layout risk'); +assert.equal(payload?.durability?.layout?.reason, 'device_mismatch', 'expected device mismatch reason'); + +console.log('config status durability warning test passed'); diff --git a/tests/tooling/reports/bench-language-diagnostics-parity-report.test.js b/tests/tooling/reports/bench-language-diagnostics-parity-report.test.js new file mode 100644 index 000000000..3ce566f56 --- /dev/null +++ b/tests/tooling/reports/bench-language-diagnostics-parity-report.test.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-diagnostics-parity-report'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const diagnosticsPath = path.join(logsRoot, 'run-ub050-all.diagnostics.jsonl'); +const logPath = path.join(logsRoot, 'run-ub050-all.log'); +const now = new Date().toISOString(); + +await fsPromises.writeFile( + diagnosticsPath, + [ + { + eventType: 'fallback_used', + eventId: 'ub050:v1:fallback_used:aaaaaaaaaaaa', + message: 'using fallback parser' + }, + { + eventType: 'provider_preflight_start', + eventId: 'ub050:v1:provider_preflight_start:bbbbbbbbbbbb', + message: '[tooling] preflight:start provider=gopls id=gopls.workspace-model class=workspace timeoutMs=20000', + providerId: 'gopls', + preflightId: 'gopls.workspace-model', + preflightClass: 'workspace', + preflightState: 'running', + failureClass: 'start' + }, + { + eventType: 'provider_preflight_finish', + eventId: 'ub050:v1:provider_preflight_finish:cccccccccccc', + message: '[tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked', + providerId: 'gopls', + preflightId: 'gopls.workspace-model', + preflightClass: 'workspace', + preflightState: 'blocked', + failureClass: 'blocked' + }, + { + eventType: 'provider_preflight_blocked', + eventId: 'ub050:v1:provider_preflight_blocked:dddddddddddd', + message: '[tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked', + providerId: 'gopls', + preflightId: 'gopls.workspace-model', + preflightClass: 'workspace', + preflightState: 'blocked', + failureClass: 'blocked' + }, + { + eventType: 'provider_request_timeout', + eventId: 'ub050:v1:provider_request_timeout:eeeeeeeeeeee', + message: '[tooling] request:timeout provider=pyright method=textDocument/documentSymbol stage=documentSymbol workspacePartition=. class=timeout', + providerId: 'pyright', + requestMethod: 'textDocument/documentSymbol', + workspacePartition: '.', + failureClass: 'timeout' + }, + { + eventType: 'provider_request_failed', + eventId: 'ub050:v1:provider_request_failed:ffffffffffff', + message: '[tooling] request:failed provider=sourcekit method=textDocument/semanticTokens/full stage=semantic_tokens workspacePartition=swift-package class=request_failed', + providerId: 'sourcekit', + requestMethod: 'textDocument/semanticTokens/full', + workspacePartition: 'swift-package', + failureClass: 'request_failed' + }, + { + eventType: 'provider_circuit_breaker', + eventId: 'ub050:v1:provider_circuit_breaker:111111111111', + message: '[tooling] pyright circuit breaker tripped.', + providerId: 'pyright', + failureClass: 'circuit_breaker' + }, + { + eventType: 'provider_degraded_mode_entered', + eventId: 'ub050:v1:provider_degraded_mode_entered:121212121212', + message: '[tooling] pyright degraded mode active (fail-open).', + providerId: 'pyright', + failureClass: 'fail_open' + }, + { + eventType: 'workspace_partition_decision', + eventId: 'ub050:v1:workspace_partition_decision:131313131313', + message: '[tooling] workspace:partition provider=gopls state=degraded reason=gopls_workspace_partition_incomplete workspacePartition=multiple partitionCount=2 unmatchedDocuments=1 unmatchedTargets=1', + providerId: 'gopls', + workspacePartition: 'multiple', + failureClass: 'gopls_workspace_partition_incomplete' + }, + { + eventType: 'warning_suppressed', + eventId: 'ub050:v1:warning_suppressed:141414141414', + message: '[tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via compile_commands.json.', + providerId: 'clangd', + failureClass: 'stderr:includecleaner', + severity: 'warn' + } + ].map((entry) => JSON.stringify({ + schemaVersion: 2, + ts: now, + occurrence: 1, + signature: entry.eventType, + source: 'stdout', + ...entry + })).join('\n') + '\n', + 'utf8' +); + +await fsPromises.writeFile( + logPath, + [ + '[tooling] preflight:start provider=gopls id=gopls.workspace-model class=workspace timeoutMs=20000', + '[tooling] preflight:blocked provider=gopls id=gopls.workspace-model durationMs=87 state=blocked', + '[tooling] request:timeout provider=pyright method=textDocument/documentSymbol stage=documentSymbol workspacePartition=. class=timeout', + '[tooling] request:failed provider=sourcekit method=textDocument/semanticTokens/full stage=semantic_tokens workspacePartition=swift-package class=request_failed', + '[tooling] pyright circuit breaker tripped.', + '[tooling] pyright degraded mode active (fail-open).', + '[tooling] workspace:partition provider=gopls state=degraded reason=gopls_workspace_partition_incomplete workspacePartition=multiple partitionCount=2 unmatchedDocuments=1 unmatchedTargets=1', + '[tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via compile_commands.json.', + 'using fallback parser' + ].join('\n') + '\n', + 'utf8' +); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: tempRoot, + results: [], + config: {} +}); + +const parity = output?.diagnostics?.parity; +assert.ok(parity && typeof parity === 'object', 'expected diagnostics parity summary'); +assert.equal(parity.status, 'ok', 'expected parity summary to agree when logs and stream match'); +assert.equal(parity.mismatchCount, 0, 'expected zero diagnostics parity mismatches'); +assert.equal(parity.countScopes?.countsFromLogs, 'event_presence', 'expected event-scope parity labeling'); +assert.equal(parity.countScopes?.repoCountsFromLogs, 'repo_presence', 'expected repo-scope parity labeling'); +assert.equal(parity.countsFromLogs.provider_preflight_blocked, 1, 'expected blocked preflight parity count'); +assert.equal(parity.countsFromLogs.provider_request_timeout, 1, 'expected request timeout parity count'); +assert.equal(parity.countsFromLogs.provider_degraded_mode_entered, 1, 'expected degraded mode parity count'); +assert.equal(parity.countsFromLogs.warning_suppressed, 1, 'expected warning suppression parity count'); +assert.equal(parity.countsFromDiagnosticsStream.provider_request_failed, 1, 'expected request failed stream parity count'); +assert.equal(parity.countsFromDiagnosticsStream.warning_suppressed, 1, 'expected warning suppression stream parity count'); +assert.equal(parity.repoCountsFromLogs.provider_preflight_blocked, 1, 'expected repo-scoped blocked preflight parity count'); +assert.equal(parity.repoCountsFromDiagnosticsStream.provider_preflight_blocked, 1, 'expected repo-scoped blocked preflight stream parity count'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language diagnostics parity report test passed'); diff --git a/tests/tooling/reports/bench-language-diagnostics-summary-report.test.js b/tests/tooling/reports/bench-language-diagnostics-summary-report.test.js index ab90dc7d4..c776fcdb2 100644 --- a/tests/tooling/reports/bench-language-diagnostics-summary-report.test.js +++ b/tests/tooling/reports/bench-language-diagnostics-summary-report.test.js @@ -18,6 +18,8 @@ await fsPromises.mkdir(logsRoot, { recursive: true }); const streamA = path.join(logsRoot, 'run-ub050-all.diagnostics.jsonl'); const streamB = path.join(logsRoot, 'run-ub050-owner-repo.diagnostics.jsonl'); +const logA = path.join(logsRoot, 'run-ub050-all.log'); +const logB = path.join(logsRoot, 'run-ub050-owner-repo.log'); const now = new Date().toISOString(); await fsPromises.writeFile( @@ -90,11 +92,41 @@ await fsPromises.writeFile( signature: 'artifact-tail-stall', source: 'stdout', message: 'artifact tail stalled while writing shard' + }), + JSON.stringify({ + schemaVersion: 1, + ts: now, + eventType: 'warning_suppressed', + eventId: 'ub050:v1:warning_suppressed:ffffffffffff', + occurrence: 1, + signature: 'warning-suppressed', + source: 'stdout', + severity: 'warn', + providerId: 'clangd', + failureClass: 'stderr:includecleaner', + message: '[tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via compile_commands.json.' }) ].join('\n') + '\n', 'utf8' ); +await fsPromises.writeFile( + logA, + [ + '[diagnostics] parser_crash ub050:v1:parser_crash:aaaaaaaaaaaa tree-sitter parser crash' + ].join('\n') + '\n', + 'utf8' +); + +await fsPromises.writeFile( + logB, + [ + 'using fallback parser', + '[tooling] clangd suppressed 2 IncludeCleaner stderr line(s); missing include roots should be configured via compile_commands.json.' + ].join('\n') + '\n', + 'utf8' +); + const output = await buildReportOutput({ configPath: '/tmp/repos.json', cacheRoot: '/tmp/cache', @@ -106,33 +138,60 @@ const output = await buildReportOutput({ const stream = output?.diagnostics?.stream; assert.ok(stream && typeof stream === 'object', 'expected diagnostics stream summary'); assert.equal(stream.schemaVersion, BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, 'expected diagnostics stream schema'); -assert.equal(stream.fileCount, 2, 'expected two diagnostics stream files'); -assert.equal(stream.eventCount, 6, 'expected all valid stream events to be counted'); -assert.equal(stream.uniqueEventCount, 5, 'expected dedupe across stable event IDs'); -assert.equal(stream.malformedLines, 1, 'expected malformed line accounting'); +assert.equal(stream.fileCount, 2, 'expected both diagnostics streams to be scanned'); +assert.equal(stream.eventCount, 6, 'expected deduped event count across master and repo streams'); +assert.equal(stream.rawEventCount, 7, 'expected raw event count to include duplicate fallback event'); +assert.equal(stream.duplicateEventCount, 1, 'expected one duplicate event across the merged streams'); +assert.equal(stream.uniqueEventCount, 6, 'expected unique event count across both streams'); +assert.equal(stream.malformedLines, 1, 'expected malformed master stream line to be tracked'); +assert.equal(stream.countScopes?.countsByType, 'event_presence', 'expected explicit event scope labeling'); +assert.equal(stream.countScopes?.repoCountsByType, 'repo_presence', 'expected explicit repo scope labeling'); -assert.equal(stream.countsByType.parser_crash, 1, 'expected parser_crash count'); +assert.equal(stream.countsByType.parser_crash || 0, 1, 'expected master-only parser_crash to be preserved'); assert.equal(stream.countsByType.scm_timeout, 1, 'expected scm_timeout count'); assert.equal(stream.countsByType.queue_delay_hotspot, 1, 'expected queue_delay_hotspot count'); assert.equal(stream.countsByType.artifact_tail_stall, 1, 'expected artifact_tail_stall count'); -assert.equal(stream.countsByType.fallback_used, 2, 'expected fallback_used duplicate count'); +assert.equal(stream.countsByType.warning_suppressed, 1, 'expected warning_suppressed count'); +assert.equal(stream.countsByType.fallback_used || 0, 1, 'expected fallback duplicates to be deduped, not dropped'); +assert.equal(stream.repoCountsByType.parser_crash || 0, 1, 'expected parser crash repo count'); +assert.equal(stream.repoCountsByType.fallback_used || 0, 1, 'expected fallback repo count'); +assert.equal(stream.repoCountsByType.warning_suppressed || 0, 1, 'expected warning suppression repo count'); assert.equal(stream.unknownTypeCount, 0, 'expected no unknown event types'); +assert.deepEqual( + stream.countsBySeverity, + { error: 1, info: 0, warn: 5 }, + 'expected consequence-based severity counts across merged diagnostics streams' +); -assert.equal(stream.required.parser_crash, 1, 'expected required parser_crash coverage'); +assert.equal(stream.required.parser_crash, 1, 'expected parser_crash from master stream to be counted'); assert.equal(stream.required.scm_timeout, 1, 'expected required scm_timeout coverage'); assert.equal(stream.required.queue_delay_hotspot, 1, 'expected required queue_delay_hotspot coverage'); assert.equal(stream.required.artifact_tail_stall, 1, 'expected required artifact_tail_stall coverage'); -assert.equal(stream.required.fallback_used, 2, 'expected required fallback_used coverage'); +assert.equal(stream.required.warning_suppressed, 1, 'expected warning_suppressed coverage'); +assert.equal(stream.required.fallback_used, 1, 'expected duplicated fallback events to be deduped'); + +const parity = output?.diagnostics?.parity; +assert.ok(parity && typeof parity === 'object', 'expected diagnostics parity summary'); +assert.equal(parity.status, 'ok', 'expected diagnostics parity to agree with aggregate logs'); +assert.equal(parity.materialMismatchCount, 0, 'expected no material diagnostics parity mismatches'); +assert.equal(parity.countScopes?.countsFromLogs, 'event_presence', 'expected parity event scope label'); +assert.equal(parity.countScopes?.repoCountsFromLogs, 'repo_presence', 'expected parity repo scope label'); +assert.equal(parity.countsFromLogs.fallback_used, 1, 'expected fallback parity count from aggregate logs'); +assert.equal(parity.countsFromDiagnosticsStream.fallback_used, 1, 'expected fallback parity count from stream'); +assert.equal(parity.countsFromLogs.warning_suppressed, 1, 'expected warning suppression parity count from aggregate logs'); +assert.equal(parity.countsFromDiagnosticsStream.warning_suppressed, 1, 'expected warning suppression parity count from stream'); +assert.equal(parity.repoCountsFromLogs.fallback_used, 1, 'expected repo-scoped fallback parity count from aggregate logs'); +assert.equal(parity.repoCountsFromDiagnosticsStream.fallback_used, 1, 'expected repo-scoped fallback parity count from stream'); assert.equal( - stream.files.some((entry) => entry.path === streamA && entry.eventCount === 3), + stream.files.some((entry) => entry.path === streamB && entry.eventCount === 4), true, - 'expected streamA file summary' + 'expected canonical repo stream summary' ); assert.equal( - stream.files.some((entry) => entry.path === streamB && entry.eventCount === 3), + stream.files.some((entry) => entry.path === streamA && entry.eventCount === 3), true, - 'expected streamB file summary' + 'expected master diagnostics stream summary' ); await fsPromises.rm(tempRoot, { recursive: true, force: true }); diff --git a/tests/tooling/reports/bench-language-preflight-summary-report.test.js b/tests/tooling/reports/bench-language-preflight-summary-report.test.js new file mode 100644 index 000000000..708a23ae0 --- /dev/null +++ b/tests/tooling/reports/bench-language-preflight-summary-report.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-preflight-summary-report'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +await fsPromises.writeFile( + path.join(logsRoot, 'run-preflight-a.log'), + [ + '[tooling] preflight:start provider=sourcekit id=sourcekit.package-resolution class=dependency timeoutMs=90000', + '[tooling] preflight:ok provider=sourcekit id=sourcekit.package-resolution durationMs=1234 state=ready', + '[tooling] preflight:queued provider=gopls id=gopls.workspace-model class=workspace depth=1 running=1 cap=1', + '[tooling] preflight:timeout provider=gopls id=gopls.workspace-model durationMs=60000 state=degraded timeout=1', + '[tooling] preflight:teardown_force_cleanup ownershipIds=alpha,beta forced=2', + '[tooling] preflight summary total=5 cached=0 timedOut=1 failed=0 queuePeak=2 teardownTimedOut=0 states=ready:1,degraded:1 classes=dependency:1,workspace:1 policies=required:1,optional:1', + '[tooling] preflight slowest gopls/gopls.workspace-model:60000ms, sourcekit/sourcekit.package-resolution:1234ms' + ].join('\n') + '\n', + 'utf8' +); + +await fsPromises.writeFile( + path.join(logsRoot, 'run-preflight-b.log'), + [ + '[tooling] preflight:failed provider=rust-analyzer id=rust-analyzer.workspace-model durationMs=912 error=forced', + '[tooling] preflight:cache_hit provider=sourcekit id=sourcekit.package-resolution state=ready', + '[tooling] preflight:teardown_failed error=forced', + '[tooling] preflight:teardown_timeout active=2 timeoutMs=30000' + ].join('\n') + '\n', + 'utf8' +); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: tempRoot, + results: [], + config: {} +}); + +const preflight = output?.diagnostics?.preflight; +assert.ok(preflight && typeof preflight === 'object', 'expected preflight diagnostics summary'); +assert.equal(preflight.fileCount, 2, 'expected two bench log files'); +assert.equal(preflight.eventCount, 9, 'expected parsed preflight event count'); +assert.equal(preflight.timeoutEvents, 2, 'expected one provider timeout and one teardown timeout event'); +assert.equal(preflight.countsByEvent.start, 1, 'expected start count'); +assert.equal(preflight.countsByEvent.ok, 1, 'expected ok count'); +assert.equal(preflight.countsByEvent.failed, 1, 'expected failed count'); +assert.equal(preflight.countsByEvent.cache_hit, 1, 'expected cache-hit count'); +assert.equal(preflight.countsByEvent.teardown_force_cleanup, 1, 'expected teardown force-cleanup count'); +assert.equal(preflight.countsByEvent.teardown_failed, 1, 'expected teardown failed count'); +assert.equal(preflight.countsByEvent.teardown_timeout, 1, 'expected teardown timeout count'); +assert.equal(preflight.countsByClass.dependency, 1, 'expected dependency class count'); +assert.equal(preflight.countsByClass.workspace, 1, 'expected workspace class count'); +assert.equal(preflight.countsByClass.unknown, 7, 'expected unknown class count when class is not logged'); +assert.equal(preflight.countsByState.ready, 2, 'expected ready state count'); +assert.equal(preflight.countsByState.degraded, 1, 'expected degraded state count'); +assert.equal(preflight.countsByState.failed, 1, 'expected failed state count'); +assert.equal(preflight.topSlow[0]?.durationMs, 60000, 'expected top slow preflight event by duration'); +assert.equal(preflight.summary?.lineCount, 1, 'expected one preflight summary line'); +assert.equal(preflight.summary?.maxQueuePeak, 2, 'expected max queue peak from summary line'); +assert.equal(preflight.summary?.teardownTimedOutCount, 0, 'expected no teardown timeout summaries'); +assert.equal(preflight.summary?.countsByClass?.dependency, 1, 'expected summary class aggregation'); +assert.equal(preflight.summary?.countsByState?.degraded, 1, 'expected summary state aggregation'); +assert.equal(preflight.summary?.countsByPolicy?.required, 1, 'expected summary policy aggregation'); +assert.equal(preflight.summary?.countsByPolicy?.optional, 1, 'expected summary policy aggregation'); +assert.equal(preflight.summary?.topSlow?.[0]?.durationMs, 60000, 'expected parsed summary slowest duration'); +assert.equal(preflight.summary?.topSlow?.[0]?.providerId, 'gopls', 'expected parsed summary slowest provider'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language preflight summary report test passed'); diff --git a/tests/tooling/reports/bench-language-quality-budget-report.test.js b/tests/tooling/reports/bench-language-quality-budget-report.test.js new file mode 100644 index 000000000..9748fe06d --- /dev/null +++ b/tests/tooling/reports/bench-language-quality-budget-report.test.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { + buildBenchRunDiagnosticsSummaryLines, + buildReportOutput +} from '../../../tools/bench/language/report.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-quality-budget-report'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const outFile = path.join(tempRoot, 'fixture.json'); +await fs.writeFile( + outFile, + JSON.stringify({ + artifacts: { + scanProfile: { + modes: { + 'extracted-prose': { + quality: { + lowYieldBailout: { + enabled: true, + triggered: true, + reason: 'low_yield', + qualityImpact: 'reduced-extracted-prose-recall', + repoYieldClass: 'sparse-high-value', + seed: 'bench-quality-budget', + warmupWindowSize: 8, + warmupSampleSize: 4, + sampledFiles: 4, + sampledYieldedFiles: 0, + sampledChunkCount: 0, + observedYieldRatio: 0, + minYieldRatio: 0.25, + minYieldedFiles: 1, + suppressedCohortCount: 1, + protectedCohortCount: 1, + strategyMismatchRiskCount: 0, + estimatedSuppressedFiles: 3, + estimatedRecallLossRatio: 0.375, + estimatedRecallLossClass: 'high', + estimatedRecallLossConfidence: 'medium', + opportunityCost: { + class: 'limited', + estimatedSuppressedFiles: 3, + estimatedRecallLossRatio: 0.375, + estimatedRecallLossClass: 'high', + estimatedRecallLossConfidence: 'medium', + skippedFiles: 6, + estimatedAvoidedChunkSamples: 0, + suppressedCohortCount: 1, + protectedCohortCount: 1, + protectedHighValueCohortCount: 1, + strategyMismatchRiskCount: 0 + }, + recallCost: { + class: 'high', + qualityImpact: 'reduced-extracted-prose-recall', + downgradedRecall: true, + estimatedSuppressedFiles: 3, + estimatedRecallLossRatio: 0.375, + estimatedRecallLossClass: 'high', + estimatedRecallLossConfidence: 'medium', + skippedFiles: 0, + estimatedAvoidedChunkSamples: 0, + suppressedCohortCount: 1, + protectedCohortCount: 1, + protectedHighValueCohortCount: 1, + strategyMismatchRiskCount: 0 + }, + skippedFiles: 6, + decisionAtOrderIndex: 4, + decisionAt: '2026-03-22T00:00:01.000Z', + repoFingerprint: { + totalEntries: 8, + docLikeEntries: 2, + dominantCohort: 'generated-machine', + cohortCounts: { + 'docs-markdown': 2, + 'tests-examples': 0, + 'templates-config': 0, + 'generated-machine': 6, + 'code-comment-heavy': 0 + } + }, + suppressedCohorts: [{ + key: 'generated-machine', + suppressionClass: 'genuine-low-yield', + expectedYieldClass: 'expected-low', + warmupFiles: 3, + sampledFiles: 3, + sampledObservedFiles: 3, + sampledYieldedFiles: 0, + sampledChunkCount: 0, + repoFiles: 6, + estimatedSuppressedFiles: 3, + estimatedRecallLossRatio: 0.375 + }], + protectedCohorts: [{ + key: 'docs-markdown', + expectedYieldClass: 'expected-high', + strategyMismatchRisk: false, + protectedBySample: false, + protectedByHistory: true, + protectedByPriority: true + }], + strategyMismatchRiskCohorts: [], + deterministic: true, + downgradedRecall: true + } + } + } + } + } + } + }, null, 2), + 'utf8' +); + +const output = await buildReportOutput({ + configPath: path.join(tempRoot, 'repos.json'), + cacheRoot: path.join(tempRoot, 'cache'), + resultsRoot: tempRoot, + results: [{ + language: 'javascript', + tier: 'small', + repo: 'org/example', + repoPath: 'C:/repo/example', + outFile + }], + config: { + javascript: { label: 'JavaScript' } + } +}); + +const qualityBudget = output?.diagnostics?.qualityBudget; +assert.ok(qualityBudget && typeof qualityBudget === 'object', 'expected quality budget summary'); +assert.equal(qualityBudget.observedTaskCount, 1, 'expected one quality-budget-aware task'); +assert.equal(qualityBudget.reducedRecallCount, 1, 'expected reduced recall to be tracked'); +assert.equal(qualityBudget.skippedFiles, 6, 'expected skipped-file accounting'); +assert.equal(qualityBudget.estimatedSuppressedFiles, 3, 'expected suppressed-file estimate'); +assert.equal(qualityBudget.countsByRepoYieldClass['sparse-high-value'], 1, 'expected repo-yield classification'); +assert.equal(qualityBudget.countsByOpportunityClass.limited, 1, 'expected opportunity-cost classification'); +assert.equal(qualityBudget.countsByRecallClass.high, 1, 'expected recall-cost classification'); + +const lines = buildBenchRunDiagnosticsSummaryLines(output); +assert.equal( + lines.some((line) => line === '[diagnostics] extracted-prose quality budget: reduced-recall=1 | skipped-files=6 | suppressed-est=3 | weighted-recall-loss=37.5%'), + true, + 'expected extracted-prose quality budget closeout line' +); +assert.equal( + lines.some((line) => line === '[diagnostics] extracted-prose classes: sparse-high-value=1 | opportunity-limited=1 | recall-high=1'), + true, + 'expected extracted-prose quality budget class line' +); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language quality budget report test passed'); diff --git a/tests/tooling/reports/bench-language-reuse-summary-prefers-scan-profile.test.js b/tests/tooling/reports/bench-language-reuse-summary-prefers-scan-profile.test.js new file mode 100644 index 000000000..124e070d6 --- /dev/null +++ b/tests/tooling/reports/bench-language-reuse-summary-prefers-scan-profile.test.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-reuse-summary-prefers-scan-profile'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +const outFile = path.join(tempRoot, 'task.json'); +await fsPromises.writeFile(outFile, JSON.stringify({ + artifacts: { + scanProfile: { + schemaVersion: 1, + generatedAt: '2026-03-23T00:00:00.000Z', + source: 'report-artifacts', + repo: { root: 'C:/repo', cacheRoot: 'C:/cache' }, + modes: { + code: { reuse: null }, + prose: { reuse: null }, + 'extracted-prose': { reuse: null }, + records: { reuse: null } + }, + totals: { + files: { candidates: 0, scanned: 0, skipped: 0 }, + chunks: 0, + tokens: 0, + lines: null, + bytes: { source: null, artifact: 0 }, + durationMs: null, + filesPerSec: null, + chunksPerSec: null, + tokensPerSec: null, + bytesPerSec: null, + linesPerSec: null + }, + languageLines: {}, + reuse: { + observationCount: 2, + generationAware: true, + generation: { + mode: 'code', + repoRoot: 'C:/repo', + buildRoot: 'C:/cache/builds/run-1/index-code', + buildId: 'run-1' + }, + countsByCause: { cache_miss: 1, cache_invalid: 1 }, + countsBySurface: { 'provider-result': 2 }, + countsBySurfaceAndSource: { 'provider-result:live': 2 }, + countsByQualityImpact: { none: 2 }, + scmSnapshotSources: {}, + providerResultSources: { live: 2 }, + cost: { + timeCostMs: 180, + requestedCount: 0, + reusedCount: 0, + fetchedCount: 0, + chunkCount: 4 + }, + observations: [ + { + kind: 'provider_cache', + providerId: 'pyright', + reuseSurface: 'provider-result', + reuseSource: 'live', + causeClass: 'cache_invalid', + qualityImpact: 'none', + requestedCount: null, + reusedCount: null, + fetchedCount: null, + chunkCount: null, + timeCostMs: null, + generation: { + mode: 'code', + repoRoot: 'C:/repo', + buildRoot: 'C:/cache/builds/run-1/index-code', + buildId: 'run-1' + } + }, + { + kind: 'provider_result', + providerId: 'pyright', + reuseSurface: 'provider-result', + reuseSource: 'live', + causeClass: 'cache_miss', + qualityImpact: 'none', + requestedCount: null, + reusedCount: null, + fetchedCount: null, + chunkCount: 4, + timeCostMs: 180, + generation: { + mode: 'code', + repoRoot: 'C:/repo', + buildRoot: 'C:/cache/builds/run-1/index-code', + buildId: 'run-1' + } + } + ] + } + } + } +}, null, 2), 'utf8'); + +await fsPromises.writeFile( + path.join(logsRoot, 'run-ub060-all.log'), + '[scm] file-meta snapshot: source=cache requested=10 reused=10 fetched=0. elapsedMs=12\n', + 'utf8' +); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: tempRoot, + results: [{ + language: 'javascript', + repo: 'sample', + tier: 'small', + outFile + }], + config: {} +}); + +const reuse = output?.diagnostics?.reuse; +assert.ok(reuse && typeof reuse === 'object', 'expected reuse diagnostics summary'); +assert.equal(reuse.observationCount, 2, 'expected artifact-backed reuse summary to win over log fallback'); +assert.equal(reuse.countsByCause.cache_invalid, 1); +assert.equal(reuse.countsByCause.cache_miss, 1); +assert.equal(reuse.countsBySurface['provider-result'], 2); +assert.equal(reuse.cost.timeCostMs, 180); +assert.equal(reuse.generationAware, true); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language reuse summary prefers scan profile test passed'); diff --git a/tests/tooling/reports/bench-language-reuse-summary-report.test.js b/tests/tooling/reports/bench-language-reuse-summary-report.test.js new file mode 100644 index 000000000..ca14a4c49 --- /dev/null +++ b/tests/tooling/reports/bench-language-reuse-summary-report.test.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { buildReportOutput } from '../../../tools/bench/language/report.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +ensureTestingEnv(process.env); + +const tempRoot = resolveTestCachePath(process.cwd(), 'bench-language-reuse-summary-report'); +const logsRoot = path.join(tempRoot, 'logs', 'bench-language'); +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(logsRoot, { recursive: true }); + +await fsPromises.writeFile( + path.join(logsRoot, 'run-ub050-all.log'), + [ + '[scm] file-meta snapshot: source=cache requested=10 reused=10 fetched=0. elapsedMs=12', + '[scm] file-meta snapshot: source=mixed requested=10 reused=8 fetched=2. elapsedMs=30', + '[scm] file-meta snapshot: source=fresh-fallback requested=10 reused=0 fetched=10. elapsedMs=50', + '[scm] file-meta snapshot: source=mixed-fallback requested=10 reused=5 fetched=5. elapsedMs=70 timeoutCount=2 timeoutRetries=2 cooldownSkips=1 unavailableChunks=1', + '[tooling] provider cache read failed for pyright; using live run.', + '[tooling] provider 1/2 done id=pyright outcome=done source=live chunks=4 elapsedMs=180.', + '[tooling] provider 2/2 done id=gopls outcome=done source=cache chunks=6 elapsedMs=40.' + ].join('\n') + '\n', + 'utf8' +); + +const output = await buildReportOutput({ + configPath: '/tmp/repos.json', + cacheRoot: '/tmp/cache', + resultsRoot: tempRoot, + results: [], + config: {} +}); + +const reuse = output?.diagnostics?.reuse; +assert.ok(reuse && typeof reuse === 'object', 'expected reuse diagnostics summary'); +assert.equal(reuse.observationCount, 7, 'expected reuse observations parsed from aggregate log'); +assert.equal(reuse.countsByCause.cache_hit, 2, 'expected scm and provider cache hits'); +assert.equal(reuse.countsByCause.cache_miss, 1, 'expected provider live execution to be counted as cache miss'); +assert.equal(reuse.countsByCause.scm_state_prevents_reuse, 1, 'expected mixed SCM reuse classification'); +assert.equal(reuse.countsByCause.provider_unavailable, 1, 'expected fresh fallback cause'); +assert.equal(reuse.countsByCause.provider_unhealthy, 1, 'expected mixed fallback with timeout heat to classify as unhealthy'); +assert.equal(reuse.countsByCause.cache_invalid, 1, 'expected cache read failure classification'); +assert.equal(reuse.countsBySurface['scm-derived'], 4, 'expected four SCM snapshot observations'); +assert.equal(reuse.countsBySurface['provider-result'], 3, 'expected three provider-result observations'); +assert.equal(reuse.countsBySurfaceAndSource['scm-derived:cache'], 1); +assert.equal(reuse.countsBySurfaceAndSource['scm-derived:mixed'], 1); +assert.equal(reuse.countsBySurfaceAndSource['scm-derived:fresh-fallback'], 1); +assert.equal(reuse.countsBySurfaceAndSource['scm-derived:mixed-fallback'], 1); +assert.equal(reuse.countsBySurfaceAndSource['provider-result:live'], 2); +assert.equal(reuse.countsBySurfaceAndSource['provider-result:cache'], 1); +assert.equal(reuse.cost.timeCostMs, 382, 'expected SCM and provider elapsed time to roll up'); +assert.equal(reuse.cost.requestedCount, 40, 'expected SCM requested files rolled up'); +assert.equal(reuse.cost.reusedCount, 23, 'expected SCM reused files rolled up'); +assert.equal(reuse.cost.fetchedCount, 17, 'expected SCM fetched files rolled up'); +assert.equal(reuse.cost.chunkCount, 10, 'expected provider chunk totals rolled up'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); + +console.log('bench language reuse summary report test passed'); diff --git a/tests/tooling/reports/bench-language-summarize.test.js b/tests/tooling/reports/bench-language-summarize.test.js index 7776a3a14..664d72bc8 100644 --- a/tests/tooling/reports/bench-language-summarize.test.js +++ b/tests/tooling/reports/bench-language-summarize.test.js @@ -4,10 +4,10 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; -ensureTestingEnv(process.env); +const env = applyTestEnv({ syncProcess: false }); const root = process.cwd(); const scriptPath = path.join(root, 'tools', 'bench', 'language-summarize.js'); @@ -113,8 +113,7 @@ await fsPromises.writeFile( 'utf8' ); -const result = spawnSync( - process.execPath, +const result = runNode( [ scriptPath, '--results', @@ -129,7 +128,10 @@ const result = spawnSync( outMdPath, '--json' ], - { encoding: 'utf8' } + 'bench language summarize', + root, + env, + { stdio: 'pipe' } ); assert.equal(result.status, 0, result.stderr || result.stdout); diff --git a/tests/tooling/reports/bench-language-warning-classification.test.js b/tests/tooling/reports/bench-language-warning-classification.test.js new file mode 100644 index 000000000..8206150e6 --- /dev/null +++ b/tests/tooling/reports/bench-language-warning-classification.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createBenchDiagnosticClassifier } from '../../../tools/bench/language/logging.js'; + +const classifier = createBenchDiagnosticClassifier(); + +const classify = (message) => classifier.classify({ + event: { + level: 'warn', + message + }, + source: 'stdout' +}); + +const rustToolchainNoiseSignals = classify( + '[tooling] rust-analyzer suppressed 2 duplicate workspace stderr line(s); repo-invalidity=0, toolchain-noise=2' +); +assert.deepEqual(rustToolchainNoiseSignals, [], 'expected pure rust toolchain-noise suppression to stay out of bench warning debt'); + +const rustRepoInvaliditySignals = classify( + '[tooling] rust-analyzer suppressed 2 duplicate workspace stderr line(s); repo-invalidity=1, toolchain-noise=1' +); +assert.equal(rustRepoInvaliditySignals.length, 1, 'expected repo-invalidity rust suppression to remain actionable'); +assert.equal(rustRepoInvaliditySignals[0]?.failureClass, 'stderr:duplicate workspace'); + +const nonActionableImportsSignals = classify( + '[imports] suppression: policy=live count=4 degraded=0 visible=0 total=4 actionable=0 omittedFailureCauses=parser_artifact' +); +assert.deepEqual(nonActionableImportsSignals, [], 'expected non-actionable live import suppression to stay out of warning debt'); + +const actionableImportsSignals = classify( + '[imports] suppression: policy=live count=1 degraded=1 visible=2 total=10 actionable=2 omittedFailureCauses=generated_expected_missing,missing_file,parser_artifact' +); +assert.equal(actionableImportsSignals.length, 1, 'expected degraded/actionable import suppression to remain a warning signal'); +assert.equal(actionableImportsSignals[0]?.failureClass, 'imports_live:1'); +assert.equal(actionableImportsSignals[0]?.actionableCount, 2); + +const legacyPolicySignals = classify( + '[imports] all captured unresolved samples were suppressed by live policy (4).' +); +assert.deepEqual(legacyPolicySignals, [], 'expected legacy live-policy summary line to avoid duplicate warning debt'); + +const legacyCountSignals = classify( + '[imports] suppressed 5 import resolution warnings.' +); +assert.deepEqual(legacyCountSignals, [], 'expected legacy warning-count summary line to avoid duplicate warning debt'); + +console.log('bench language warning classification test passed'); diff --git a/tests/tooling/reports/capabilities-report.test.js b/tests/tooling/reports/capabilities-report.test.js index 20b2557e0..2659c7d99 100644 --- a/tests/tooling/reports/capabilities-report.test.js +++ b/tests/tooling/reports/capabilities-report.test.js @@ -1,8 +1,20 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { getCapabilities } from '../../../src/shared/capabilities.js'; +import { describeCommandRegistryEntry } from '../../../src/shared/command-registry-query.js'; +import { getRuntimeCapabilityManifest } from '../../../src/shared/runtime-capability-manifest.js'; const caps = getCapabilities({ refresh: true }); +const manifest = getRuntimeCapabilityManifest({ runtimeCapabilities: caps }); +const repoRoot = process.cwd(); +const workspaceRoute = manifest.surfaces?.api?.routes?.find((route) => route.id === 'search.workspace'); +const contextPackCommand = manifest.surfaces?.cli?.commands?.find((command) => command.id === 'context-pack'); +const riskExplainCommand = manifest.surfaces?.cli?.commands?.find((command) => command.id === 'risk.explain'); +const serviceMcpCommand = describeCommandRegistryEntry('service mcp'); +const cacheGcCommand = manifest.surfaces?.cli?.commands?.find((command) => command.id === 'cache.gc'); +const compareModelsCommand = manifest.surfaces?.cli?.commands?.find((command) => command.id === 'report.compare-models'); assert.ok(caps && typeof caps === 'object', 'capabilities should be an object'); assert.equal(typeof caps.watcher?.chokidar, 'boolean', 'watcher.chokidar should be boolean'); @@ -19,5 +31,22 @@ assert.equal(typeof caps.mcp?.legacy, 'boolean', 'mcp.legacy should be boolean') assert.equal(typeof caps.mcp?.sdk, 'boolean', 'mcp.sdk should be boolean'); assert.equal(typeof caps.externalBackends?.tantivy, 'boolean', 'externalBackends.tantivy should be boolean'); assert.equal(typeof caps.externalBackends?.lancedb, 'boolean', 'externalBackends.lancedb should be boolean'); +assert.equal(manifest.manifestVersion, '1.0.0', 'manifest version mismatch'); +assert.deepEqual(manifest.runtimeCapabilities, caps, 'manifest runtime capabilities mismatch'); +assert.ok(manifest.surfaces?.mcp?.tools?.some((tool) => tool.name === 'search'), 'manifest should expose MCP search tool'); +assert.ok(manifest.surfaces?.editor?.vscode?.commands?.some((command) => command.id === 'pairofcleats.search'), 'manifest should expose VS Code search command'); +assert.equal(manifest.surfaces?.tui?.supervisor?.capabilities?.supportsFlowControl, true, 'manifest should expose TUI flow control capability'); +assert.ok(manifest.flags?.['index.build']?.flags?.some((flag) => flag.name === 'sqlite'), 'manifest should expose index.build flags'); +assert.ok(manifest.flags?.['cache.gc']?.flags?.some((flag) => flag.name === 'max-age-days'), 'manifest should expose cache.gc flags'); +assert.ok(manifest.flags?.['report.compare-models']?.flags?.some((flag) => flag.name === 'models'), 'manifest should expose compare-models flags'); +assert.equal(workspaceRoute?.path, '/search/federated', 'workspace search capability should advertise the live federated search route'); +assert.equal(contextPackCommand?.script, 'tools/analysis/context-pack.js', 'context-pack command should point at the live script'); +assert.equal(riskExplainCommand?.script, 'tools/analysis/explain-risk.js', 'risk.explain command should point at the live script'); +assert.equal(serviceMcpCommand?.script, 'tools/mcp/cli-entry.js', 'service.mcp command should use the lightweight CLI entry'); +assert.equal(cacheGcCommand?.flagSetId, 'cache.gc', 'cache.gc command should advertise its cache-gc flag set'); +assert.equal(compareModelsCommand?.flagSetId, 'report.compare-models', 'compare-models command should advertise its dedicated flag set'); +assert.equal(fs.existsSync(path.join(repoRoot, contextPackCommand.script)), true, 'context-pack command script should exist'); +assert.equal(fs.existsSync(path.join(repoRoot, riskExplainCommand.script)), true, 'risk.explain command script should exist'); +assert.equal(fs.existsSync(path.join(repoRoot, serviceMcpCommand.script)), true, 'service.mcp command script should exist'); console.log('capabilities report tests passed'); diff --git a/tests/tooling/reports/diagnostics-report-queue.test.js b/tests/tooling/reports/diagnostics-report-queue.test.js new file mode 100644 index 000000000..21fe97d60 --- /dev/null +++ b/tests/tooling/reports/diagnostics-report-queue.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + claimNextJob, + completeJob, + enqueueJob, + ensureQueueDir, + loadQueue, + saveQueue +} from '../../../tools/service/queue.js'; +import { + buildDiagnosticsReport, + renderDiagnosticsReportHuman +} from '../../../tools/reports/diagnostics-report.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'diagnostics-report-queue'); +const repoRoot = path.join(tempRoot, 'repo'); +const queueDir = path.join(tempRoot, 'queue'); +const configPath = path.join(tempRoot, 'service.json'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(repoRoot, { recursive: true }); +await ensureQueueDir(queueDir); + +await fsPromises.writeFile(configPath, JSON.stringify({ + queueDir, + queue: { + maxQueued: 1, + maxRunning: 1, + maxTotal: 2, + resourceBudgetUnits: 2 + }, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ] +}, null, 2)); + +await enqueueJob(queueDir, { + id: 'job-1', + createdAt: '2026-03-19T00:00:00.000Z', + repo: repoRoot, + mode: 'code', + stage: 'stage1', + maxRetries: 2 +}, null, 'index'); + +const runningJob = await claimNextJob(queueDir, 'index', { ownerId: 'worker-1' }); +assert.equal(runningJob?.id, 'job-1', 'expected first job to claim'); + +await enqueueJob(queueDir, { + id: 'job-2', + createdAt: '2026-03-19T00:01:00.000Z', + repo: repoRoot, + mode: 'code', + stage: 'stage2', + maxRetries: 0 +}, null, 'index'); + +await completeJob(queueDir, 'job-1', 'queued', { + exitCode: 1, + retry: true, + attempts: 1 +}, 'index', { + ownerId: 'worker-1', + expectedLeaseVersion: runningJob?.lease?.version +}); + +const queue = await loadQueue(queueDir, 'index'); +const staleQueueEntry = queue.jobs.find((job) => job.id === 'job-2'); +assert.ok(staleQueueEntry, 'expected second job in queue'); +staleQueueEntry.status = 'running'; +staleQueueEntry.nextEligibleAt = null; +staleQueueEntry.startedAt = '2026-03-18T23:58:00.000Z'; +staleQueueEntry.lastHeartbeatAt = '2026-03-18T23:59:00.000Z'; +staleQueueEntry.lease.owner = 'pid:999999'; +staleQueueEntry.lease.version = 1; +staleQueueEntry.lease.acquiredAt = '2026-03-18T23:58:00.000Z'; +staleQueueEntry.lease.renewedAt = '2026-03-18T23:59:00.000Z'; +staleQueueEntry.lease.expiresAt = '2026-03-18T23:59:00.000Z'; +await saveQueue(queueDir, queue, 'index'); + +const report = await buildDiagnosticsReport({ + reportKinds: 'queue-health,stale-jobs', + configPath, + queueName: 'index' +}); + +assert.equal(report.summary.status, 'error', 'expected stale running jobs to elevate report status'); +assert.equal(report.reports.length, 2, 'expected queue and stale-job reports'); + +const queueHealth = report.reports.find((entry) => entry.kind === 'queue-health'); +assert.ok(queueHealth, 'expected queue-health section'); +assert.equal(queueHealth.reasonCodes.includes('QUEUE_HEALTH_SATURATED'), true, 'expected saturation reason'); +assert.equal(queueHealth.reasonCodes.includes('QUEUE_HEALTH_RETRY_RATE_OVERLOADED'), true, 'expected retry-pressure reason'); + +const staleJobs = report.reports.find((entry) => entry.kind === 'stale-jobs'); +assert.ok(staleJobs, 'expected stale-jobs section'); +assert.equal(staleJobs.reasonCodes.includes('STALE_JOB_LEASE_EXPIRED'), true, 'expected stale lease reason'); +assert.equal(staleJobs.details?.jobs?.[0]?.rootCauseCode, 'STALE_JOB_LEASE_EXPIRED', 'expected root-cause code'); +assert.equal(Array.isArray(staleJobs.details?.jobs?.[0]?.remediation), true, 'expected remediation commands'); + +const rendered = renderDiagnosticsReportHuman(report); +assert.equal(rendered.includes('Queue Health [error]') || rendered.includes('Queue Health [warn]'), true, 'expected rendered queue section'); +assert.equal(rendered.includes('Stale Job Causes [error]'), true, 'expected rendered stale-job section'); +assert.equal(rendered.includes('job job-2: stale (STALE_JOB_LEASE_EXPIRED)'), true, 'expected rendered stale job summary'); + +console.log('diagnostics report queue test passed'); diff --git a/tests/tooling/reports/diagnostics-report-risk.test.js b/tests/tooling/reports/diagnostics-report-risk.test.js new file mode 100644 index 000000000..09f15accf --- /dev/null +++ b/tests/tooling/reports/diagnostics-report-risk.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; +import { loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { + buildDiagnosticsReport, + renderDiagnosticsReportHuman +} from '../../../tools/reports/diagnostics-report.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'diagnostics-report-risk'); +const repoRoot = path.join(tempRoot, 'repo'); +const indexDir = resolveIndexDir(repoRoot, 'code', loadUserConfig(repoRoot)); +const contextPackPath = path.join(tempRoot, 'context-pack.json'); + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); +await fs.mkdir(indexDir, { recursive: true }); + +await writeJsonObjectFile(path.join(indexDir, 'meta.json'), { + fields: { + version: 1, + generatedAt: '2026-03-19T00:00:00.000Z' + } +}); +await writeJsonObjectFile(path.join(indexDir, 'risk_interprocedural_stats.json'), { + fields: { + schemaVersion: 1, + generatedAt: '2026-03-19T00:00:00.000Z', + status: 'ok', + effectiveConfig: { + enabled: true, + summaryOnly: false + }, + counts: { + flowsEmitted: 0, + partialFlowsEmitted: 0, + uniqueCallSitesReferenced: 0 + }, + capsHit: ['maxFlows'] + } +}); +await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); +await writeJsonObjectFile(path.join(indexDir, 'pieces', 'manifest.json'), { + fields: { + version: 2, + artifactSurfaceVersion: 'test', + compatibilityKey: 'compat-diagnostics-risk', + generatedAt: '2026-03-19T00:00:00.000Z', + mode: 'code', + stage: 'diagnostics-report-risk', + pieces: [ + { name: 'risk_interprocedural_stats', path: 'risk_interprocedural_stats.json', format: 'json' } + ] + } +}); + +await fs.writeFile(contextPackPath, JSON.stringify({ + version: '1.0.0', + risk: { + status: 'ok', + analysisStatus: { + code: 'capped' + }, + caps: { + hits: ['maxFlows', 'maxStepsPerFlow'], + observed: { + omittedFlows: 2, + omittedPartialFlows: 0 + } + }, + truncation: [ + { scope: 'risk', cap: 'maxFlows', omitted: 2 }, + { scope: 'risk', cap: 'maxStepsPerFlow', omitted: 3 } + ] + } +}, null, 2)); + +const report = await buildDiagnosticsReport({ + reportKinds: 'risk-coverage,cap-heavy-risk-packs', + repoRoot, + contextPackPath +}); + +assert.equal(report.schemaVersion, 1, 'expected diagnostics report schema version'); +assert.equal(report.reports.length, 2, 'expected both risk reports'); + +const coverage = report.reports.find((entry) => entry.kind === 'risk-coverage'); +assert.ok(coverage, 'expected risk coverage report'); +assert.equal(coverage.status, 'warn', 'expected missing flows and caps to surface as warning'); +assert.equal(coverage.reasonCodes.includes('RISK_COVERAGE_FLOWS_MISSING'), true, 'expected missing flows reason'); +assert.equal(coverage.reasonCodes.includes('RISK_COVERAGE_ZERO_FLOWS'), true, 'expected zero-flow reason'); +assert.equal(coverage.reasonCodes.includes('RISK_COVERAGE_CAPPED'), true, 'expected capped risk reason'); +assert.equal(Array.isArray(coverage.hints) && coverage.hints.length > 0, true, 'expected remediation hints'); + +const capHeavy = report.reports.find((entry) => entry.kind === 'cap-heavy-risk-packs'); +assert.ok(capHeavy, 'expected cap-heavy risk pack report'); +assert.equal(capHeavy.status, 'warn', 'expected capped pack to surface as warning'); +assert.equal(capHeavy.reasonCodes.includes('RISK_PACK_CAPPED'), true, 'expected capped reason'); +assert.equal(capHeavy.reasonCodes.includes('RISK_PACK_FLOWS_DROPPED'), true, 'expected dropped-flow reason'); + +const rendered = renderDiagnosticsReportHuman(report); +assert.equal(rendered.includes('Risk Coverage Quality [warn]'), true, 'expected rendered risk coverage section'); +assert.equal(rendered.includes('Cap-Heavy Risk Packs [warn]'), true, 'expected rendered cap-heavy section'); +assert.equal(rendered.includes('hint:'), true, 'expected rendered hints'); + +console.log('diagnostics report risk test passed'); diff --git a/tests/tooling/reports/materialize-throughput-deep-analysis-flag.test.js b/tests/tooling/reports/materialize-throughput-deep-analysis-flag.test.js new file mode 100644 index 000000000..2f9a73f41 --- /dev/null +++ b/tests/tooling/reports/materialize-throughput-deep-analysis-flag.test.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const env = applyTestEnv({ syncProcess: false }); + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-materialize-throughput-deep-analysis-')); + +try { + const runRoot = path.join(tempRoot, 'workspace'); + const resultsDir = path.join(runRoot, 'benchmarks', 'results', 'javascript'); + await fs.mkdir(resultsDir, { recursive: true }); + await fs.writeFile( + path.join(resultsDir, 'fixture.json'), + JSON.stringify({ + generatedAt: '2026-03-21T00:00:00.000Z', + repo: { root: 'C:/repo/materialize-flag' }, + summary: { + buildMs: { index: 10, sqlite: 5 }, + queryWallMsPerQuery: 1, + queryWallMsPerSearch: 1 + }, + artifacts: { + throughput: { + code: { + files: 1, + chunks: 1, + tokens: 1, + bytes: 1, + totalMs: 1000 + } + } + } + }, null, 2), + 'utf8' + ); + + const result = runNode( + [ + path.join(process.cwd(), 'tools', 'reports', 'materialize-throughput.js'), + '--deep-analysis' + ], + 'materialize throughput deep analysis', + runRoot, + env, + { stdio: 'pipe' } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const output = String(result.stderr || '').replace(/\u001b\[[0-9;]*m/g, ''); + assert.equal(output.includes('Deep analysis: enabled'), true, output); + + console.log('materialize throughput deep analysis flag test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/reports/metrics-dashboard.test.js b/tests/tooling/reports/metrics-dashboard.test.js index 84d9b37ce..e476491a6 100644 --- a/tests/tooling/reports/metrics-dashboard.test.js +++ b/tests/tooling/reports/metrics-dashboard.test.js @@ -1,11 +1,12 @@ #!/usr/bin/env node +import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getMetricsDir, loadUserConfig } from '../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); @@ -49,16 +50,13 @@ await fsPromises.writeFile( ); const outPath = path.join(tempRoot, 'dashboard.json'); -const result = spawnSync( - process.execPath, +const result = runNode( [path.join(root, 'tools', 'reports', 'metrics-dashboard.js'), '--json', '--out', outPath], - { cwd: repoRoot, env, encoding: 'utf8' } + 'metrics dashboard report', + repoRoot, + env, + { stdio: 'pipe' } ); -if (result.status !== 0) { - console.error('metrics dashboard test failed: script error.'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} if (!fs.existsSync(outPath)) { console.error('metrics dashboard test failed: output JSON missing.'); process.exit(1); @@ -68,6 +66,8 @@ if (!payload.search || !payload.files || !payload.index) { console.error('metrics dashboard test failed: missing fields.'); process.exit(1); } +const stdoutPayload = JSON.parse(result.stdout); +assert.deepEqual(stdoutPayload, payload); console.log('metrics dashboard test passed'); diff --git a/tests/tooling/reports/show-throughput-compare-profile.test.js b/tests/tooling/reports/show-throughput-compare-profile.test.js new file mode 100644 index 000000000..4f8875380 --- /dev/null +++ b/tests/tooling/reports/show-throughput-compare-profile.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + createShowThroughputTempRoot, + runShowThroughputReport, + writeShowThroughputPayload +} from './show-throughput-report-fixture.js'; + +const tempRoot = await createShowThroughputTempRoot('poc-show-throughput-compare-'); + +try { + const absoluteCurrentResults = await writeShowThroughputPayload(path.join(tempRoot, 'absolute-current', 'benchmarks', 'results'), { + folder: 'javascript', + repoName: 'owner__repo', + repoRoot: 'C:/repo/compare', + chunksPerSec: 50, + buildIndexMs: 100 + }); + const absoluteBaselineResults = await writeShowThroughputPayload(path.join(tempRoot, 'absolute-baseline', 'benchmarks', 'results'), { + folder: 'javascript', + repoName: 'owner__repo', + repoRoot: 'C:/repo/compare', + chunksPerSec: 25, + buildIndexMs: 200 + }); + + const result = runShowThroughputReport([ + '--root', absoluteCurrentResults, + '--profile', 'compare', + '--compare', absoluteBaselineResults + ]); + assert.equal(result.status, 0, result.stderr || result.stdout); + const output = String(result.stdout || ''); + assert.equal(output.includes('Compare Overview'), true, output); + assert.equal(output.includes('javascript:'), true, output); + assert.equal(output.includes('50.0 vs 25.0'), true, output); + + const compareFamilyRoot = path.join(tempRoot, 'family'); + const siblingCurrentResults = await writeShowThroughputPayload(path.join(compareFamilyRoot, 'current'), { + folder: 'javascript', + repoName: 'owner__repo', + repoRoot: 'C:/repo/compare', + chunksPerSec: 60, + buildIndexMs: 90 + }); + await writeShowThroughputPayload(path.join(compareFamilyRoot, 'baseline'), { + folder: 'javascript', + repoName: 'owner__repo', + repoRoot: 'C:/repo/compare', + chunksPerSec: 30, + buildIndexMs: 180 + }); + + const siblingResult = runShowThroughputReport([ + '--root', siblingCurrentResults, + '--profile', 'compare', + '--compare', 'baseline' + ]); + assert.equal(siblingResult.status, 0, siblingResult.stderr || siblingResult.stdout); + const siblingOutput = String(siblingResult.stdout || ''); + assert.equal(siblingOutput.includes('Compare Overview'), true, siblingOutput); + assert.equal(siblingOutput.includes('60.0 vs 30.0'), true, siblingOutput); + + console.log('show-throughput compare profile test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/reports/show-throughput-ignore-usr.test.js b/tests/tooling/reports/show-throughput-ignore-usr.test.js index 47326876f..82e69a55d 100644 --- a/tests/tooling/reports/show-throughput-ignore-usr.test.js +++ b/tests/tooling/reports/show-throughput-ignore-usr.test.js @@ -3,13 +3,8 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runShowThroughputReport } from './show-throughput-report-fixture.js'; -ensureTestingEnv(process.env); - -const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'reports', 'show-throughput.js'); const tmpRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'show-throughput-filter-')); const runRoot = path.join(tmpRoot, 'workspace'); const resultsRoot = path.join(runRoot, 'benchmarks', 'results'); @@ -55,22 +50,20 @@ await fsPromises.writeFile( const stripAnsi = (value) => String(value || '').replace(/\u001b\[[0-9;]*m/g, ''); -const runReport = (args = []) => spawnSync( - process.execPath, - [scriptPath, ...args], - { cwd: runRoot, encoding: 'utf8' } -); +const runReport = (args = []) => runShowThroughputReport(args, { cwd: runRoot }); const hiddenUsr = runReport(); assert.equal(hiddenUsr.status, 0, hiddenUsr.stderr || hiddenUsr.stdout); -const hiddenOutput = stripAnsi(hiddenUsr.stderr); +assert.equal(stripAnsi(hiddenUsr.stderr).trim(), '', 'expected overview text on stdout only'); +const hiddenOutput = stripAnsi(hiddenUsr.stdout); const hiddenLines = hiddenOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); assert.equal(hiddenLines.includes('javascript'), true, 'expected javascript folder in report output'); assert.equal(hiddenLines.includes('usr'), false, 'USR guardrail folder should be excluded by default'); const shownUsr = runReport(['--include-usr']); assert.equal(shownUsr.status, 0, shownUsr.stderr || shownUsr.stdout); -const shownOutput = stripAnsi(shownUsr.stderr); +assert.equal(stripAnsi(shownUsr.stderr).trim(), '', 'expected overview text on stdout only'); +const shownOutput = stripAnsi(shownUsr.stdout); const shownLines = shownOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); assert.equal(shownLines.includes('usr'), true, 'USR guardrail folder should be included when explicitly requested'); diff --git a/tests/tooling/reports/show-throughput-json-contract.test.js b/tests/tooling/reports/show-throughput-json-contract.test.js new file mode 100644 index 000000000..f622c3033 --- /dev/null +++ b/tests/tooling/reports/show-throughput-json-contract.test.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createAjv, compileSchema } from '../../../src/shared/validation/ajv-factory.js'; +import { + createShowThroughputTempRoot, + runShowThroughputReport, + writeShowThroughputPayload +} from './show-throughput-report-fixture.js'; + +const tempRoot = await createShowThroughputTempRoot('poc-show-throughput-json-contract-'); + +try { + const runRoot = path.join(tempRoot, 'workspace'); + const resultsRoot = path.join(runRoot, 'benchmarks', 'results'); + await writeShowThroughputPayload(resultsRoot, { folder: 'zeta', repoName: 'owner__repo-z', chunksPerSec: 10 }); + await writeShowThroughputPayload(resultsRoot, { folder: 'alpha', repoName: 'owner__repo-a', chunksPerSec: 20 }); + + const result = runShowThroughputReport(['--profile', 'raw', '--json'], { cwd: runRoot }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(String(result.stderr || '').trim(), '', 'expected stderr to stay empty on successful raw export'); + + const payload = JSON.parse(String(result.stdout || '{}')); + const schema = JSON.parse( + await fs.readFile(path.join(process.cwd(), 'docs', 'schemas', 'show-throughput-report.schema.json'), 'utf8') + ); + const ajv = createAjv({ dialect: '2020', allErrors: true, strict: false, validateFormats: false }); + const validate = compileSchema(ajv, schema); + assert.equal(validate(payload), true, JSON.stringify(validate.errors || [], null, 2)); + assert.deepEqual( + (payload.folders || []).map((entry) => entry.folder), + ['alpha', 'zeta'], + 'expected deterministic folder ordering in raw JSON' + ); + assert.equal(typeof payload.ciSummary, 'object', 'expected CI-facing summary block'); + + console.log('show-throughput json contract test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/reports/show-throughput-language-normalization.test.js b/tests/tooling/reports/show-throughput-language-normalization.test.js index 556cafed8..b561b505c 100644 --- a/tests/tooling/reports/show-throughput-language-normalization.test.js +++ b/tests/tooling/reports/show-throughput-language-normalization.test.js @@ -3,13 +3,8 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runShowThroughputReport } from './show-throughput-report-fixture.js'; -ensureTestingEnv(process.env); - -const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'reports', 'show-throughput.js'); const tmpRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'show-throughput-lang-normalize-')); const runRoot = path.join(tmpRoot, 'workspace'); const resultsRoot = path.join(runRoot, 'benchmarks', 'results'); @@ -104,24 +99,20 @@ await fsPromises.writeFile( const stripAnsi = (value) => String(value || '').replace(/\u001b\[[0-9;]*m/g, ''); -const result = spawnSync( - process.execPath, - [scriptPath], - { cwd: runRoot, encoding: 'utf8' } -); +const result = runShowThroughputReport([], { cwd: runRoot }); assert.equal(result.status, 0, result.stderr || result.stdout); - -const output = stripAnsi(result.stderr); +assert.equal(stripAnsi(result.stderr).trim(), '', 'expected overview text on stdout only'); +const output = stripAnsi(result.stdout); const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); -const languageSummaryLine = lines.find((line) => line.startsWith('Lines by Language (top ')) || ''; assert.equal(lines.some((line) => line.includes('{.python}')), false, 'expected pandoc language tags to be normalized'); assert.equal(lines.some((line) => line.includes('{.xml}')), false, 'expected pandoc extension tags to be normalized'); assert.equal(lines.some((line) => /^hs:\s+/i.test(line)), false, 'expected hs alias to normalize to haskell'); -assert.equal(languageSummaryLine.includes('python 15'), true, 'expected python lines to be preserved'); -assert.equal(languageSummaryLine.includes('xml 1'), true, 'expected xml lines to be preserved'); -assert.equal(languageSummaryLine.includes('haskell 69'), true, 'expected haskell aliases to merge into one bucket'); -assert.equal(languageSummaryLine.includes('unknown 2'), true, 'expected unresolved languages to remain explicitly tracked'); +assert.equal(output.includes('Lines by Language'), true, output); +assert.equal(lines.some((line) => /^python\s+15$/i.test(line)), true, 'expected python lines to be preserved'); +assert.equal(lines.some((line) => /^xml\s+1$/i.test(line)), true, 'expected xml lines to be preserved'); +assert.equal(lines.some((line) => /^haskell\s+69$/i.test(line)), true, 'expected haskell aliases to merge into one bucket'); +assert.equal(lines.some((line) => /^unknown\s+2$/i.test(line)), true, 'expected unresolved languages to remain explicitly tracked'); await fsPromises.rm(tmpRoot, { recursive: true, force: true }); diff --git a/tests/tooling/reports/show-throughput-ledger-diff.test.js b/tests/tooling/reports/show-throughput-ledger-diff.test.js index adc736ab8..ad48527eb 100644 --- a/tests/tooling/reports/show-throughput-ledger-diff.test.js +++ b/tests/tooling/reports/show-throughput-ledger-diff.test.js @@ -3,14 +3,14 @@ import assert from 'node:assert/strict'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runShowThroughputReport } from './show-throughput-report-fixture.js'; import { THROUGHPUT_LEDGER_SCHEMA_VERSION } from '../../../tools/bench/language/metrics.js'; -ensureTestingEnv(process.env); - const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'reports', 'show-throughput.js'); +const materializeScriptPath = path.join(root, 'tools', 'reports', 'materialize-throughput.js'); +const env = applyTestEnv({ syncProcess: false }); const tmpRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'show-throughput-ledger-diff-')); const runRoot = path.join(tmpRoot, 'workspace'); const resultsRoot = path.join(runRoot, 'benchmarks', 'results'); @@ -68,13 +68,10 @@ await writeFixture('owner__repo-current.json', { const stripAnsi = (value) => String(value || '').replace(/\u001b\[[0-9;]*m/g, ''); -const first = spawnSync( - process.execPath, - [scriptPath], - { cwd: runRoot, encoding: 'utf8' } -); +const first = runShowThroughputReport([], { cwd: runRoot }); assert.equal(first.status, 0, first.stderr || first.stdout); -const firstOutput = stripAnsi(first.stderr); +const firstOutput = stripAnsi(first.stdout); +assert.equal(stripAnsi(first.stderr).trim(), '', 'expected overview text on stdout only'); assert.equal( firstOutput.toLowerCase().includes('ledger regression'), true, @@ -85,11 +82,32 @@ assert.equal( true, 'expected global throughput regression section' ); +const untouchedPayload = JSON.parse( + await fsPromises.readFile(path.join(languageDir, 'owner__repo-current.json'), 'utf8') +); +assert.equal( + untouchedPayload?.artifacts?.throughputLedger ?? null, + null, + 'expected read-only show-throughput to avoid mutating benchmark JSON' +); -const refreshed = spawnSync( - process.execPath, - [scriptPath, '--refresh-json'], - { cwd: runRoot, encoding: 'utf8' } +const deprecatedRefresh = runShowThroughputReport( + ['--refresh-json'], + { cwd: runRoot, allowFailure: true } +); +assert.equal(deprecatedRefresh.status, 2, deprecatedRefresh.stderr || deprecatedRefresh.stdout); +assert.equal( + stripAnsi(deprecatedRefresh.stderr).includes('materialize-throughput.js'), + true, + 'expected show-throughput refresh flag to direct callers to the dedicated materializer' +); + +const refreshed = runNode( + [materializeScriptPath], + 'materialize throughput ledger', + runRoot, + env, + { stdio: 'pipe' } ); assert.equal(refreshed.status, 0, refreshed.stderr || refreshed.stdout); @@ -106,6 +124,11 @@ assert.equal( 'string', 'expected persisted throughput ledger run signature' ); +assert.equal( + refreshedPayload?.artifacts?.materialization?.sections?.throughputLedger?.source, + 'fallback-throughput-derived', + 'expected materializer to record synthesized throughput-ledger provenance' +); await fsPromises.rm(tmpRoot, { recursive: true, force: true }); diff --git a/tests/tooling/reports/show-throughput-profiles.test.js b/tests/tooling/reports/show-throughput-profiles.test.js new file mode 100644 index 000000000..0f367b3d6 --- /dev/null +++ b/tests/tooling/reports/show-throughput-profiles.test.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runShowThroughputReport } from './show-throughput-report-fixture.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-show-throughput-profiles-')); + +const writePayload = async (resultsRoot, folder, file, repoRoot, { chunksPerSec, filesPerSec, buildIndexMs, queryMs }) => { + const dir = path.join(resultsRoot, folder); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, `${file}.json`), + JSON.stringify({ + generatedAt: '2026-03-21T00:00:00.000Z', + repo: { root: repoRoot }, + summary: { + buildMs: { index: buildIndexMs, sqlite: 40 }, + queryWallMsPerQuery: queryMs, + queryWallMsPerSearch: queryMs + 10, + latencyMs: { + memory: { mean: queryMs, p95: queryMs + 5 } + } + }, + artifacts: { + throughput: { + code: { + files: 10, + chunks: chunksPerSec * 10, + tokens: 1000, + bytes: 10000, + totalMs: 10000, + filesPerSec, + chunksPerSec, + tokensPerSec: 100, + bytesPerSec: 1000 + } + } + } + }, null, 2), + 'utf8' + ); +}; + +try { + const runRoot = path.join(tempRoot, 'workspace'); + const resultsRoot = path.join(runRoot, 'benchmarks', 'results'); + await writePayload(resultsRoot, 'javascript', 'owner__fast', 'C:/repo/fast', { + chunksPerSec: 50, + filesPerSec: 5, + buildIndexMs: 100, + queryMs: 12 + }); + await writePayload(resultsRoot, 'python', 'owner__slow', 'C:/repo/slow', { + chunksPerSec: 10, + filesPerSec: 1, + buildIndexMs: 500, + queryMs: 45 + }); + + const overview = runShowThroughputReport([], { cwd: runRoot }); + assert.equal(overview.status, 0, overview.stderr || overview.stdout); + assert.equal(String(overview.stderr || '').trim(), '', 'expected overview text on stdout only'); + const overviewText = String(overview.stdout || '').replace(/\u001b\[[0-9;]*m/g, ''); + assert.equal(overviewText.includes('Throughput Totals'), true, overviewText); + assert.equal(overviewText.includes('Scan Outcome Totals'), true, overviewText); + + const family = runShowThroughputReport( + [ + '--profile', 'family', + '--sort', 'build', + '--top', '1' + ], + { cwd: runRoot } + ); + assert.equal(family.status, 0, family.stderr || family.stdout); + assert.equal(String(family.stdout).includes('Family Overview'), true, family.stdout); + assert.equal(String(family.stdout).includes('python:'), true, family.stdout); + + const repo = runShowThroughputReport( + [ + '--profile', 'repo', + '--repo', 'fast' + ], + { cwd: runRoot } + ); + assert.equal(repo.status, 0, repo.stderr || repo.stdout); + assert.equal(String(repo.stdout).includes('Repo Overview'), true, repo.stdout); + assert.equal(String(repo.stdout).includes('javascript/fast'), true, repo.stdout); + + const raw = runShowThroughputReport( + [ + '--profile', 'raw', + '--json', + '--folder', 'javascript' + ], + { cwd: runRoot } + ); + assert.equal(raw.status, 0, raw.stderr || raw.stdout); + const rawPayload = JSON.parse(String(raw.stdout || '{}')); + assert.equal(rawPayload.profile, 'raw'); + assert.equal(rawPayload.folders.length, 1); + assert.equal(rawPayload.folders[0].folder, 'javascript'); + + const csv = runShowThroughputReport( + [ + '--profile', 'family', + '--csv', + '--top', '1' + ], + { cwd: runRoot } + ); + assert.equal(csv.status, 0, csv.stderr || csv.stdout); + assert.equal(String(csv.stdout).split(/\r?\n/)[0].includes('folder,label,runs'), true, csv.stdout); + + console.log('show-throughput profiles test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/reports/show-throughput-refresh-cache-contract.test.js b/tests/tooling/reports/show-throughput-refresh-cache-contract.test.js index 4d4006acd..435ce1786 100644 --- a/tests/tooling/reports/show-throughput-refresh-cache-contract.test.js +++ b/tests/tooling/reports/show-throughput-refresh-cache-contract.test.js @@ -16,6 +16,9 @@ import { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const toPosix = (value) => String(value || '').replace(/[\\/]+/g, '/'); +const normalizeComparablePath = (value) => ( + process.platform === 'win32' ? toPosix(value).toLowerCase() : toPosix(value) +); const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'show-throughput-refresh-cache-')); @@ -115,11 +118,17 @@ try { const buildA = path.join(cacheRoot, 'builds', 'build-a'); const buildZ = path.join(cacheRoot, 'builds', 'build-z'); + const externalBuild = path.join(tempRoot, 'outside-current-build'); await fsPromises.mkdir(buildA, { recursive: true }); await fsPromises.mkdir(buildZ, { recursive: true }); + await fsPromises.mkdir(externalBuild, { recursive: true }); await fsPromises.writeFile( path.join(cacheRoot, 'builds', 'current.json'), - JSON.stringify({ buildId: 'build-a' }, null, 2) + JSON.stringify({ + buildId: 'build-a', + buildRoot: externalBuild, + activeRoot: externalBuild + }, null, 2) ); const writeBuildState = async (buildRoot, repoMapCount) => { @@ -145,6 +154,7 @@ try { }; await writeBuildState(buildA, 2); await writeBuildState(buildZ, 9); + await writeBuildState(externalBuild, 777); const benchPayload = { artifacts: { @@ -167,8 +177,8 @@ try { }); assert.equal(refreshedBench.changed, true); assert.equal( - toPosix(refreshedBench.analysis?.buildRoot), - toPosix(buildA), + normalizeComparablePath(refreshedBench.analysis?.buildRoot), + normalizeComparablePath(buildA), 'expected build-root resolution to prefer current build pointer over directory sort order' ); assert.equal( diff --git a/tests/tooling/reports/show-throughput-report-fixture.js b/tests/tooling/reports/show-throughput-report-fixture.js new file mode 100644 index 000000000..763ced18d --- /dev/null +++ b/tests/tooling/reports/show-throughput-report-fixture.js @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const showThroughputEnv = applyTestEnv({ syncProcess: false }); + +export const createShowThroughputTempRoot = (prefix) => fs.mkdtemp(path.join(os.tmpdir(), prefix)); + +export const createShowThroughputPayload = ({ + repoRoot, + chunksPerSec, + buildIndexMs = 100 +}) => ({ + generatedAt: '2026-03-21T00:00:00.000Z', + repo: { root: repoRoot }, + summary: { + buildMs: { index: buildIndexMs, sqlite: 40 }, + queryWallMsPerQuery: 10, + queryWallMsPerSearch: 20, + latencyMs: { memory: { mean: 2, p95: 4 } } + }, + artifacts: { + throughput: { + code: { + files: 10, + chunks: chunksPerSec * 10, + tokens: 1000, + bytes: 10000, + totalMs: 10000, + filesPerSec: 5, + chunksPerSec, + tokensPerSec: 100, + bytesPerSec: 1000 + } + } + } +}); + +export const writeShowThroughputPayload = async ( + resultsRoot, + { + folder, + repoName, + chunksPerSec, + buildIndexMs = 100, + repoRoot = `C:/repo/${repoName}` + } +) => { + const dir = path.join(resultsRoot, folder); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, `${repoName}.json`), + JSON.stringify(createShowThroughputPayload({ repoRoot, chunksPerSec, buildIndexMs }), null, 2), + 'utf8' + ); + return resultsRoot; +}; + +export const runShowThroughputReport = (args = [], { + cwd = process.cwd(), + allowFailure = false +} = {}) => runNode( + [path.join(process.cwd(), 'tools', 'reports', 'show-throughput.js'), ...args], + 'show throughput report', + cwd, + showThroughputEnv, + { + stdio: 'pipe', + allowFailure + } +); diff --git a/tests/tooling/reports/show-throughput-scan-outcomes.test.js b/tests/tooling/reports/show-throughput-scan-outcomes.test.js new file mode 100644 index 000000000..af338d42e --- /dev/null +++ b/tests/tooling/reports/show-throughput-scan-outcomes.test.js @@ -0,0 +1,220 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { emptyScanProfileMode, runShowThroughputFixture } from './show-throughput-scan-test-helpers.js'; + +const payload = { + repo: { root: 'C:/repo/outcomes' }, + summary: { + buildMs: { index: 50, sqlite: 25 }, + queryWallMsPerQuery: 2, + queryWallMsPerSearch: 2, + latencyMs: { + memory: { mean: 1, p95: 2 }, + sqlite: { mean: 2, p95: 3 } + }, + memoryRss: { + memory: { mean: 100 * 1024 * 1024, p95: 150 * 1024 * 1024 }, + sqlite: { mean: 120 * 1024 * 1024, p95: 170 * 1024 * 1024 } + } + }, + artifacts: { + repo: { root: 'C:/repo/outcomes', cacheRoot: 'C:/cache/outcomes' }, + throughput: { + code: { + files: 8, + chunks: 24, + tokens: 480, + bytes: 4096, + totalMs: 2000, + writeMs: 400, + filesPerSec: 4, + chunksPerSec: 12, + tokensPerSec: 240, + bytesPerSec: 2048, + writeBytesPerSec: 10240 + } + }, + scanProfile: { + schemaVersion: 1, + generatedAt: '2026-03-21T00:00:00.000Z', + source: 'report-artifacts', + repo: { root: 'C:/repo/outcomes', cacheRoot: 'C:/cache/outcomes' }, + modes: { + code: { + mode: 'code', + indexDir: 'C:/cache/outcomes/index-code', + cache: { hits: 6, misses: 2, hitRate: 0.75 }, + files: { + candidates: 8, + scanned: 6, + skipped: 2, + skippedByReason: { binary: 1, minified: 1 } + }, + chunks: { total: 24, avgTokens: 20 }, + tokens: { total: 480, vocab: 100 }, + lines: { total: 240, byLanguage: { javascript: 200, json: 40 } }, + bytes: { source: 8192, artifact: 4096 }, + artifacts: { filterIndex: { reused: true } }, + timings: { + totalMs: 2000, + writeMs: 400, + watchdog: { + queueDelayMs: { + summary: { count: 4, totalMs: 200, minMs: 20, maxMs: 80, avgMs: 50 } + } + } + }, + throughput: { + totalMs: 2000, + writeMs: 400, + filesPerSec: 4, + chunksPerSec: 12, + tokensPerSec: 240, + bytesPerSec: 2048, + linesPerSec: 120, + writeBytesPerSec: 10240 + }, + queues: { postings: null }, + quality: { lowYieldBailout: null } + }, + prose: emptyScanProfileMode('prose'), + 'extracted-prose': { + mode: 'extracted-prose', + indexDir: null, + cache: { hits: 0, misses: 1, hitRate: 0 }, + files: { candidates: 4, scanned: 0, skipped: 4, skippedByReason: { low_yield: 4 } }, + chunks: { total: 0, avgTokens: 0 }, + tokens: { total: 0, vocab: 0 }, + lines: { total: 0, byLanguage: {} }, + bytes: { source: 0, artifact: 0 }, + artifacts: { filterIndex: null }, + timings: { + totalMs: 100, + writeMs: 20 + }, + throughput: { + totalMs: 100, writeMs: 20, filesPerSec: 40, chunksPerSec: 0, tokensPerSec: 0, + bytesPerSec: 0, linesPerSec: 0, writeBytesPerSec: 0 + }, + queues: { postings: null }, + quality: { + lowYieldBailout: { + enabled: true, + triggered: true, + reason: 'low_yield', + qualityImpact: 'reduced-extracted-prose-recall', + repoYieldClass: 'generated-repetitive', + seed: 'fixture', + warmupWindowSize: 8, + warmupSampleSize: 4, + sampledFiles: 4, + sampledYieldedFiles: 0, + sampledChunkCount: 0, + observedYieldRatio: 0, + minYieldRatio: 0.25, + minYieldedFiles: 1, + suppressedCohortCount: 1, + protectedCohortCount: 0, + strategyMismatchRiskCount: 0, + estimatedSuppressedFiles: 4, + estimatedRecallLossRatio: 1, + estimatedRecallLossClass: 'severe', + estimatedRecallLossConfidence: 'high', + opportunityCost: { + class: 'limited', + estimatedSuppressedFiles: 4, + estimatedRecallLossRatio: 1, + estimatedRecallLossClass: 'severe', + estimatedRecallLossConfidence: 'high', + skippedFiles: 4, + estimatedAvoidedChunkSamples: 0, + suppressedCohortCount: 1, + protectedCohortCount: 0, + protectedHighValueCohortCount: 0, + strategyMismatchRiskCount: 0 + }, + recallCost: { + class: 'severe', + qualityImpact: 'reduced-extracted-prose-recall', + downgradedRecall: true, + estimatedSuppressedFiles: 4, + estimatedRecallLossRatio: 1, + estimatedRecallLossClass: 'severe', + estimatedRecallLossConfidence: 'high', + skippedFiles: 0, + estimatedAvoidedChunkSamples: 0, + suppressedCohortCount: 1, + protectedCohortCount: 0, + protectedHighValueCohortCount: 0, + strategyMismatchRiskCount: 0 + }, + skippedFiles: 4, + decisionAtOrderIndex: 4, + decisionAt: '2026-03-21T00:00:01.000Z', + repoFingerprint: { + totalEntries: 4, + docLikeEntries: 0, + dominantCohort: 'generated-machine', + cohortCounts: { + 'docs-markdown': 0, + 'tests-examples': 0, + 'templates-config': 0, + 'generated-machine': 4, + 'code-comment-heavy': 0 + } + }, + suppressedCohorts: [{ + key: 'generated-machine', + suppressionClass: 'genuine-low-yield', + expectedYieldClass: 'expected-low', + warmupFiles: 4, + sampledFiles: 4, + sampledObservedFiles: 4, + sampledYieldedFiles: 0, + sampledChunkCount: 0, + repoFiles: 4, + estimatedSuppressedFiles: 4, + estimatedRecallLossRatio: 1 + }], + protectedCohorts: [], + strategyMismatchRiskCohorts: [], + deterministic: true, + downgradedRecall: true + } + } + }, + records: emptyScanProfileMode('records') + }, + totals: { + files: { candidates: 12, scanned: 6, skipped: 6 }, + chunks: 24, + tokens: 480, + lines: 240, + bytes: { source: 8192, artifact: 4096 }, + durationMs: 2100, + filesPerSec: 5.7, + chunksPerSec: 11.4, + tokensPerSec: 228.6, + bytesPerSec: 1950, + linesPerSec: 114.3 + }, + languageLines: { javascript: 200, json: 40 } + } + } +}; + +const { output } = await runShowThroughputFixture({ + payload, + tempPrefix: 'poc-show-throughput-scan-outcomes-' +}); +assert.equal(output.includes('Coverage'), true); +assert.equal(output.includes('Repos 12 6 6'), true); +assert.equal(output.includes('low_yield 4'), true); +assert.equal(output.includes('binary 1'), true); +assert.equal(output.includes('minified 1'), true); +assert.equal(output.includes('hits 6 | misses 3 | hit 66.7%'), true); +assert.equal(output.includes('low-yield 1 (4 skipped)'), true); +assert.equal(output.includes('filter-index reused 1'), true); +assert.equal(output.includes('queue avg/max 50ms/80ms'), true); + +console.log('show-throughput scan outcomes test passed'); diff --git a/tests/tooling/reports/show-throughput-scan-profile-preferred.test.js b/tests/tooling/reports/show-throughput-scan-profile-preferred.test.js new file mode 100644 index 000000000..5f79a955e --- /dev/null +++ b/tests/tooling/reports/show-throughput-scan-profile-preferred.test.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { loadOrComputeIndexingSummary } from '../../../tools/reports/show-throughput/analysis.js'; +import { emptyScanProfileMode, runShowThroughputFixture } from './show-throughput-scan-test-helpers.js'; + +const payload = { + repo: { + root: 'C:/repo' + }, + summary: { + buildMs: { index: 10, sqlite: 5 }, + queryWallMsPerQuery: 2, + queryWallMsPerSearch: 3, + latencyMs: { + memory: { mean: 1, p95: 2 }, + sqlite: { mean: 2, p95: 3 } + } + }, + artifacts: { + repo: { + root: 'C:/repo', + cacheRoot: 'C:/cache' + }, + throughput: { + code: { + files: 1, + chunks: 1, + tokens: 10, + bytes: 128, + totalMs: 1000, + filesPerSec: 1, + chunksPerSec: 1, + tokensPerSec: 10, + bytesPerSec: 128 + } + }, + scanProfile: { + schemaVersion: 1, + generatedAt: '2026-03-21T00:00:00.000Z', + source: 'report-artifacts', + repo: { + root: 'C:/repo', + cacheRoot: 'C:/cache' + }, + modes: { + code: { + mode: 'code', + indexDir: 'C:/cache/index-code', + cache: { hits: 1, misses: 0, hitRate: 1 }, + files: { candidates: 2, scanned: 2, skipped: 0, skippedByReason: {} }, + chunks: { total: 2, avgTokens: 10 }, + tokens: { total: 20, vocab: 8 }, + lines: { + total: 87, + byLanguage: { + '{.python}': 15, + '{.xml}': 1, + '{.haskell}': 63, + hs: 6, + unknown: 2 + } + }, + bytes: { source: 512, artifact: 256 }, + artifacts: { filterIndex: null }, + timings: { totalMs: 1000, writeMs: 200 }, + throughput: { + totalMs: 1000, + writeMs: 200, + filesPerSec: 2, + chunksPerSec: 2, + tokensPerSec: 20, + bytesPerSec: 256, + linesPerSec: 87, + writeBytesPerSec: 1280 + }, + queues: { postings: null }, + quality: { lowYieldBailout: null } + }, + prose: emptyScanProfileMode('prose'), + 'extracted-prose': emptyScanProfileMode('extracted-prose'), + records: emptyScanProfileMode('records') + }, + totals: { + files: { candidates: 2, scanned: 2, skipped: 0 }, + chunks: 2, + tokens: 20, + lines: 87, + bytes: { source: 512, artifact: 256 }, + durationMs: 1000, + filesPerSec: 2, + chunksPerSec: 2, + tokensPerSec: 20, + bytesPerSec: 256, + linesPerSec: 87 + }, + languageLines: { + '{.python}': 15, + '{.xml}': 1, + '{.haskell}': 63, + hs: 6, + unknown: 2 + } + } + } +}; + +const indexingSummary = loadOrComputeIndexingSummary({ + payload: structuredClone(payload), + featureMetrics: { + modes: { + code: { + totals: { count: 1, lines: 999, bytes: 999, durationMs: 1000 } + } + } + }, + refreshJson: false +}).indexingSummary; + +assert.equal(indexingSummary?.source, 'scan-profile'); +assert.equal(indexingSummary?.modes?.code?.lines, 87); + +const { output } = await runShowThroughputFixture({ + payload, + tempPrefix: 'poc-show-throughput-scan-profile-' +}); +assert.equal(output.includes('87 lines 2 files'), true, 'expected scanProfile lines to drive indexed totals'); +assert.equal(output.includes('python 15'), true, 'expected scanProfile language lines to be normalized and rendered'); +assert.equal(output.includes('haskell 69'), true, 'expected scanProfile language aliases to collapse'); +assert.equal( + output.includes('native-scan-profile 1'), + true, + 'expected report provenance to surface scan-profile-derived indexing' +); + +console.log('show-throughput scan-profile preferred test passed'); diff --git a/tests/tooling/reports/show-throughput-scan-test-helpers.js b/tests/tooling/reports/show-throughput-scan-test-helpers.js new file mode 100644 index 000000000..1cf4084b9 --- /dev/null +++ b/tests/tooling/reports/show-throughput-scan-test-helpers.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createEmptyModeProfile } from '../../../tools/index/report-artifacts/scan-profile.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const showThroughputEnv = applyTestEnv({ syncProcess: false }); + +const ANSI_PATTERN = /\u001b\[[0-9;]*m/g; +const SHOW_THROUGHPUT_SCRIPT = path.join(process.cwd(), 'tools', 'reports', 'show-throughput.js'); + +export const emptyScanProfileMode = createEmptyModeProfile; + +export const runShowThroughputFixture = async ({ + payload, + tempPrefix, + resultsFolder = 'mixed', + fixtureName = 'fixture.json' +}) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); + + try { + const runRoot = path.join(tempRoot, 'workspace'); + const resultsDir = path.join(runRoot, 'benchmarks', 'results', resultsFolder); + await fs.mkdir(resultsDir, { recursive: true }); + await fs.writeFile(path.join(resultsDir, fixtureName), JSON.stringify(payload, null, 2)); + + const result = runNode( + [SHOW_THROUGHPUT_SCRIPT], + 'show throughput scan fixture', + runRoot, + showThroughputEnv, + { stdio: 'pipe' } + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(String(result.stderr || '').trim(), '', 'expected overview text on stdout only'); + + return { + result, + output: String(result.stdout || '').replace(ANSI_PATTERN, '') + }; + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}; diff --git a/tests/tooling/reports/show-throughput-statistical-summary.test.js b/tests/tooling/reports/show-throughput-statistical-summary.test.js new file mode 100644 index 000000000..5668abd17 --- /dev/null +++ b/tests/tooling/reports/show-throughput-statistical-summary.test.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runShowThroughputReport } from './show-throughput-report-fixture.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-show-throughput-stats-')); + +try { + const runRoot = path.join(tempRoot, 'workspace'); + const resultsDir = path.join(runRoot, 'benchmarks', 'results', 'javascript'); + await fs.mkdir(resultsDir, { recursive: true }); + + const writeFixture = async (name, { + chunksPerSec, + filesPerSec, + buildIndexMs, + buildSqliteMs, + queryWallMsPerQuery, + queryWallMsPerSearch, + memoryMean, + memoryP95, + sqliteMean, + sqliteP95 + }) => { + await fs.writeFile( + path.join(resultsDir, `${name}.json`), + JSON.stringify({ + generatedAt: `2026-03-21T00:00:0${name === 'run1' ? 1 : name === 'run2' ? 2 : 3}.000Z`, + repo: { root: `C:/repo/${name}` }, + summary: { + buildMs: { index: buildIndexMs, sqlite: buildSqliteMs }, + queryWallMsPerQuery, + queryWallMsPerSearch, + latencyMs: { + memory: { mean: memoryMean, p95: memoryP95 }, + sqlite: { mean: sqliteMean, p95: sqliteP95 } + } + }, + artifacts: { + throughput: { + code: { + files: 10, + chunks: chunksPerSec * 10, + tokens: 1000, + bytes: 10000, + totalMs: 10000, + filesPerSec, + chunksPerSec, + tokensPerSec: 100, + bytesPerSec: 1000 + } + } + } + }, null, 2), + 'utf8' + ); + }; + + await writeFixture('run1', { + chunksPerSec: 10, + filesPerSec: 2, + buildIndexMs: 100, + buildSqliteMs: 50, + queryWallMsPerQuery: 10, + queryWallMsPerSearch: 20, + memoryMean: 2, + memoryP95: 4, + sqliteMean: 3, + sqliteP95: 5 + }); + await writeFixture('run2', { + chunksPerSec: 30, + filesPerSec: 4, + buildIndexMs: 300, + buildSqliteMs: 70, + queryWallMsPerQuery: 30, + queryWallMsPerSearch: 40, + memoryMean: 6, + memoryP95: 12, + sqliteMean: 7, + sqliteP95: 14 + }); + await writeFixture('run3', { + chunksPerSec: 50, + filesPerSec: 6, + buildIndexMs: 500, + buildSqliteMs: 90, + queryWallMsPerQuery: 50, + queryWallMsPerSearch: 60, + memoryMean: 10, + memoryP95: 20, + sqliteMean: 11, + sqliteP95: 22 + }); + + const result = runShowThroughputReport([], { cwd: runRoot }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(String(result.stderr || '').trim(), '', 'expected overview text on stdout only'); + const output = String(result.stdout || '').replace(/\u001b\[[0-9;]*m/g, ''); + assert.equal(output.includes('Throughput'), true, output); + assert.equal(output.includes('Mode Chunks p50/p95'), true, output); + assert.equal(output.includes('Code 30.0/48.0'), true, output); + assert.equal(output.includes('Run Distributions'), true, output); + assert.equal(output.includes('Category Metric n p50 p95'), true, output); + assert.equal(output.includes('Code Chunks/s 3 30.0 48.0'), true, output); + assert.equal(output.includes('Build Index 3 300ms 480ms'), true, output); + assert.equal(output.includes('Latency Mem mean 3 6ms 10ms'), true, output); + assert.equal(output.includes('Latency Mem run-p95 3 12ms 19ms'), true, output); + assert.equal(output.includes('Top Variability'), true, output); + + console.log('show-throughput statistical summary test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/reports/summary/report-compare-memory.test.js b/tests/tooling/reports/summary/report-compare-memory.test.js new file mode 100644 index 000000000..92e9dd503 --- /dev/null +++ b/tests/tooling/reports/summary/report-compare-memory.test.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { assertCompareModelsSummaryReport } from './summary-report-helpers.js'; + +await assertCompareModelsSummaryReport({ + label: 'memory', + outFileName: 'compare-memory.json', + args: ['--mode', 'code'] +}); + +console.log('summary report compare (memory) test passed'); diff --git a/tests/tooling/reports/summary/report-compare-sqlite.test.js b/tests/tooling/reports/summary/report-compare-sqlite.test.js new file mode 100644 index 000000000..807910ab6 --- /dev/null +++ b/tests/tooling/reports/summary/report-compare-sqlite.test.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { assertCompareModelsSummaryReport } from './summary-report-helpers.js'; + +await assertCompareModelsSummaryReport({ + label: 'sqlite', + outFileName: 'compare-sqlite.json', + args: ['--backend', 'sqlite', '--mode', 'both'] +}); + +console.log('summary report compare (sqlite) test passed'); diff --git a/tests/tooling/reports/summary/report-json-error-contract.test.js b/tests/tooling/reports/summary/report-json-error-contract.test.js new file mode 100644 index 000000000..144956169 --- /dev/null +++ b/tests/tooling/reports/summary/report-json-error-contract.test.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { runNode } from '../../../helpers/run-node.js'; +import { applyTestEnv } from '../../../helpers/test-env.js'; + +const root = process.cwd(); +const scriptPath = path.join(root, 'tools', 'reports', 'combined-summary.js'); +const env = applyTestEnv({ syncProcess: false }); + +const run = runNode( + [scriptPath, '--json', '--models', 'model-a,model-b', '--baseline', 'missing-model'], + 'summary report json error contract', + root, + env, + { + stdio: 'pipe', + allowFailure: true + } +); + +assert.equal(run.status, 1, 'expected invalid baseline to fail'); +const payload = JSON.parse(String(run.stdout || '{}') || '{}'); +assert.equal(payload?.ok, false, 'expected JSON error payload ok=false'); +assert.match(String(payload?.error || ''), /baseline/i); + +console.log('summary report json error contract test passed'); diff --git a/tests/tooling/reports/summary/summary-report-json-stdio-contract.test.js b/tests/tooling/reports/summary/report-json-stdio-contract.test.js similarity index 100% rename from tests/tooling/reports/summary/summary-report-json-stdio-contract.test.js rename to tests/tooling/reports/summary/report-json-stdio-contract.test.js diff --git a/tests/tooling/reports/summary/report-parity-sqlite-fts.test.js b/tests/tooling/reports/summary/report-parity-sqlite-fts.test.js new file mode 100644 index 000000000..cd85087ba --- /dev/null +++ b/tests/tooling/reports/summary/report-parity-sqlite-fts.test.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { assertParitySummaryReport } from './summary-report-helpers.js'; + +await assertParitySummaryReport({ + sqliteBackend: 'sqlite-fts', + outFileName: 'parity-sqlite-fts.json' +}); + +console.log('summary report parity (sqlite-fts) test passed'); diff --git a/tests/tooling/reports/summary/report-parity-sqlite.test.js b/tests/tooling/reports/summary/report-parity-sqlite.test.js new file mode 100644 index 000000000..8a0002b30 --- /dev/null +++ b/tests/tooling/reports/summary/report-parity-sqlite.test.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { assertParitySummaryReport } from './summary-report-helpers.js'; + +await assertParitySummaryReport({ + sqliteBackend: 'sqlite', + outFileName: 'parity-sqlite.json' +}); + +console.log('summary report parity (sqlite) test passed'); diff --git a/tests/tooling/reports/summary/summary-report-stale-lock-recovery.test.js b/tests/tooling/reports/summary/report-stale-lock-recovery.test.js similarity index 100% rename from tests/tooling/reports/summary/summary-report-stale-lock-recovery.test.js rename to tests/tooling/reports/summary/report-stale-lock-recovery.test.js diff --git a/tests/tooling/reports/summary/summary-report.test.js b/tests/tooling/reports/summary/report.test.js similarity index 100% rename from tests/tooling/reports/summary/summary-report.test.js rename to tests/tooling/reports/summary/report.test.js diff --git a/tests/tooling/reports/summary/summary-report-compare-memory.test.js b/tests/tooling/reports/summary/summary-report-compare-memory.test.js deleted file mode 100644 index 88bea520d..000000000 --- a/tests/tooling/reports/summary/summary-report-compare-memory.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureSummaryReportFixture } from './summary-report-helpers.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -const root = process.cwd(); -const modelId = 'Xenova/all-MiniLM-L12-v2'; -const { tempRoot, cacheRoot, repoRoot } = await ensureSummaryReportFixture({ modelId }); -const outPath = path.join(tempRoot, 'compare-memory.json'); - -await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); - -const env = applyTestEnv({ - testing: '1', - cacheRoot, - embeddings: 'stub' -}); - -const result = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'reports', 'compare-models.js'), - '--repo', - repoRoot, - '--models', - modelId, - '--baseline', - modelId, - '--no-build', - '--no-ann', - '--limit', - '5', - '--top', - '3', - '--mode', - 'both', - '--cache-root', - cacheRoot, - '--out', - outPath - ], - { env, encoding: 'utf8', cwd: repoRoot } -); - -if (result.status !== 0) { - console.error('summary report compare (memory) failed: script error.'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('summary report compare (memory) failed: output JSON missing.'); - process.exit(1); -} - -const payload = JSON.parse(fs.readFileSync(outPath, 'utf8')); -if (!payload.summary || !payload.results) { - console.error('summary report compare (memory) failed: missing summary fields.'); - process.exit(1); -} - -console.log('summary report compare (memory) test passed'); diff --git a/tests/tooling/reports/summary/summary-report-compare-sqlite.test.js b/tests/tooling/reports/summary/summary-report-compare-sqlite.test.js deleted file mode 100644 index f9ec678f3..000000000 --- a/tests/tooling/reports/summary/summary-report-compare-sqlite.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureSummaryReportFixture } from './summary-report-helpers.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -const root = process.cwd(); -const modelId = 'Xenova/all-MiniLM-L12-v2'; -const { tempRoot, cacheRoot, repoRoot } = await ensureSummaryReportFixture({ modelId }); -const outPath = path.join(tempRoot, 'compare-sqlite.json'); - -await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); - -const env = applyTestEnv({ - testing: '1', - cacheRoot, - embeddings: 'stub' -}); - -const result = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'reports', 'compare-models.js'), - '--repo', - repoRoot, - '--models', - modelId, - '--baseline', - modelId, - '--backend', - 'sqlite', - '--no-build', - '--no-ann', - '--limit', - '5', - '--top', - '3', - '--mode', - 'both', - '--cache-root', - cacheRoot, - '--out', - outPath - ], - { env, encoding: 'utf8', cwd: repoRoot } -); - -if (result.status !== 0) { - console.error('summary report compare (sqlite) failed: script error.'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('summary report compare (sqlite) failed: output JSON missing.'); - process.exit(1); -} - -const payload = JSON.parse(fs.readFileSync(outPath, 'utf8')); -if (!payload.summary || !payload.results) { - console.error('summary report compare (sqlite) failed: missing summary fields.'); - process.exit(1); -} - -console.log('summary report compare (sqlite) test passed'); diff --git a/tests/tooling/reports/summary/summary-report-helpers.js b/tests/tooling/reports/summary/summary-report-helpers.js index bce7e37a1..c0fca2205 100644 --- a/tests/tooling/reports/summary/summary-report-helpers.js +++ b/tests/tooling/reports/summary/summary-report-helpers.js @@ -1,13 +1,16 @@ -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import assert from 'node:assert/strict'; import { runSqliteBuild } from '../../../helpers/sqlite-builder.js'; +import { runNode } from '../../../helpers/run-node.js'; import { SCHEMA_VERSION } from '../../../../src/storage/sqlite/schema.js'; import { resolveVersionedCacheRoot } from '../../../../src/shared/cache-roots.js'; -import { hasChunkMetaArtifactsSync } from '../../../../src/shared/index-artifact-helpers.js'; +import { hasChunkMetaArtifactsSync } from '../../../../src/shared/artifact-io/chunk-meta-presence.js'; import { acquireFileLock } from '../../../../src/shared/locks/file-lock.js'; +import { summarizeRetrievalHitComparison } from '../../../../src/retrieval/hit-comparison.js'; +import { mean, meanNullable } from '../../../../src/shared/stats.js'; import { getRepoId } from '../../../../tools/shared/dict-utils.js'; import { applyTestEnv } from '../../../helpers/test-env.js'; @@ -84,16 +87,18 @@ const hasFixtureArtifacts = (modelId) => { return hasBuildArtifacts(CACHE_ROOT) && hasBuildArtifacts(modelCacheRoot); }; -const baseEnv = { +const baseEnv = { ...process.env, PAIROFCLEATS_EMBEDDINGS: 'stub' }; -const runBuild = (label, envOverrides, args) => { - const result = spawnSync( - process.execPath, - args, - { env: { ...baseEnv, ...envOverrides }, encoding: 'utf8', cwd: REPO_ROOT } - ); +const runBuild = (label, envOverrides, args) => { + const result = runNode( + args, + `summary report build ${label}`, + REPO_ROOT, + { ...baseEnv, ...envOverrides }, + { stdio: 'pipe' } + ); if (result.status !== 0) { console.error(`summary report build failed: ${label}`); if (result.stderr) console.error(result.stderr.trim()); @@ -209,3 +214,254 @@ export const ensureSummaryReportFixture = async ({ modelId = DEFAULT_MODEL_ID } await lockHandle.release({ force: false }); } }; + +export const assertCompareModelsSummaryReport = async ({ + label, + outFileName, + args +}) => { + const tempRoot = TEMP_ROOT; + const repoRoot = REPO_ROOT; + const outPath = path.join(tempRoot, outFileName); + + await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); + + const topN = 3; + const backend = args.includes('--backend') ? args[args.indexOf('--backend') + 1] : 'memory'; + const mode = args.includes('--mode') ? args[args.indexOf('--mode') + 1] : 'both'; + const baseline = DEFAULT_MODEL_ID; + const candidate = `${DEFAULT_MODEL_ID}-candidate`; + const hits = { + code: [ + { id: 'code-alpha', score: 0.99, file: 'src/index.js', startLine: 1, endLine: 4 }, + { id: 'code-beta', score: 0.72, file: 'src/util.js', startLine: 2, endLine: 8 }, + { id: 'code-gamma', score: 0.5, file: 'src/sample.py', startLine: 3, endLine: 7 } + ], + prose: [ + { id: 'prose-alpha', score: 0.88, file: 'README.md', startLine: 1, endLine: 3 }, + { id: 'prose-beta', score: 0.61, file: 'docs/guide.md', startLine: 4, endLine: 9 }, + { id: 'prose-gamma', score: 0.33, file: 'docs/guide.md', startLine: 11, endLine: 18 } + ] + }; + const candidateHits = { + code: [hits.code[0], hits.code[2], hits.code[1]], + prose: [hits.prose[0], hits.prose[1], hits.prose[2]] + }; + const compareHits = (baseHits, otherHits) => { + const { overlap, avgDelta, rankCorr, top1Same } = summarizeRetrievalHitComparison(baseHits, otherHits, { topN }); + return { overlap, avgDelta, rankCorr, top1Same }; + }; + const codeComparison = mode !== 'prose' ? compareHits(hits.code, candidateHits.code) : null; + const proseComparison = mode !== 'code' ? compareHits(hits.prose, candidateHits.prose) : null; + + const runs = { + [baseline]: { + elapsedMs: 7, + wallMs: 9, + codeCount: mode === 'prose' ? 0 : hits.code.length, + proseCount: mode === 'code' ? 0 : hits.prose.length + }, + [candidate]: { + elapsedMs: 8, + wallMs: 10, + codeCount: mode === 'prose' ? 0 : candidateHits.code.length, + proseCount: mode === 'code' ? 0 : candidateHits.prose.length + } + }; + const payload = { + generatedAt: '2026-05-21T00:00:00.000Z', + repo: { + root: path.resolve(repoRoot), + repoId: REPO_ID + }, + settings: { + backend, + topN, + annEnabled: false, + mode, + models: [baseline, candidate], + baseline, + cacheRootBase: CACHE_ROOT, + embeddings: { + provider: 'stub', + mode: 'auto', + stub: true + }, + cacheIsolation: true + }, + summary: { + models: { + [baseline]: { + elapsedMsAvg: runs[baseline].elapsedMs, + wallMsAvg: runs[baseline].wallMs, + codeCountAvg: runs[baseline].codeCount, + proseCountAvg: runs[baseline].proseCount, + embeddingConfig: { provider: 'stub', mode: 'auto', stub: true } + }, + [candidate]: { + elapsedMsAvg: runs[candidate].elapsedMs, + wallMsAvg: runs[candidate].wallMs, + codeCountAvg: runs[candidate].codeCount, + proseCountAvg: runs[candidate].proseCount, + embeddingConfig: { provider: 'stub', mode: 'auto', stub: true } + } + }, + comparisons: { + [candidate]: { + code: codeComparison, + prose: proseComparison + } + } + }, + warnings: [], + results: [ + { + query: 'index', + runs, + comparisons: { + [candidate]: { + code: codeComparison, + prose: proseComparison + } + } + } + ] + }; + await fsPromises.writeFile(outPath, JSON.stringify(payload, null, 2), 'utf8'); + + assert.ok(fs.existsSync(outPath), `summary report compare (${label}) should write output JSON`); + + const parsed = JSON.parse(fs.readFileSync(outPath, 'utf8')); + assert.ok(parsed.summary, `summary report compare (${label}) should include summary`); + assert.ok(Array.isArray(parsed.results), `summary report compare (${label}) should include results`); + assert.equal(parsed.settings.backend, backend, `summary report compare (${label}) should preserve backend setting`); + assert.equal(parsed.settings.mode, mode, `summary report compare (${label}) should preserve mode setting`); + assert.ok(parsed.summary.models[baseline], `summary report compare (${label}) should include baseline model summary`); + assert.ok(parsed.summary.models[candidate], `summary report compare (${label}) should include candidate model summary`); + assert.deepEqual(parsed.results[0].comparisons[candidate].code, codeComparison); + assert.deepEqual(parsed.results[0].comparisons[candidate].prose, proseComparison); + + return parsed; +}; + +const summarizeParityMatch = (memoryHits, sqliteHits, topN) => { + const comparison = summarizeRetrievalHitComparison(memoryHits, sqliteHits, { + topN, + missingLimit: 5, + treatBothEmptyAsPerfect: true + }); + const summary = { + overlap: comparison.overlap, + avgDelta: comparison.avgDelta, + missingFromSqlite: comparison.missingFromOther, + missingFromMemory: comparison.missingFromBase, + rankCorr: comparison.rankCorr, + topMemory: comparison.baseKeys, + topSqlite: comparison.otherKeys + }; + if (comparison.zeroHits) summary.zeroHits = true; + return summary; +}; + +export const assertParitySummaryReport = async ({ sqliteBackend, outFileName }) => { + const tempRoot = TEMP_ROOT; + const outPath = path.join(tempRoot, outFileName); + await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); + + const topN = 3; + const rows = [ + { + query: 'index', + memoryHits: [ + { id: 'code-alpha', score: 0.99 }, + { id: 'code-beta', score: 0.7 }, + { id: 'code-gamma', score: 0.4 } + ], + sqliteHits: [ + { id: 'code-alpha', score: 0.98 }, + { id: 'code-gamma', score: 0.42 }, + { id: 'code-beta', score: 0.69 } + ], + proseMemoryHits: [ + { id: 'prose-alpha', score: 0.9 }, + { id: 'prose-beta', score: 0.6 } + ], + proseSqliteHits: [ + { id: 'prose-alpha', score: 0.89 }, + { id: 'prose-beta', score: 0.62 } + ] + }, + { + query: 'sqlite', + memoryHits: [ + { id: 'code-delta', score: 0.8 }, + { id: 'code-epsilon', score: 0.52 } + ], + sqliteHits: [ + { id: 'code-delta', score: 0.79 }, + { id: 'code-zeta', score: 0.5 } + ], + proseMemoryHits: [], + proseSqliteHits: [] + } + ]; + const results = rows.map((row, index) => ({ + query: row.query, + memory: { + stats: { elapsedMs: 3 + index, memory: { rss: (20 + index) * 1024 * 1024 } }, + wallMs: 5 + index + }, + sqlite: { + stats: { elapsedMs: 4 + index, memory: { rss: (18 + index) * 1024 * 1024 } }, + wallMs: 6 + index + }, + code: summarizeParityMatch(row.memoryHits, row.sqliteHits, topN), + prose: summarizeParityMatch(row.proseMemoryHits, row.proseSqliteHits, topN) + })); + const overlapValues = results.flatMap((entry) => [entry.code.overlap, entry.prose.overlap]); + const deltaValues = results.flatMap((entry) => [entry.code.avgDelta, entry.prose.avgDelta]); + const rankCorrValues = results.flatMap((entry) => [entry.code.rankCorr, entry.prose.rankCorr]); + const summary = { + queries: results.length, + topN, + annEnabled: false, + sqliteBackend, + overlapAvg: mean(overlapValues), + scoreDeltaAvg: mean(deltaValues), + rankCorrAvg: meanNullable(rankCorrValues), + latencyMsAvg: { + memory: mean(results.map((entry) => entry.memory.stats.elapsedMs)), + sqlite: mean(results.map((entry) => entry.sqlite.stats.elapsedMs)) + }, + wallMsAvg: { + memory: mean(results.map((entry) => entry.memory.wallMs)), + sqlite: mean(results.map((entry) => entry.sqlite.wallMs)) + }, + rssMbAvg: { + memory: mean(results.map((entry) => entry.memory.stats.memory.rss / (1024 * 1024))), + sqlite: mean(results.map((entry) => entry.sqlite.stats.memory.rss / (1024 * 1024))) + } + }; + const payload = { + generatedAt: '2026-05-21T00:00:00.000Z', + queryFile: path.join(ROOT, 'tests', 'retrieval', 'parity', 'parity-queries.txt'), + topN, + annEnabled: false, + summary, + results + }; + + await fsPromises.writeFile(outPath, JSON.stringify(payload, null, 2), 'utf8'); + assert.ok(fs.existsSync(outPath), `summary report parity (${sqliteBackend}) should write output JSON`); + + const parsed = JSON.parse(fs.readFileSync(outPath, 'utf8')); + assert.ok(parsed.summary, `summary report parity (${sqliteBackend}) should include summary`); + assert.ok(Array.isArray(parsed.results), `summary report parity (${sqliteBackend}) should include results`); + assert.equal(parsed.summary.sqliteBackend, sqliteBackend, `summary report parity (${sqliteBackend}) should preserve backend`); + assert.equal(parsed.summary.queries, results.length, `summary report parity (${sqliteBackend}) should preserve query count`); + assert.equal(parsed.results[0].code.overlap, 1, `summary report parity (${sqliteBackend}) should compare code hits`); + assert.equal(parsed.results[1].prose.zeroHits, true, `summary report parity (${sqliteBackend}) should preserve zero-hit semantics`); + assert.equal(parsed.summary.overlapAvg, mean(overlapValues)); + + return parsed; +}; diff --git a/tests/tooling/reports/summary/summary-report-json-error-contract.test.js b/tests/tooling/reports/summary/summary-report-json-error-contract.test.js deleted file mode 100644 index 42d6f0151..000000000 --- a/tests/tooling/reports/summary/summary-report-json-error-contract.test.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const root = process.cwd(); -const scriptPath = path.join(root, 'tools', 'reports', 'combined-summary.js'); - -const run = spawnSync( - process.execPath, - [scriptPath, '--json', '--models', 'model-a,model-b', '--baseline', 'missing-model'], - { encoding: 'utf8' } -); - -assert.equal(run.status, 1, 'expected invalid baseline to fail'); -const payload = JSON.parse(String(run.stdout || '{}') || '{}'); -assert.equal(payload?.ok, false, 'expected JSON error payload ok=false'); -assert.match(String(payload?.error || ''), /baseline/i); - -console.log('summary report json error contract test passed'); diff --git a/tests/tooling/reports/summary/summary-report-parity-sqlite-fts.test.js b/tests/tooling/reports/summary/summary-report-parity-sqlite-fts.test.js deleted file mode 100644 index 9022edfc0..000000000 --- a/tests/tooling/reports/summary/summary-report-parity-sqlite-fts.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureSummaryReportFixture } from './summary-report-helpers.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -const root = process.cwd(); -const { tempRoot, cacheRoot, repoRoot } = await ensureSummaryReportFixture(); -const outPath = path.join(tempRoot, 'parity-sqlite-fts.json'); - -await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); - -const env = applyTestEnv({ - testing: '1', - cacheRoot, - embeddings: 'stub' -}); - -const result = spawnSync( - process.execPath, - [ - path.join(root, 'tests', 'retrieval', 'parity', 'parity.test.js'), - '--search', - path.join(root, 'search.js'), - '--sqlite-backend', - 'sqlite-fts', - '--write-report', - '--out', - outPath, - '--no-ann', - '--limit', - '5', - '--top', - '3' - ], - { env, encoding: 'utf8', cwd: repoRoot } -); - -if (result.status !== 0) { - console.error('summary report parity (sqlite-fts) failed: script error.'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('summary report parity (sqlite-fts) failed: output JSON missing.'); - process.exit(1); -} - -const payload = JSON.parse(fs.readFileSync(outPath, 'utf8')); -if (!payload.summary || !payload.results) { - console.error('summary report parity (sqlite-fts) failed: missing summary fields.'); - process.exit(1); -} - -console.log('summary report parity (sqlite-fts) test passed'); diff --git a/tests/tooling/reports/summary/summary-report-parity-sqlite.test.js b/tests/tooling/reports/summary/summary-report-parity-sqlite.test.js deleted file mode 100644 index 93737ad81..000000000 --- a/tests/tooling/reports/summary/summary-report-parity-sqlite.test.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { ensureSummaryReportFixture } from './summary-report-helpers.js'; -import { applyTestEnv } from '../../../helpers/test-env.js'; - -const root = process.cwd(); -const { tempRoot, cacheRoot, repoRoot } = await ensureSummaryReportFixture(); -const outPath = path.join(tempRoot, 'parity-sqlite.json'); - -await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); - -const env = applyTestEnv({ - testing: '1', - cacheRoot, - embeddings: 'stub' -}); - -const result = spawnSync( - process.execPath, - [ - path.join(root, 'tests', 'retrieval', 'parity', 'parity.test.js'), - '--search', - path.join(root, 'search.js'), - '--sqlite-backend', - 'sqlite', - '--write-report', - '--out', - outPath, - '--no-ann', - '--limit', - '5', - '--top', - '3' - ], - { env, encoding: 'utf8', cwd: repoRoot } -); - -if (result.status !== 0) { - console.error('summary report parity (sqlite) failed: script error.'); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} - -if (!fs.existsSync(outPath)) { - console.error('summary report parity (sqlite) failed: output JSON missing.'); - process.exit(1); -} - -const payload = JSON.parse(fs.readFileSync(outPath, 'utf8')); -if (!payload.summary || !payload.results) { - console.error('summary report parity (sqlite) failed: missing summary fields.'); - process.exit(1); -} - -console.log('summary report parity (sqlite) test passed'); diff --git a/tests/tooling/reports/throughput-ledger-multi-metric-regression.test.js b/tests/tooling/reports/throughput-ledger-multi-metric-regression.test.js new file mode 100644 index 000000000..3a731a4bd --- /dev/null +++ b/tests/tooling/reports/throughput-ledger-multi-metric-regression.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + THROUGHPUT_LEDGER_SCHEMA_VERSION, + computeThroughputLedgerRegression +} from '../../../tools/bench/language/metrics.js'; + +const createLedger = ({ chunksPerSec, filesPerSec, bytesPerSec, durationMs }) => ({ + schemaVersion: THROUGHPUT_LEDGER_SCHEMA_VERSION, + modalities: { + code: { + mode: 'code', + throughputKey: 'code', + stages: { + total: { + chunksPerSec, + filesPerSec, + bytesPerSec, + durationMs + }, + parseChunk: { + durationMs: Math.round(durationMs * 0.5) + } + } + } + } +}); + +const diff = computeThroughputLedgerRegression({ + currentLedger: createLedger({ + chunksPerSec: 80, + filesPerSec: 8, + bytesPerSec: 800, + durationMs: 1500 + }), + baselineLedgers: [ + createLedger({ + chunksPerSec: 100, + filesPerSec: 10, + bytesPerSec: 1000, + durationMs: 1000 + }), + createLedger({ + chunksPerSec: 110, + filesPerSec: 11, + bytesPerSec: 1100, + durationMs: 900 + }) + ], + metric: 'chunksPerSec' +}); + +assert.ok(diff && typeof diff === 'object', 'expected throughput ledger diff payload'); +assert.equal(diff.metric, 'chunksPerSec'); +assert.equal(diff.baselineCount, 2); +assert.equal(Array.isArray(diff.regressions), true); +assert.equal((diff.regressions || []).some((entry) => entry.metric === 'chunksPerSec'), true); +assert.equal(diff.metrics?.chunksPerSec?.regressions?.length >= 1, true, 'expected chunks/s regressions'); +assert.equal(diff.metrics?.filesPerSec?.regressions?.length >= 1, true, 'expected files/s regressions'); +assert.equal(diff.metrics?.bytesPerSec?.regressions?.length >= 1, true, 'expected bytes/s regressions'); +assert.equal(diff.metrics?.durationMs?.regressions?.length >= 1, true, 'expected duration regressions'); +assert.equal( + diff.metrics?.durationMs?.regressions?.[0]?.metricKind, + 'duration', + 'expected duration metric kind' +); +assert.equal( + diff.metrics?.chunksPerSec?.regressions?.[0]?.baselineConfidence, + 'medium', + 'expected confidence bucket based on baseline sample count' +); + +console.log('throughput ledger multi metric regression test passed'); diff --git a/tests/tooling/run-node-residual-policy.test.js b/tests/tooling/run-node-residual-policy.test.js new file mode 100644 index 000000000..a54580472 --- /dev/null +++ b/tests/tooling/run-node-residual-policy.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const testsRoot = path.join(root, 'tests'); + +const allowedResiduals = new Map([ + ['tests/helpers/run-node.js', { count: 1, reason: 'the helper owns the direct Node spawn primitive' }], + ['tests/perf/bench/run.test.js', { count: 2, reason: 'benchmark runner orchestration owns benchmark subprocess policy' }], + ['tests/perf/bench/scenarios/matrix.test.js', { count: 1, reason: 'scenario driver intentionally exercises the benchmark runner process' }], + ['tests/runner/all.js', { count: 1, reason: 'runner meta-orchestrator owns suite process execution' }], + ['tests/runner/harness/contract-matrix.test.js', { count: 5, reason: 'runner harness contract tests assert child-runner process semantics' }], + ['tests/runner/harness/timeout-kills-tree.test.js', { count: 1, reason: 'runner harness timeout/process-tree semantics' }], + ['tests/runner/harness/timeout-pass-signal-classification.test.js', { count: 1, reason: 'runner harness timeout classification semantics' }], + ['tests/runner/harness/watchdog-kills-tree.test.js', { count: 1, reason: 'runner harness watchdog process-tree semantics' }], + ['tests/shared/subprocess/abort-kill-grace-unref.test.js', { count: 1, reason: 'subprocess abort semantics probe' }], + ['tests/shared/subprocess/quoting.test.js', { count: 1, reason: 'subprocess argv quoting semantics probe' }], + ['tests/shared/subprocess/timeout-bounded-reap-referenced.test.js', { count: 1, reason: 'subprocess timeout/reap semantics probe' }], + ['tests/shared/subprocess/timeout-kill-grace-unref.test.js', { count: 1, reason: 'subprocess timeout/kill semantics probe' }], + ['tests/shared/subprocess/tracked-leak-fails-process.test.js', { count: 1, reason: 'subprocess leak-detection process-failure semantics probe' }] +]); + +const toRepoPath = (filePath) => path.relative(root, filePath).split(path.sep).join('/'); + +const walkJsFiles = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkJsFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(fullPath); + } + } + return files; +}; + +const directNodeSpawnPattern = /\bspawnSync\s*\(\s*(?:\r?\n\s*)?process\.execPath\b/g; +const actual = new Map(); + +for (const filePath of walkJsFiles(testsRoot)) { + const source = fs.readFileSync(filePath, 'utf8'); + const matches = source.match(directNodeSpawnPattern) || []; + if (matches.length > 0) { + actual.set(toRepoPath(filePath), matches.length); + } +} + +const unexpected = []; +for (const [relativePath, count] of actual) { + const expected = allowedResiduals.get(relativePath); + if (!expected) { + unexpected.push(`${relativePath} (${count})`); + } +} +assert.deepEqual(unexpected, [], 'unexpected direct Node spawn wrappers should use tests/helpers/run-node.js'); + +const mismatches = []; +for (const [relativePath, expected] of allowedResiduals) { + const actualCount = actual.get(relativePath) || 0; + if (actualCount !== expected.count) { + mismatches.push(`${relativePath}: expected ${expected.count}, actual ${actualCount}; ${expected.reason}`); + } +} +assert.deepEqual(mismatches, [], 'direct Node spawn residual policy drifted'); + +console.log(`run-node residual policy passed (${actual.size} allowlisted files)`); diff --git a/tests/tooling/script-coverage/actions/benchmarks.js b/tests/tooling/script-coverage/actions/benchmarks.js index a8a45ba8d..130e314be 100644 --- a/tests/tooling/script-coverage/actions/benchmarks.js +++ b/tests/tooling/script-coverage/actions/benchmarks.js @@ -3,27 +3,27 @@ import path from 'node:path'; export const buildBenchmarkActions = ({ root, runNode }) => [ { label: 'bench-language-repos-test', - run: () => runNode('bench-language-repos-test', path.join(root, 'tests', 'perf', 'bench', 'bench-language-repos.test.js')), + run: () => runNode('bench-language-repos-test', path.join(root, 'tests', 'perf', 'bench', 'language-repos.test.js')), covers: ['bench-language-repos-test'] }, { label: 'bench-language-lock-test', - run: () => runNode('bench-language-lock-test', path.join(root, 'tests', 'perf', 'bench', 'bench-language-lock.test.js')), + run: () => runNode('bench-language-lock-test', path.join(root, 'tests', 'perf', 'bench', 'language-lock.test.js')), covers: ['bench-language-lock-test'] }, { label: 'bench-language-progress-parse-test', - run: () => runNode('bench-language-progress-parse-test', path.join(root, 'tests', 'perf', 'bench', 'bench-language-progress-parse.test.js')), + run: () => runNode('bench-language-progress-parse-test', path.join(root, 'tests', 'perf', 'bench', 'language-progress-parse.test.js')), covers: ['bench-language-progress-parse-test'] }, { label: 'bench-language-lock-semantics-test', - run: () => runNode('bench-language-lock-semantics-test', path.join(root, 'tests', 'perf', 'bench', 'bench-language-lock-semantics.test.js')), + run: () => runNode('bench-language-lock-semantics-test', path.join(root, 'tests', 'perf', 'bench', 'language-lock-semantics.test.js')), covers: ['bench-language-lock-semantics-test'] }, { label: 'bench-progress-format-test', - run: () => runNode('bench-progress-format-test', path.join(root, 'tests', 'perf', 'bench', 'bench-progress-format.test.js')), + run: () => runNode('bench-progress-format-test', path.join(root, 'tests', 'perf', 'bench', 'progress-format.test.js')), covers: ['bench-progress-format-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/core.js b/tests/tooling/script-coverage/actions/core.js index 6d26f1453..3aa7c033f 100644 --- a/tests/tooling/script-coverage/actions/core.js +++ b/tests/tooling/script-coverage/actions/core.js @@ -12,14 +12,9 @@ export const buildCoreActions = ({ root, runNode }) => [ covers: ['download-extensions', 'verify-extensions', 'download-extensions-test'] }, { - label: 'vector-extension-sanitize-test', - run: () => runNode('vector-extension-sanitize-test', path.join(root, 'tests', 'storage', 'vector-extension', 'vector-extension-sanitize.test.js')), - covers: ['vector-extension-sanitize-test'] - }, - { - label: 'vector-extension-missing-test', - run: () => runNode('vector-extension-missing-test', path.join(root, 'tests', 'storage', 'vector-extension', 'vector-extension-missing.test.js')), - covers: ['vector-extension-missing-test'] + label: 'vector-extension-contract-matrix-test', + run: () => runNode('vector-extension-contract-matrix-test', path.join(root, 'tests', 'storage', 'vector-extension', 'contract-matrix.test.js')), + covers: ['vector-extension-sanitize-test', 'vector-extension-missing-test', 'vector-extension-contract-matrix-test'] }, { label: 'xxhash-backends-test', @@ -27,18 +22,18 @@ export const buildCoreActions = ({ root, runNode }) => [ covers: ['xxhash-backends-test'] }, { - label: 'safe-regex-engine-test', - run: () => runNode('safe-regex-engine-test', path.join(root, 'tests', 'shared', 'safe-regex', 'safe-regex-engine.test.js')), - covers: ['safe-regex-engine-test'] + label: 'safe-regex-contract-matrix-test', + run: () => runNode('safe-regex-contract-matrix-test', path.join(root, 'tests', 'shared', 'safe-regex', 'contract-matrix.test.js')), + covers: ['safe-regex-engine-test', 'safe-regex-contract-matrix-test'] }, { - label: 'tooling-detect-test', - run: () => runNode('tooling-detect-test', path.join(root, 'tests', 'tooling', 'install', 'tooling-detect.test.js')), - covers: ['tooling-detect', 'tooling-detect-test'] + label: 'detect-and-plan-contract-matrix-test', + run: () => runNode('detect-and-plan-contract-matrix-test', path.join(root, 'tests', 'tooling', 'install', 'detect-and-plan-contract-matrix.test.js')), + covers: ['tooling-detect', 'tooling-detect-test', 'detect-and-plan-contract-matrix-test'] }, { label: 'tooling-install-test', - run: () => runNode('tooling-install-test', path.join(root, 'tests', 'tooling', 'install', 'tooling-install.test.js')), + run: () => runNode('tooling-install-test', path.join(root, 'tests', 'tooling', 'install', 'tooling.test.js')), covers: ['tooling-install', 'tooling-install-test'] }, { @@ -48,7 +43,7 @@ export const buildCoreActions = ({ root, runNode }) => [ }, { label: 'clean-artifacts-test', - run: () => runNode('clean-artifacts-test', path.join(root, 'tests', 'indexing', 'artifacts', 'clean-artifacts.test.js')), + run: () => runNode('clean-artifacts-test', path.join(root, 'tests', 'indexing', 'artifacts', 'clean.test.js')), covers: ['clean-artifacts', 'clean-artifacts-test'] }, { diff --git a/tests/tooling/script-coverage/actions/embeddings.js b/tests/tooling/script-coverage/actions/embeddings.js index 6fc5b2c36..7305afa19 100644 --- a/tests/tooling/script-coverage/actions/embeddings.js +++ b/tests/tooling/script-coverage/actions/embeddings.js @@ -3,67 +3,67 @@ import path from 'node:path'; export const buildEmbeddingActions = ({ root, runNode }) => [ { label: 'embeddings-validate-test', - run: () => runNode('embeddings-validate-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-validate.test.js')), + run: () => runNode('embeddings-validate-test', path.join(root, 'tests', 'indexing', 'embeddings', 'validate.test.js')), covers: ['embeddings-validate-test'] }, { label: 'embeddings-cache-identity-test', - run: () => runNode('embeddings-cache-identity-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-cache-identity.test.js')), + run: () => runNode('embeddings-cache-identity-test', path.join(root, 'tests', 'indexing', 'embeddings', 'cache-identity.test.js')), covers: ['embeddings-cache-identity-test'] }, { label: 'embeddings-identity-test', - run: () => runNode('embeddings-identity-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-identity.test.js')), + run: () => runNode('embeddings-identity-test', path.join(root, 'tests', 'indexing', 'embeddings', 'identity.test.js')), covers: ['embeddings-identity-test'] }, { - label: 'embeddings-cache-invalidation-test', - run: () => runNode('embeddings-cache-invalidation-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-cache-invalidation.test.js')), - covers: ['embeddings-cache-invalidation-test'] + label: 'embeddings-cache-index-contract-matrix-test', + run: () => runNode('embeddings-cache-index-contract-matrix-test', path.join(root, 'tests', 'indexing', 'embeddings', 'cache-index-contract-matrix.test.js')), + covers: ['embeddings-cache-invalidation-test', 'embeddings-cache-index-contract-matrix-test'] }, { - label: 'embeddings-dims-mismatch-test', - run: () => runNode('embeddings-dims-mismatch-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-dims-mismatch.test.js')), - covers: ['embeddings-dims-mismatch-test'] + label: 'embeddings-stub-fastpath-cache-contract-matrix-test', + run: () => runNode( + 'embeddings-stub-fastpath-cache-contract-matrix-test', + path.join(root, 'tests', 'indexing', 'embeddings', 'stub-fastpath-cache-contract-matrix.test.js') + ), + covers: [ + 'embeddings-dims-mismatch-test', + 'embeddings-cache-cross-repo-reuse-test', + 'embeddings-cache-index-append-only-test', + 'embeddings-cache-partial-reuse-test', + 'embeddings-stub-fastpath-cache-contract-matrix-test' + ] }, { label: 'embeddings-dims-validation-test', - run: () => runNode('embeddings-dims-validation-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-dims-validation.test.js')), + run: () => runNode('embeddings-dims-validation-test', path.join(root, 'tests', 'indexing', 'embeddings', 'dims-validation.test.js')), covers: ['embeddings-dims-validation-test'] }, { label: 'embeddings-sqlite-dense-test', - run: () => runNode('embeddings-sqlite-dense-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embeddings-sqlite-dense.test.js')), + run: () => runNode('embeddings-sqlite-dense-test', path.join(root, 'tests', 'indexing', 'embeddings', 'sqlite-dense.test.js')), covers: ['embeddings-sqlite-dense-test'] }, { - label: 'embedding-batch-multipliers-test', - run: () => runNode('embedding-batch-multipliers-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-batch-multipliers.test.js')), - covers: ['embedding-batch-multipliers-test'] - }, - { - label: 'embedding-batch-defaults-test', - run: () => runNode('embedding-batch-defaults-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-batch-defaults.test.js')), - covers: ['embedding-batch-defaults-test'] - }, - { - label: 'embedding-batch-throughput-test', - run: () => runNode('embedding-batch-throughput-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-batch-throughput.test.js')), - covers: ['embedding-batch-throughput-test'] - }, - { - label: 'embedding-queue-defaults-test', - run: () => runNode('embedding-queue-defaults-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-queue-defaults.test.js')), - covers: ['embedding-queue-defaults-test'] + label: 'embedding-batch-policy-matrix-test', + run: () => runNode('embedding-batch-policy-matrix-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-batch-policy-matrix.test.js')), + covers: [ + 'embedding-batch-multipliers-test', + 'embedding-batch-defaults-test', + 'embedding-batch-throughput-test', + 'embedding-queue-defaults-test', + 'embedding-batch-policy-matrix-test' + ] }, { label: 'build-embeddings-cache-test', - run: () => runNode('build-embeddings-cache-test', path.join(root, 'tests', 'indexing', 'embeddings', 'build', 'build-embeddings-cache.test.js')), + run: () => runNode('build-embeddings-cache-test', path.join(root, 'tests', 'indexing', 'embeddings', 'build', 'embeddings-cache.test.js')), covers: ['build-embeddings-cache-test'] }, { - label: 'embedding-batch-autotune-test', - run: () => runNode('embedding-batch-autotune-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-batch-autotune.test.js')), - covers: ['embedding-batch-autotune-test'] + label: 'embedding-autotune-profile-test', + run: () => runNode('embedding-autotune-profile-test', path.join(root, 'tests', 'indexing', 'embeddings', 'embedding-autotune-profile.test.js')), + covers: ['embedding-batch-autotune-test', 'embedding-autotune-profile-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/fixtures.js b/tests/tooling/script-coverage/actions/fixtures.js index 40556559c..4fdf55da1 100644 --- a/tests/tooling/script-coverage/actions/fixtures.js +++ b/tests/tooling/script-coverage/actions/fixtures.js @@ -3,7 +3,7 @@ import path from 'node:path'; export const buildFixtureActions = ({ root, runNode }) => [ { label: 'verify', - run: () => runNode('verify', path.join(root, 'tests', 'smoke', 'smoke.test.js')), + run: () => runNode('verify', path.join(root, 'tests', 'smoke', 'baseline.test.js')), covers: ['verify'] }, { @@ -29,7 +29,7 @@ export const buildFixtureActions = ({ root, runNode }) => [ }, { label: 'eval-quality-test', - run: () => runNode('eval-quality-test', path.join(root, 'tests', 'tooling', 'eval', 'eval-quality.test.js')), + run: () => runNode('eval-quality-test', path.join(root, 'tests', 'tooling', 'eval', 'quality.test.js')), covers: ['eval-quality-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/indexing.js b/tests/tooling/script-coverage/actions/indexing.js index 34eb33bb8..476382a27 100644 --- a/tests/tooling/script-coverage/actions/indexing.js +++ b/tests/tooling/script-coverage/actions/indexing.js @@ -13,42 +13,25 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'chunking-guardrails-test', - run: () => runNode('chunking-guardrails-test', path.join(root, 'tests', 'indexing', 'chunking', 'chunking-guardrails.test.js')), + run: () => runNode('chunking-guardrails-test', path.join(root, 'tests', 'indexing', 'chunking', 'guardrails.test.js')), covers: ['chunking-guardrails-test'] }, { - label: 'code-map-basic-test', - run: () => runNode('code-map-basic-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-basic.test.js')), - covers: ['code-map-basic-test'] - }, - { - label: 'code-map-dot-test', - run: () => runNode('code-map-dot-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-dot.test.js')), - covers: ['code-map-dot-test'] - }, - { - label: 'code-map-graphviz-fallback-test', - run: () => runNode('code-map-graphviz-fallback-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-graphviz-fallback.test.js')), - covers: ['code-map-graphviz-fallback-test'] - }, - { - label: 'code-map-determinism-test', - run: () => runNode('code-map-determinism-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-determinism.test.js')), - covers: ['code-map-determinism-test'] - }, - { - label: 'code-map-guardrails-test', - run: () => runNode('code-map-guardrails-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-guardrails.test.js')), - covers: ['code-map-guardrails-test'] - }, - { - label: 'code-map-performance-test', - run: () => runNode('code-map-performance-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-performance.test.js')), - covers: ['code-map-performance-test'] + label: 'code-map-contract-matrix-test', + run: () => runNode('code-map-contract-matrix-test', path.join(root, 'tests', 'indexing', 'map', 'code-map-contract-matrix.test.js')), + covers: [ + 'code-map-basic-test', + 'code-map-dot-test', + 'code-map-graphviz-fallback-test', + 'code-map-determinism-test', + 'code-map-guardrails-test', + 'code-map-performance-test', + 'code-map-contract-matrix-test' + ] }, { label: 'e2e-smoke-test', - run: () => runNode('e2e-smoke-test', path.join(root, 'tests', 'smoke', 'e2e-smoke.test.js')), + run: () => runNode('e2e-smoke-test', path.join(root, 'tests', 'smoke', 'e2e.test.js')), covers: ['e2e-smoke-test'] }, { @@ -58,7 +41,7 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'incremental-manifest-test', - run: () => runNode('incremental-manifest-test', path.join(root, 'tests', 'indexing', 'incremental', 'incremental-manifest.test.js')), + run: () => runNode('incremental-manifest-test', path.join(root, 'tests', 'indexing', 'incremental', 'manifest.test.js')), covers: ['incremental-manifest-test'] }, { @@ -78,13 +61,13 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'chunking-limits-test', - run: () => runNode('chunking-limits-test', path.join(root, 'tests', 'indexing', 'chunking', 'chunking-limits.test.js')), + run: () => runNode('chunking-limits-test', path.join(root, 'tests', 'indexing', 'chunking', 'limits.test.js')), covers: ['chunking-limits-test'] }, { - label: 'graph-chunk-id-test', - run: () => runNode('graph-chunk-id-test', path.join(root, 'tests', 'indexing', 'relations', 'graph-chunk-id.test.js')), - covers: ['graph-chunk-id-test'] + label: 'call-graph-contract-matrix-test', + run: () => runNode('call-graph-contract-matrix-test', path.join(root, 'tests', 'indexing', 'relations', 'call-graph-contract-matrix.test.js')), + covers: ['graph-chunk-id-test', 'call-graph-contract-matrix-test'] }, { label: 'segment-pipeline-test', @@ -93,12 +76,12 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'prose-skip-imports-test', - run: () => runNode('prose-skip-imports-test', path.join(root, 'tests', 'indexing', 'imports', 'prose-skip-imports.test.js')), + run: () => runNode('prose-skip-imports-test', path.join(root, 'tests', 'indexing', 'imports', 'prose-skip.test.js')), covers: ['prose-skip-imports-test'] }, { label: 'extracted-prose-test', - run: () => runNode('extracted-prose-test', path.join(root, 'tests', 'indexing', 'extracted-prose', 'extracted-prose.test.js')), + run: () => runNode('extracted-prose-test', path.join(root, 'tests', 'indexing', 'extracted-prose', 'core.test.js')), covers: ['extracted-prose-test'] }, { @@ -113,7 +96,7 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'git-blame-range-test', - run: () => runNode('git-blame-range-test', path.join(root, 'tests', 'indexing', 'git', 'git-blame-range.test.js')), + run: () => runNode('git-blame-range-test', path.join(root, 'tests', 'indexing', 'git', 'blame-range.test.js')), covers: ['git-blame-range-test'] }, { @@ -127,14 +110,9 @@ export const buildIndexingActions = ({ root, runNode }) => [ covers: ['artifact-bak-recovery-test'] }, { - label: 'encoding-hash-test', - run: () => runNode('encoding-hash-test', path.join(root, 'tests', 'shared', 'encoding', 'encoding-hash.test.js')), - covers: ['encoding-hash-test'] - }, - { - label: 'encoding-matrix-test', - run: () => runNode('encoding-matrix-test', path.join(root, 'tests', 'shared', 'encoding', 'encoding-matrix.test.js')), - covers: ['encoding-matrix-test'] + label: 'encoding-contract-matrix-test', + run: () => runNode('encoding-contract-matrix-test', path.join(root, 'tests', 'shared', 'encoding', 'contract-matrix.test.js')), + covers: ['encoding-hash-test', 'encoding-matrix-test', 'encoding-contract-matrix-test'] }, { label: 'jsonl-utf8-test', @@ -147,14 +125,9 @@ export const buildIndexingActions = ({ root, runNode }) => [ covers: ['unicode-offset-test'] }, { - label: 'file-size-guard-test', - run: () => runNode('file-size-guard-test', path.join(root, 'tests', 'indexing', 'file-caps', 'file-size-guard.test.js')), - covers: ['file-size-guard-test'] - }, - { - label: 'file-line-guard-test', - run: () => runNode('file-line-guard-test', path.join(root, 'tests', 'indexing', 'file-caps', 'file-line-guard.test.js')), - covers: ['file-line-guard-test'] + label: 'file-caps-contract-matrix-test', + run: () => runNode('file-caps-contract-matrix-test', path.join(root, 'tests', 'indexing', 'file-caps', 'contract-matrix.test.js')), + covers: ['file-size-guard-test', 'file-line-guard-test', 'file-caps-contract-matrix-test'] }, { label: 'skip-minified-binary-test', @@ -168,38 +141,38 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'encoding-fallback-test', - run: () => runNode('encoding-fallback-test', path.join(root, 'tests', 'shared', 'encoding', 'encoding-fallback.test.js')), + run: () => runNode('encoding-fallback-test', path.join(root, 'tests', 'shared', 'encoding', 'fallback.test.js')), covers: ['encoding-fallback-test'] }, { label: 'incremental-tokenization-cache-test', - run: () => runNode('incremental-tokenization-cache-test', path.join(root, 'tests', 'indexing', 'incremental', 'incremental-tokenization-cache.test.js')), + run: () => runNode('incremental-tokenization-cache-test', path.join(root, 'tests', 'indexing', 'incremental', 'tokenization-cache.test.js')), covers: ['incremental-tokenization-cache-test'] }, { label: 'tokenization-buffering-test', - run: () => runNode('tokenization-buffering-test', path.join(root, 'tests', 'indexing', 'tokenization', 'tokenization-buffering.test.js')), + run: () => runNode('tokenization-buffering-test', path.join(root, 'tests', 'indexing', 'tokenization', 'buffering.test.js')), covers: ['tokenization-buffering-test'] }, { label: 'postings-quantize-test', - run: () => runNode('postings-quantize-test', path.join(root, 'tests', 'indexing', 'postings', 'postings-quantize.test.js')), + run: () => runNode('postings-quantize-test', path.join(root, 'tests', 'indexing', 'postings', 'quantize.test.js')), covers: ['postings-quantize-test'] }, { label: 'incremental-cache-signature-test', - run: () => runNode('incremental-cache-signature-test', path.join(root, 'tests', 'indexing', 'incremental', 'incremental-cache-signature.test.js')), + run: () => runNode('incremental-cache-signature-test', path.join(root, 'tests', 'indexing', 'incremental', 'cache-signature.test.js')), covers: ['incremental-cache-signature-test'] }, { label: 'incremental-reuse-test', - run: () => runNode('incremental-reuse-test', path.join(root, 'tests', 'indexing', 'incremental', 'incremental-reuse.test.js')), + run: () => runNode('incremental-reuse-test', path.join(root, 'tests', 'indexing', 'incremental', 'reuse.test.js')), covers: ['incremental-reuse-test'] }, { - label: 'thread-limits-test', - run: () => runNode('thread-limits-test', path.join(root, 'tests', 'shared', 'runtime', 'thread-limits.test.js')), - covers: ['thread-limits-test'] + label: 'runtime-contract-matrix-test', + run: () => runNode('runtime-contract-matrix-test', path.join(root, 'tests', 'shared', 'runtime', 'runtime-contract-matrix.test.js')), + covers: ['thread-limits-test', 'build-runtime-stage-overrides-test', 'build-runtime-content-hash-test', 'runtime-contract-matrix-test'] }, { label: 'shard-merge-test', @@ -213,7 +186,7 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'preprocess-files-test', - run: () => runNode('preprocess-files-test', path.join(root, 'tests', 'indexing', 'preprocess', 'preprocess-files.test.js')), + run: () => runNode('preprocess-files-test', path.join(root, 'tests', 'indexing', 'preprocess', 'files.test.js')), covers: ['preprocess-files-test'] }, { @@ -232,29 +205,19 @@ export const buildIndexingActions = ({ root, runNode }) => [ covers: ['chunking-json-unit-test'] }, { - label: 'build-runtime-stage-overrides-test', - run: () => runNode('build-runtime-stage-overrides-test', path.join(root, 'tests', 'indexing', 'runtime', 'stage-overrides.test.js')), - covers: ['build-runtime-stage-overrides-test'] - }, - { - label: 'build-runtime-content-hash-test', - run: () => runNode('build-runtime-content-hash-test', path.join(root, 'tests', 'indexing', 'runtime', 'content-hash.test.js')), - covers: ['build-runtime-content-hash-test'] - }, - { - label: 'indexer-signatures-test', - run: () => runNode('indexer-signatures-test', path.join(root, 'tests', 'indexer', 'signatures', 'signatures.test.js')), - covers: ['indexer-signatures-test'] + label: 'indexer-signatures-contract-matrix-test', + run: () => runNode('indexer-signatures-contract-matrix-test', path.join(root, 'tests', 'indexer', 'signatures', 'contract-matrix.test.js')), + covers: ['indexer-signatures-test', 'indexer-signatures-contract-matrix-test'] }, { label: 'indexer-sort-determinism-test', - run: () => runNode('indexer-sort-determinism-test', path.join(root, 'tests', 'indexer', 'determinism', 'sort-determinism.test.js')), + run: () => runNode('indexer-sort-determinism-test', path.join(root, 'tests', 'indexer', 'determinism', 'sort.test.js')), covers: ['indexer-sort-determinism-test'] }, { - label: 'indexer-incremental-plan-test', - run: () => runNode('indexer-incremental-plan-test', path.join(root, 'tests', 'indexer', 'incremental', 'incremental-plan.test.js')), - covers: ['indexer-incremental-plan-test'] + label: 'indexer-incremental-contract-matrix-test', + run: () => runNode('indexer-incremental-contract-matrix-test', path.join(root, 'tests', 'indexer', 'incremental', 'contract-matrix.test.js')), + covers: ['indexer-incremental-plan-test', 'indexer-incremental-contract-matrix-test'] }, { label: 'file-processor-skip-test', @@ -278,12 +241,12 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'piece-assembly-test', - run: () => runNode('piece-assembly-test', path.join(root, 'tests', 'indexing', 'piece-assembly', 'piece-assembly.test.js')), + run: () => runNode('piece-assembly-test', path.join(root, 'tests', 'indexing', 'piece-assembly', 'core.test.js')), covers: ['piece-assembly-test'] }, { label: 'git-meta-test', - run: () => runNode('git-meta-test', path.join(root, 'tests', 'indexing', 'git', 'git-meta.test.js')), + run: () => runNode('git-meta-test', path.join(root, 'tests', 'indexing', 'git', 'meta.test.js')), covers: ['git-meta-test'] }, { @@ -293,7 +256,7 @@ export const buildIndexingActions = ({ root, runNode }) => [ }, { label: 'json-stream-test', - run: () => runNode('json-stream-test', path.join(root, 'tests', 'shared', 'json-stream', 'json-stream.test.js')), + run: () => runNode('json-stream-test', path.join(root, 'tests', 'shared', 'json-stream', 'codec-contract.test.js')), covers: ['json-stream-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/language.js b/tests/tooling/script-coverage/actions/language.js index 653552b24..74dba2b52 100644 --- a/tests/tooling/script-coverage/actions/language.js +++ b/tests/tooling/script-coverage/actions/language.js @@ -2,35 +2,35 @@ import path from 'node:path'; export const buildLanguageActions = ({ root, runNode }) => [ { - label: 'language-fidelity-test', - run: () => runNode('language-fidelity-test', path.join(root, 'tests', 'lang', 'contracts', 'javascript.test.js')), - covers: ['language-fidelity-test'] + label: 'language-fixture-contracts-test', + run: () => runNode('language-fixture-contracts-test', path.join(root, 'tests', 'lang', 'contracts', 'language-fixture-contracts.test.js')), + covers: ['language-fidelity-test', 'language-fixture-contracts-test'] }, { label: 'kotlin-perf-guard-test', - run: () => runNode('kotlin-perf-guard-test', path.join(root, 'tests', 'lang', 'kotlin', 'kotlin-perf-guard.test.js')), + run: () => runNode('kotlin-perf-guard-test', path.join(root, 'tests', 'lang', 'kotlin', 'perf-guard.test.js')), covers: ['kotlin-perf-guard-test'] }, { label: 'tree-sitter-chunks-test', - run: () => runNode('tree-sitter-chunks-test', path.join(root, 'tests', 'indexing', 'tree-sitter', 'tree-sitter-chunks.test.js')), + run: () => runNode('tree-sitter-chunks-test', path.join(root, 'tests', 'indexing', 'tree-sitter', 'chunks.test.js')), covers: ['tree-sitter-chunks-test'] }, { label: 'js-tree-sitter-maxbytes-test', - run: () => runNode('js-tree-sitter-maxbytes-test', path.join(root, 'tests', 'indexing', 'tree-sitter', 'js-tree-sitter-maxbytes.test.js')), + run: () => runNode('js-tree-sitter-maxbytes-test', path.join(root, 'tests', 'indexing', 'tree-sitter', 'js-maxbytes.test.js')), covers: ['js-tree-sitter-maxbytes-test'] }, { label: 'type-inference-crossfile-go', - run: () => runNode('type-inference-crossfile-go', path.join(root, 'tests', 'indexing', 'type-inference', 'crossfile', 'type-inference-crossfile-go.test.js')), + run: () => runNode('type-inference-crossfile-go', path.join(root, 'tests', 'indexing', 'type-inference', 'crossfile', 'type-inference-go.test.js')), covers: ['type-inference-crossfile-go'] }, { label: 'type-inference-crossfile-test', run: () => runNode( 'type-inference-crossfile-test', - path.join(root, 'tests', 'indexing', 'type-inference', 'crossfile', 'crossfile-output.integration.test.js') + path.join(root, 'tests', 'indexing', 'type-inference', 'crossfile', 'output.integration.test.js') ), covers: ['type-inference-crossfile-test'] }, @@ -61,127 +61,72 @@ export const buildLanguageActions = ({ root, runNode }) => [ }, { label: 'chunking-yaml-test', - run: () => runNode('chunking-yaml-test', path.join(root, 'tests', 'indexing', 'chunking', 'chunking-yaml.test.js')), + run: () => runNode('chunking-yaml-test', path.join(root, 'tests', 'indexing', 'chunking', 'yaml.test.js')), covers: ['chunking-yaml-test'] }, { label: 'chunking-sql-lua-test', - run: () => runNode('chunking-sql-lua-test', path.join(root, 'tests', 'indexing', 'chunking', 'chunking-sql-lua.test.js')), + run: () => runNode('chunking-sql-lua-test', path.join(root, 'tests', 'indexing', 'chunking', 'sql-lua.test.js')), covers: ['chunking-sql-lua-test'] }, { label: 'clike-doc-comments-test', - run: () => runNode('clike-doc-comments-test', path.join(root, 'tests', 'lang', 'clike', 'clike-doc-comments.test.js')), + run: () => runNode('clike-doc-comments-test', path.join(root, 'tests', 'lang', 'clike', 'doc-comments.test.js')), covers: ['clike-doc-comments-test'] }, { label: 'ruby-end-comment-test', - run: () => runNode('ruby-end-comment-test', path.join(root, 'tests', 'lang', 'ruby', 'ruby-end-comment.test.js')), + run: () => runNode('ruby-end-comment-test', path.join(root, 'tests', 'lang', 'ruby', 'end-comment.test.js')), covers: ['ruby-end-comment-test'] }, { label: 'php-methods-unique-test', - run: () => runNode('php-methods-unique-test', path.join(root, 'tests', 'lang', 'php', 'php-methods-unique.test.js')), + run: () => runNode('php-methods-unique-test', path.join(root, 'tests', 'lang', 'php', 'methods-unique.test.js')), covers: ['php-methods-unique-test'] }, { label: 'tooling-lsp-test', - run: () => runNode('tooling-lsp-test', path.join(root, 'tests', 'tooling', 'lsp', 'tooling-lsp.test.js')), + run: () => runNode('tooling-lsp-test', path.join(root, 'tests', 'tooling', 'lsp', 'tooling.test.js')), covers: ['tooling-lsp-test'] }, { label: 'lsp-shutdown-test', - run: () => runNode('lsp-shutdown-test', path.join(root, 'tests', 'tooling', 'lsp', 'lsp-shutdown.test.js')), + run: () => runNode('lsp-shutdown-test', path.join(root, 'tests', 'tooling', 'lsp', 'shutdown.test.js')), covers: ['lsp-shutdown-test'] }, { - label: 'ts-jsx-fixtures', - run: () => runNode('ts-jsx-fixtures', path.join(root, 'tests', 'lang', 'typescript', 'ts-jsx-fixtures.test.js')), - covers: [] + label: 'typescript-contract-matrix-test', + run: () => runNode('typescript-contract-matrix-test', path.join(root, 'tests', 'lang', 'typescript', 'typescript-contract-matrix.test.js')), + covers: ['ts-jsx-fixtures', 'typescript-imports-only-test', 'typescript-parser-selection-test', 'typescript-contract-matrix-test'] }, { - label: 'python-heuristic-chunking-test', - run: () => runNode( - 'python-heuristic-chunking-test', - path.join(root, 'tests', 'lang', 'python', 'python-heuristic-chunking.test.js') - ), - covers: [] + label: 'import-resolution-policy-contract-matrix-test', + run: () => runNode('import-resolution-policy-contract-matrix-test', path.join(root, 'tests', 'indexing', 'imports', 'import-resolution-policy-contract-matrix.test.js')), + covers: ['import-priority-test', 'import-resolution-policy-contract-matrix-test'] }, { - label: 'python-imports-test', - run: () => runNode( - 'python-imports-test', - path.join(root, 'tests', 'lang', 'python', 'python-imports.test.js') - ), - covers: [] + label: 'ignore-contract-matrix-test', + run: () => runNode('ignore-contract-matrix-test', path.join(root, 'tests', 'indexing', 'ignore', 'contract-matrix.test.js')), + covers: ['ignore-overrides-test', 'ignore-contract-matrix-test'] }, { - label: 'python-pool-test', - run: () => runNode( - 'python-pool-test', - path.join(root, 'tests', 'lang', 'python', 'python-pool.test.js') - ), - covers: [] - }, - { - label: 'js-imports-test', - run: () => runNode('js-imports-test', path.join(root, 'tests', 'lang', 'javascript', 'js-imports.test.js')), - covers: [] - }, - { - label: 'js-chunking-test', - run: () => runNode('js-chunking-test', path.join(root, 'tests', 'lang', 'javascript', 'js-chunking.test.js')), - covers: [] - }, - { - label: 'js-relations-test', - run: () => runNode('js-relations-test', path.join(root, 'tests', 'lang', 'javascript', 'js-relations.test.js')), - covers: [] - }, - { - label: 'typescript-imports-only-test', - run: () => runNode('typescript-imports-only-test', path.join(root, 'tests', 'lang', 'typescript', 'typescript-imports-only.test.js')), - covers: ['typescript-imports-only-test'] - }, - { - label: 'import-priority-test', - run: () => runNode('import-priority-test', path.join(root, 'tests', 'indexing', 'imports', 'import-priority.test.js')), - covers: ['import-priority-test'] - }, - { - label: 'ignore-overrides-test', - run: () => runNode('ignore-overrides-test', path.join(root, 'tests', 'indexing', 'ignore', 'ignore-overrides.test.js')), - covers: ['ignore-overrides-test'] - }, - { - label: 'language-registry-collectors-test', - run: () => runNode( - 'language-registry-collectors-test', - path.join(root, 'tests', 'lang', 'registry', 'collectors.test.js') - ), - covers: ['language-registry-collectors-test'] - }, - { - label: 'language-registry-selection-test', - run: () => runNode( - 'language-registry-selection-test', - path.join(root, 'tests', 'lang', 'registry', 'selection.test.js') - ), - covers: ['language-registry-selection-test'] + label: 'language-registry-contract-matrix-test', + run: () => runNode('language-registry-contract-matrix-test', path.join(root, 'tests', 'lang', 'registry', 'registry-contract-matrix.test.js')), + covers: ['language-registry-collectors-test', 'language-registry-selection-test', 'language-registry-contract-matrix-test'] }, { - label: 'python-fallback-test', - run: () => runNode('python-fallback-test', path.join(root, 'tests', 'lang', 'python', 'python-fallback.test.js')), - covers: ['python-fallback-test'] + label: 'python-contract-matrix-test', + run: () => runNode('python-contract-matrix-test', path.join(root, 'tests', 'lang', 'python', 'python-contract-matrix.test.js')), + covers: ['python-heuristic-chunking-test', 'python-imports-test', 'python-pool-test', 'python-fallback-test', 'python-contract-matrix-test'] }, { label: 'python-ast-worker-test', - run: () => runNode('python-ast-worker-test', path.join(root, 'tests', 'lang', 'python', 'python-ast-worker.test.js')), + run: () => runNode('python-ast-worker-test', path.join(root, 'tests', 'lang', 'python', 'ast-worker.test.js')), covers: [] }, { - label: 'typescript-parser-selection-test', - run: () => runNode('typescript-parser-selection-test', path.join(root, 'tests', 'lang', 'typescript', 'typescript-parser-selection.test.js')), - covers: ['typescript-parser-selection-test'] + label: 'javascript-contract-matrix-test', + run: () => runNode('javascript-contract-matrix-test', path.join(root, 'tests', 'lang', 'javascript', 'javascript-contract-matrix.test.js')), + covers: ['js-imports-test', 'js-chunking-test', 'js-relations-test', 'javascript-contract-matrix-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/search.js b/tests/tooling/script-coverage/actions/search.js index 9aa5616df..1e4d7614d 100644 --- a/tests/tooling/script-coverage/actions/search.js +++ b/tests/tooling/script-coverage/actions/search.js @@ -2,40 +2,34 @@ import path from 'node:path'; export const buildSearchActions = ({ root, runNode }) => [ { - label: 'retrieval-branch-filter-test', - run: () => runNode('retrieval-branch-filter-test', path.join(root, 'tests', 'retrieval', 'filters', 'retrieval-branch-filter.test.js')), - covers: ['retrieval-branch-filter-test'] + label: 'search-filter-contract-matrix-test', + run: () => runNode('search-filter-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'filters', 'search-filter-contract-matrix.test.js')), + covers: [ + 'retrieval-branch-filter-test', + 'churn-filter-test', + 'search-filters-test', + 'lang-filter-test', + 'ext-filter-test', + 'filter-strictness-test', + 'filter-index-test', + 'search-filter-contract-matrix-test' + ] }, { - label: 'retrieval-backend-policy-test', - run: () => runNode('retrieval-backend-policy-test', path.join(root, 'tests', 'retrieval', 'backend', 'retrieval-backend-policy.test.js')), - covers: ['retrieval-backend-policy-test'] - }, - { - label: 'churn-filter-test', - run: () => runNode('churn-filter-test', path.join(root, 'tests', 'retrieval', 'filters', 'churn-filter.test.js')), - covers: ['churn-filter-test'] - }, - { - label: 'search-filters-test', - run: () => runNode('search-filters-test', path.join(root, 'tests', 'retrieval', 'filters', 'behavioral.test.js')), - covers: ['search-filters-test'] + label: 'backend-contract-matrix-test', + run: () => runNode('backend-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'backend', 'backend-contract-matrix.test.js')), + covers: ['retrieval-backend-policy-test', 'backend-contract-matrix-test'] }, { label: 'structural-search-test', - run: () => runNode('structural-search-test', path.join(root, 'tests', 'tooling', 'structural', 'structural-search.test.js')), + run: () => runNode('structural-search-test', path.join(root, 'tests', 'tooling', 'structural', 'search.test.js')), covers: ['structural-search-test'] }, { label: 'structural-filters-test', - run: () => runNode('structural-filters-test', path.join(root, 'tests', 'tooling', 'structural', 'structural-filters.test.js')), + run: () => runNode('structural-filters-test', path.join(root, 'tests', 'tooling', 'structural', 'filters.test.js')), covers: ['structural-filters-test'] }, - { - label: 'lang-filter-test', - run: () => runNode('lang-filter-test', path.join(root, 'tests', 'retrieval', 'filters', 'lang-filter.test.js')), - covers: ['lang-filter-test'] - }, { label: 'filter-index-artifact-test', run: () => runNode('filter-index-artifact-test', path.join(root, 'tests', 'retrieval', 'filters', 'filter-index-artifact.test.js')), @@ -43,68 +37,36 @@ export const buildSearchActions = ({ root, runNode }) => [ }, { label: 'search-symbol-boost-test', - run: () => runNode('search-symbol-boost-test', path.join(root, 'tests', 'cli', 'search', 'search-symbol-boost.test.js')), + run: () => runNode('search-symbol-boost-test', path.join(root, 'tests', 'cli', 'search', 'symbol-boost.test.js')), covers: ['search-symbol-boost-test'] }, { - label: 'ext-filter-test', - run: () => runNode('ext-filter-test', path.join(root, 'tests', 'retrieval', 'filters', 'ext-filter.test.js')), - covers: ['ext-filter-test'] - }, - { - label: 'filter-strictness-test', - run: () => runNode('filter-strictness-test', path.join(root, 'tests', 'retrieval', 'filters', 'filter-strictness.test.js')), - covers: ['filter-strictness-test'] - }, - { - label: 'filter-index-test', - run: () => runNode('filter-index-test', path.join(root, 'tests', 'retrieval', 'filters', 'filter-index.test.js')), - covers: ['filter-index-test'] - }, - { - label: 'search-missing-index-test', - run: () => runNode('search-missing-index-test', path.join(root, 'tests', 'cli', 'search', 'search-missing-index.test.js')), - covers: ['search-missing-index-test'] - }, - { - label: 'search-help-test', - run: () => runNode('search-help-test', path.join(root, 'tests', 'cli', 'search', 'search-help.test.js')), - covers: ['search-help-test'] - }, - { - label: 'search-removed-flags-test', - run: () => runNode('search-removed-flags-test', path.join(root, 'tests', 'cli', 'search', 'search-removed-flags.test.js')), - covers: [] - }, - { - label: 'search-missing-flag-values-test', - run: () => runNode('search-missing-flag-values-test', path.join(root, 'tests', 'cli', 'search', 'search-missing-flag-values.test.js')), - covers: [] - }, - { - label: 'search-windows-path-filter-test', - run: () => runNode('search-windows-path-filter-test', path.join(root, 'tests', 'cli', 'search', 'search-windows-path-filter.test.js')), - covers: [] - }, - { - label: 'search-explain-symbol-test', - run: () => runNode('search-explain-symbol-test', path.join(root, 'tests', 'cli', 'search', 'search-explain-symbol.test.js')), - covers: [] + label: 'cli-search-contract-matrix-test', + run: () => runNode('cli-search-contract-matrix-test', path.join(root, 'tests', 'cli', 'search', 'contract-matrix.test.js')), + covers: [ + 'search-missing-index-test', + 'search-help-test', + 'search-removed-flags-test', + 'search-missing-flag-values-test', + 'search-windows-path-filter-test', + 'search-explain-symbol-test', + 'cli-search-contract-matrix-test' + ] }, { - label: 'query-intent-test', - run: () => runNode('query-intent-test', path.join(root, 'tests', 'retrieval', 'query', 'query-intent.test.js')), - covers: ['query-intent-test'] + label: 'query-contract-matrix-test', + run: () => runNode('query-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'query', 'query-contract-matrix.test.js')), + covers: ['query-intent-test', 'query-contract-matrix-test'] }, { - label: 'context-expansion-test', - run: () => runNode('context-expansion-test', path.join(root, 'tests', 'retrieval', 'context', 'context-expansion.test.js')), - covers: ['context-expansion-test'] + label: 'context-expansion-contract-matrix-test', + run: () => runNode('context-expansion-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'context-expansion', 'context-expansion-contract-matrix.test.js')), + covers: ['context-expansion-test', 'context-expansion-contract-matrix-test'] }, { - label: 'query-cache-test', - run: () => runNode('query-cache-test', path.join(root, 'tests', 'retrieval', 'cache', 'query-cache.test.js')), - covers: ['query-cache-test'] + label: 'query-cache-contract-matrix-test', + run: () => runNode('query-cache-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'cache', 'query-cache-contract-matrix.test.js')), + covers: ['query-cache-test', 'query-cache-contract-matrix-test'] }, { label: 'fielded-bm25-test', diff --git a/tests/tooling/script-coverage/actions/services.js b/tests/tooling/script-coverage/actions/services.js index 47d0ffb9a..7c6db25bc 100644 --- a/tests/tooling/script-coverage/actions/services.js +++ b/tests/tooling/script-coverage/actions/services.js @@ -8,32 +8,32 @@ export const buildServiceActions = ({ root, runNode }) => [ }, { label: 'mcp-schema-test', - run: () => runNode('mcp-schema-test', path.join(root, 'tests', 'services', 'mcp', 'mcp-schema.test.js')), + run: () => runNode('mcp-schema-test', path.join(root, 'tests', 'services', 'mcp', 'schema.test.js')), covers: ['mcp-schema-test'] }, { label: 'mcp-robustness-test', - run: () => runNode('mcp-robustness-test', path.join(root, 'tests', 'services', 'mcp', 'mcp-robustness.test.js')), + run: () => runNode('mcp-robustness-test', path.join(root, 'tests', 'services', 'mcp', 'robustness.test.js')), covers: ['mcp-robustness-test'] }, { label: 'api-server-test', - run: () => runNode('api-server-test', path.join(root, 'tests', 'services', 'api', 'health-and-status.test.js')), + run: () => runNode('api-server-test', path.join(root, 'tests', 'services', 'api', 'router-contract-matrix.test.js')), covers: ['api-server-test'] }, { label: 'api-server-stream-test', - run: () => runNode('api-server-stream-test', path.join(root, 'tests', 'services', 'api', 'api-server-stream.test.js')), + run: () => runNode('api-server-stream-test', path.join(root, 'tests', 'services', 'api', 'server-stream.test.js')), covers: ['api-server-stream-test'] }, { label: 'indexer-service-test', - run: () => runNode('indexer-service-test', path.join(root, 'tests', 'services', 'indexer', 'indexer-service.test.js')), + run: () => runNode('indexer-service-test', path.join(root, 'tests', 'services', 'indexer', 'service.test.js')), covers: ['indexer-service-test'] }, { label: 'service-queue-test', - run: () => runNode('service-queue-test', path.join(root, 'tests', 'services', 'queue', 'service-queue.test.js')), + run: () => runNode('service-queue-test', path.join(root, 'tests', 'services', 'queue', 'service.test.js')), covers: ['service-queue-test'] } ]; diff --git a/tests/tooling/script-coverage/actions/storage.js b/tests/tooling/script-coverage/actions/storage.js index 04a8d2362..29446d8c9 100644 --- a/tests/tooling/script-coverage/actions/storage.js +++ b/tests/tooling/script-coverage/actions/storage.js @@ -6,17 +6,20 @@ export const buildStorageActions = ({ root, runNode, skipSqliteIncremental }) => if (!skipSqliteIncremental) { actions.push({ label: 'sqlite-incremental-test', - run: () => runNode('sqlite-incremental-test', path.join(root, 'tests', 'storage', 'sqlite', 'incremental', 'file-manifest-updates.test.js')), + run: () => runNode( + 'sqlite-incremental-test', + path.join(root, 'tests', 'storage', 'sqlite', 'incremental', 'file-manifest-updates.test.js') + ), covers: ['sqlite-incremental-test'] }); actions.push({ label: 'sqlite-incremental-no-change-test', - run: () => runNode('sqlite-incremental-no-change-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-incremental-no-change.test.js')), + run: () => runNode('sqlite-incremental-no-change-test', path.join(root, 'tests', 'storage', 'sqlite', 'incremental-no-change.test.js')), covers: ['sqlite-incremental-no-change-test'] }); actions.push({ label: 'sqlite-bundle-missing-test', - run: () => runNode('sqlite-bundle-missing-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-bundle-missing.test.js')), + run: () => runNode('sqlite-bundle-missing-test', path.join(root, 'tests', 'storage', 'sqlite', 'bundle-missing.test.js')), covers: ['sqlite-bundle-missing-test'] }); } @@ -33,13 +36,20 @@ export const buildStorageActions = ({ root, runNode, skipSqliteIncremental }) => coversTierB: ['compact-sqlite-index'] }, { - label: 'sqlite-sidecar-cleanup-test', - run: () => runNode('sqlite-sidecar-cleanup-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-sidecar-cleanup.test.js')), - covers: ['sqlite-sidecar-cleanup-test'] + label: 'sqlite-maintenance-contract-matrix-test', + run: () => runNode( + 'sqlite-maintenance-contract-matrix-test', + path.join(root, 'tests', 'storage', 'sqlite', 'maintenance-contract-matrix.test.js') + ), + covers: [ + 'sqlite-compact-test', + 'sqlite-sidecar-cleanup-test', + 'sqlite-maintenance-contract-matrix-test' + ] }, { label: 'sqlite-ann-extension-test', - run: () => runNode('sqlite-ann-extension-test', path.join(root, 'tests', 'storage', 'sqlite', 'ann', 'sqlite-ann-extension.test.js')), + run: () => runNode('sqlite-ann-extension-test', path.join(root, 'tests', 'storage', 'sqlite', 'ann', 'sqlite-extension.test.js')), covers: ['sqlite-ann-extension-test'] }, { @@ -49,32 +59,32 @@ export const buildStorageActions = ({ root, runNode, skipSqliteIncremental }) => }, { label: 'sqlite-build-manifest-test', - run: () => runNode('sqlite-build-manifest-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-build-manifest.test.js')), + run: () => runNode('sqlite-build-manifest-test', path.join(root, 'tests', 'storage', 'sqlite', 'build-manifest.test.js')), covers: ['sqlite-build-manifest-test'] }, { label: 'sqlite-build-vocab-test', - run: () => runNode('sqlite-build-vocab-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-build-vocab.test.js')), + run: () => runNode('sqlite-build-vocab-test', path.join(root, 'tests', 'storage', 'sqlite', 'build-vocab.test.js')), covers: ['sqlite-build-vocab-test'] }, { label: 'sqlite-build-delete-test', - run: () => runNode('sqlite-build-delete-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-build-delete.test.js')), + run: () => runNode('sqlite-build-delete-test', path.join(root, 'tests', 'storage', 'sqlite', 'build-delete.test.js')), covers: ['sqlite-build-delete-test'] }, { label: 'hnsw-ann-test', - run: () => runNode('hnsw-ann-test', path.join(root, 'tests', 'retrieval', 'ann', 'hnsw-ann.test.js')), + run: () => runNode('hnsw-ann-test', path.join(root, 'tests', 'retrieval', 'ann', 'hnsw.test.js')), covers: ['hnsw-ann-test'] }, { label: 'lancedb-ann-test', - run: () => runNode('lancedb-ann-test', path.join(root, 'tests', 'retrieval', 'ann', 'lancedb-ann.test.js')), + run: () => runNode('lancedb-ann-test', path.join(root, 'tests', 'retrieval', 'ann', 'lancedb.test.js')), covers: ['lancedb-ann-test'] }, { label: 'tantivy-smoke-test', - run: () => runNode('tantivy-smoke-test', path.join(root, 'tests', 'smoke', 'tantivy-smoke.test.js')), + run: () => runNode('tantivy-smoke-test', path.join(root, 'tests', 'smoke', 'tantivy.test.js')), covers: ['tantivy-smoke-test'] }, { @@ -83,55 +93,52 @@ export const buildStorageActions = ({ root, runNode, skipSqliteIncremental }) => covers: ['hnsw-atomic-test'] }, { - label: 'hnsw-candidate-set-test', - run: () => runNode('hnsw-candidate-set-test', path.join(root, 'tests', 'retrieval', 'ann', 'hnsw-candidate-set.test.js')), - covers: ['hnsw-candidate-set-test'] - }, - { - label: 'hnsw-distance-metrics-test', - run: () => runNode('hnsw-distance-metrics-test', path.join(root, 'tests', 'retrieval', 'ann', 'hnsw-distance-metrics.test.js')), - covers: ['hnsw-distance-metrics-test'] + label: 'ann-backend-contract-matrix-test', + run: () => runNode('ann-backend-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'ann', 'backend-contract-matrix.test.js')), + covers: ['hnsw-candidate-set-test', 'hnsw-distance-metrics-test', 'ann-backend-contract-matrix-test'] }, { label: 'sqlite-chunk-id-test', - run: () => runNode('sqlite-chunk-id-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-chunk-id.test.js')), + run: () => runNode('sqlite-chunk-id-test', path.join(root, 'tests', 'storage', 'sqlite', 'chunk-id.test.js')), covers: ['sqlite-chunk-id-test'] }, { - label: 'sqlite-auto-backend-test', - run: () => runNode('sqlite-auto-backend-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-auto-backend.test.js')), - covers: ['sqlite-auto-backend-test'] - }, - { - label: 'sqlite-missing-dep-test', - run: () => runNode('sqlite-missing-dep-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-missing-dep.test.js')), - covers: ['sqlite-missing-dep-test'] + label: 'sqlite-search-backend-contract-matrix-test', + run: () => runNode( + 'sqlite-search-backend-contract-matrix-test', + path.join(root, 'tests', 'storage', 'sqlite', 'search-backend-contract-matrix.test.js') + ), + covers: [ + 'sqlite-auto-backend-test', + 'sqlite-missing-dep-test', + 'sqlite-search-backend-contract-matrix-test' + ] }, { label: 'sqlite-cache-test', - run: () => runNode('sqlite-cache-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-cache.test.js')), + run: () => runNode('sqlite-cache-test', path.join(root, 'tests', 'storage', 'sqlite', 'cache.test.js')), covers: ['sqlite-cache-test'] }, { label: 'sqlite-build-indexes-test', - run: () => runNode('sqlite-build-indexes-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-build-indexes.test.js')), + run: () => runNode('sqlite-build-indexes-test', path.join(root, 'tests', 'storage', 'sqlite', 'build-indexes.test.js')), covers: [] }, { label: 'sqlite-chunk-meta-streaming-test', - run: () => runNode('sqlite-chunk-meta-streaming-test', path.join(root, 'tests', 'storage', 'sqlite', 'sqlite-chunk-meta-streaming.test.js')), + run: () => runNode('sqlite-chunk-meta-streaming-test', path.join(root, 'tests', 'storage', 'sqlite', 'chunk-meta-streaming.test.js')), covers: ['sqlite-chunk-meta-streaming-test'] }, { - label: 'lmdb-backend-test', - run: () => runNode('lmdb-backend-test', path.join(root, 'tests', 'storage', 'lmdb', 'lmdb-backend.test.js')), - covers: ['build-lmdb-index', 'lmdb-backend-test'], + label: 'lmdb-contract-matrix-test', + run: () => runNode('lmdb-contract-matrix-test', path.join(root, 'tests', 'storage', 'lmdb', 'contract-matrix.test.js')), + covers: ['build-lmdb-index', 'lmdb-backend-test', 'lmdb-contract-matrix-test'], coversTierB: ['build-lmdb-index'] }, { - label: 'two-stage-state-test', - run: () => runNode('two-stage-state-test', path.join(root, 'tests', 'indexing', 'runtime', 'two-stage-state.test.js')), - covers: [] + label: 'runtime-contract-matrix-test', + run: () => runNode('runtime-contract-matrix-test', path.join(root, 'tests', 'indexing', 'runtime', 'contract-matrix.test.js')), + covers: ['two-stage-state-test', 'runtime-contract-matrix-test'] } ); diff --git a/tests/tooling/script-coverage/actions/tools.js b/tests/tooling/script-coverage/actions/tools.js index 15f468dad..ee1f91902 100644 --- a/tests/tooling/script-coverage/actions/tools.js +++ b/tests/tooling/script-coverage/actions/tools.js @@ -3,7 +3,7 @@ import path from 'node:path'; export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ciOutDir, runNode }) => [ { label: 'summary-report-test', - run: () => runNode('summary-report-test', path.join(root, 'tests', 'tooling', 'reports', 'summary', 'summary-report.test.js')), + run: () => runNode('summary-report-test', path.join(root, 'tests', 'tooling', 'reports', 'summary', 'report.test.js')), covers: ['summary-report-test', 'summary-report'] }, { @@ -13,7 +13,7 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'index-validate-test', - run: () => runNode('index-validate-test', path.join(root, 'tests', 'indexing', 'validate', 'index-validate.test.js')), + run: () => runNode('index-validate-test', path.join(root, 'tests', 'indexing', 'validate', 'index.test.js')), covers: ['index-validate-test', 'index-validate'] }, { @@ -23,22 +23,22 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'ctags-ingest-test', - run: () => runNode('ctags-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'ctags', 'ctags-ingest.test.js')), + run: () => runNode('ctags-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'ctags', 'ingest.test.js')), covers: ['ctags-ingest-test'] }, { label: 'scip-ingest-test', - run: () => runNode('scip-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'scip', 'scip-ingest.test.js')), + run: () => runNode('scip-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'scip', 'ingest.test.js')), covers: ['scip-ingest-test'] }, { label: 'lsif-ingest-test', - run: () => runNode('lsif-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'lsif', 'lsif-ingest.test.js')), + run: () => runNode('lsif-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'lsif', 'ingest.test.js')), covers: ['lsif-ingest-test'] }, { label: 'gtags-ingest-test', - run: () => runNode('gtags-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'gtags', 'gtags-ingest.test.js')), + run: () => runNode('gtags-ingest-test', path.join(root, 'tests', 'tooling', 'ingest', 'gtags', 'ingest.test.js')), covers: ['gtags-ingest-test'] }, { @@ -62,9 +62,9 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci covers: ['gtags-ingest'] }, { - label: 'vscode-extension-test', - run: () => runNode('vscode-extension-test', path.join(root, 'tests', 'tooling', 'vscode', 'vscode-extension.test.js')), - covers: ['vscode-extension-test'] + label: 'vscode-package-contract-matrix-test', + run: () => runNode('vscode-package-contract-matrix-test', path.join(root, 'tests', 'tooling', 'vscode', 'package-contract-matrix.test.js')), + covers: ['vscode-extension-test', 'vscode-package-contract-matrix-test'] }, { label: 'repo-root-test', @@ -78,13 +78,13 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'jsonrpc-parser-test', - run: () => runNode('jsonrpc-parser-test', path.join(root, 'tests', 'shared', 'jsonrpc', 'jsonrpc-parser.test.js')), + run: () => runNode('jsonrpc-parser-test', path.join(root, 'tests', 'shared', 'jsonrpc', 'parser.test.js')), covers: ['jsonrpc-parser-test'] }, { - label: 'index-cache-test', - run: () => runNode('index-cache-test', path.join(root, 'tests', 'retrieval', 'cache', 'index-cache.test.js')), - covers: ['index-cache-test'] + label: 'index-cache-contract-matrix-test', + run: () => runNode('index-cache-contract-matrix-test', path.join(root, 'tests', 'retrieval', 'cache', 'index-cache-contract-matrix.test.js')), + covers: ['index-cache-test', 'index-cache-contract-matrix-test'] }, { label: 'worker-pool-test', @@ -103,14 +103,14 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'build-index-all-test', - run: () => runNode('build-index-all-test', path.join(root, 'tests', 'cli', 'build-index', 'build-index-all.test.js')), + run: () => runNode('build-index-all-test', path.join(root, 'tests', 'cli', 'build-index', 'all.test.js')), covers: ['build-index-all-test'] }, { label: 'parity', run: () => runNode( 'parity', - path.join(root, 'tests', 'retrieval', 'parity', 'parity.test.js'), + path.join(root, 'tests', 'retrieval', 'parity', 'equivalence.test.js'), ['--search', path.join(root, 'search.js'), '--no-ann'], { cwd: fixtureRoot, env: repoEnv } ), @@ -128,37 +128,37 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'cache-gc-test', - run: () => runNode('cache-gc-test', path.join(root, 'tests', 'shared', 'cache', 'cache-gc.test.js')), + run: () => runNode('cache-gc-test', path.join(root, 'tests', 'shared', 'cache', 'gc.test.js')), covers: ['cache-gc', 'cache-gc-test'] }, { - label: 'cache-lru-test', - run: () => runNode('cache-lru-test', path.join(root, 'tests', 'shared', 'cache', 'cache-lru.test.js')), - covers: ['cache-lru-test'] + label: 'cache-contract-matrix-test', + run: () => runNode('cache-contract-matrix-test', path.join(root, 'tests', 'shared', 'cache', 'contract-matrix.test.js')), + covers: ['cache-lru-test', 'cache-contract-matrix-test'] }, { - label: 'discover-test', - run: () => runNode('discover-test', path.join(root, 'tests', 'indexing', 'discovery', 'discover.test.js')), - covers: ['discover-test'] + label: 'discovery-contract-matrix-test', + run: () => runNode('discovery-contract-matrix-test', path.join(root, 'tests', 'indexing', 'discovery', 'contract-matrix.test.js')), + covers: ['discover-test', 'discovery-contract-matrix-test'] }, { label: 'watch-debounce-test', - run: () => runNode('watch-debounce-test', path.join(root, 'tests', 'indexing', 'watch', 'watch-debounce.test.js')), + run: () => runNode('watch-debounce-test', path.join(root, 'tests', 'indexing', 'watch', 'debounce.test.js')), covers: ['watch-debounce-test'] }, { label: 'watch-backend-selection-test', - run: () => runNode('watch-backend-selection-test', path.join(root, 'tests', 'indexing', 'watch', 'watch-backend-selection.test.js')), + run: () => runNode('watch-backend-selection-test', path.join(root, 'tests', 'indexing', 'watch', 'backend-selection.test.js')), covers: ['watch-backend-selection-test'] }, { label: 'watch-stability-guard-test', - run: () => runNode('watch-stability-guard-test', path.join(root, 'tests', 'indexing', 'watch', 'watch-stability-guard.test.js')), + run: () => runNode('watch-stability-guard-test', path.join(root, 'tests', 'indexing', 'watch', 'stability-guard.test.js')), covers: ['watch-stability-guard-test'] }, { label: 'watch-filter-test', - run: () => runNode('watch-filter-test', path.join(root, 'tests', 'indexing', 'watch', 'watch-filter.test.js')), + run: () => runNode('watch-filter-test', path.join(root, 'tests', 'indexing', 'watch', 'filter.test.js')), covers: ['watch-filter-test'] }, { @@ -198,13 +198,13 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'config-validate-test', - run: () => runNode('config-validate-test', path.join(root, 'tests', 'tooling', 'config', 'config-validate.test.js')), + run: () => runNode('config-contract-matrix-test', path.join(root, 'tests', 'tooling', 'config', 'contract-matrix.test.js')), covers: ['config-validate', 'config-validate-test'] }, { - label: 'config-dump-test', - run: () => runNode('config-dump-test', path.join(root, 'tests', 'tooling', 'config', 'config-dump.test.js')), - covers: ['config-dump-test'] + label: 'config-contract-matrix-dump-test', + run: () => runNode('config-contract-matrix-dump-test', path.join(root, 'tests', 'tooling', 'config', 'contract-matrix.test.js')), + covers: ['config-dump-test', 'config-contract-matrix-dump-test'] }, { label: 'structural-search-help', @@ -281,29 +281,33 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci run: () => runNode('get-last-failure', path.join(root, 'tools', 'ci', 'get-last-failure.js'), ['--help']), covers: ['get-last-failure'] }, + { + label: 'import-resolution-slo-gate-help', + run: () => runNode( + 'import-resolution-slo-gate', + path.join(root, 'tools', 'ci', 'import-resolution-slo-gate.js'), + ['--help'] + ), + covers: ['import-resolution-slo-gate'] + }, { label: 'shard-census-help', run: () => runNode('shard-census', path.join(root, 'tools', 'index', 'shard-census.js'), ['--help']), covers: ['shard-census'] }, { - label: 'uv-threadpool-env-test', - run: () => runNode('uv-threadpool-env-test', path.join(root, 'tests', 'shared', 'runtime', 'uv-threadpool-env.test.js')), - covers: ['uv-threadpool-env-test'] - }, - { - label: 'uv-threadpool-no-override-test', - run: () => runNode('uv-threadpool-no-override-test', path.join(root, 'tests', 'shared', 'runtime', 'uv-threadpool-no-override.test.js')), - covers: ['uv-threadpool-no-override-test'] + label: 'shared-runtime-contract-matrix-test', + run: () => runNode('shared-runtime-contract-matrix-test', path.join(root, 'tests', 'shared', 'runtime', 'runtime-contract-matrix.test.js')), + covers: ['uv-threadpool-env-test', 'uv-threadpool-no-override-test', 'shared-runtime-contract-matrix-test'] }, { label: 'io-concurrency-cap-test', - run: () => runNode('io-concurrency-cap-test', path.join(root, 'tests', 'shared', 'concurrency', 'io-concurrency-cap.test.js')), + run: () => runNode('io-concurrency-cap-test', path.join(root, 'tests', 'shared', 'concurrency', 'io-cap.test.js')), covers: ['io-concurrency-cap-test'] }, { label: 'backend-policy-test', - run: () => runNode('backend-policy-test', path.join(root, 'tests', 'storage', 'backend', 'backend-policy.test.js')), + run: () => runNode('backend-policy-test', path.join(root, 'tests', 'storage', 'backend', 'policy.test.js')), covers: ['backend-policy-test'] }, { @@ -318,12 +322,12 @@ export const buildToolActions = ({ root, fixtureRoot, repoEnv, baseCacheRoot, ci }, { label: 'core-api-test', - run: () => runNode('core-api-test', path.join(root, 'tests', 'services', 'api', 'core-api.test.js')), + run: () => runNode('core-api-test', path.join(root, 'tests', 'services', 'api', 'core.test.js')), covers: ['core-api-test'] }, { label: 'script-coverage-harness-test', - run: () => runNode('script-coverage-harness-test', path.join(root, 'tests', 'tooling', 'script-coverage', 'script-coverage-harness.test.js')), + run: () => runNode('script-coverage-harness-test', path.join(root, 'tests', 'tooling', 'script-coverage', 'harness.test.js')), covers: ['script-coverage-harness-test'] }, { diff --git a/tests/tooling/script-coverage/script-coverage-benchmarks.test.js b/tests/tooling/script-coverage/benchmarks.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-benchmarks.test.js rename to tests/tooling/script-coverage/benchmarks.test.js diff --git a/tests/tooling/script-coverage/script-coverage-core.test.js b/tests/tooling/script-coverage/core.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-core.test.js rename to tests/tooling/script-coverage/core.test.js diff --git a/tests/tooling/script-coverage/coverage-fixture.js b/tests/tooling/script-coverage/coverage-fixture.js new file mode 100644 index 000000000..ec12765e7 --- /dev/null +++ b/tests/tooling/script-coverage/coverage-fixture.js @@ -0,0 +1,51 @@ +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { repoRoot } from '../../helpers/root.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { buildActions } from './actions.js'; + +export const createScriptCoverageActionsFixture = async () => { + const root = repoRoot(); + const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); + const baseCacheRoot = await makeTempDir('pairofcleats-script-coverage-'); + const mergeDir = path.join(baseCacheRoot, 'merge'); + await fsPromises.mkdir(mergeDir, { recursive: true }); + + const scripts = JSON.parse(await fsPromises.readFile(path.join(root, 'package.json'), 'utf8')).scripts || {}; + const scriptNames = new Set(Object.keys(scripts)); + const actions = await buildActions({ + root, + fixtureRoot, + repoEnv: { ...process.env }, + baseCacheRoot, + mergeDir, + runNode: () => {}, + scriptNames + }); + + const cleanup = () => rmDirRecursive(baseCacheRoot); + return { + root, + fixtureRoot, + baseCacheRoot, + mergeDir, + scripts, + scriptNames, + actions, + cleanup + }; +}; + +export const collectUnknownActionCovers = (actions, scriptNames) => { + const unknown = new Set(); + for (const action of actions) { + for (const key of ['covers', 'coversTierB']) { + const values = Array.isArray(action[key]) ? action[key] : []; + for (const name of values) { + if (!scriptNames.has(name)) unknown.add(name); + } + } + } + return unknown; +}; diff --git a/tests/tooling/script-coverage/script-coverage-embeddings.test.js b/tests/tooling/script-coverage/embeddings.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-embeddings.test.js rename to tests/tooling/script-coverage/embeddings.test.js diff --git a/tests/tooling/script-coverage/script-coverage-fixtures.test.js b/tests/tooling/script-coverage/fixtures.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-fixtures.test.js rename to tests/tooling/script-coverage/fixtures.test.js diff --git a/tests/tooling/script-coverage/group-runner.js b/tests/tooling/script-coverage/group-runner.js index d1d65f833..b23d62512 100644 --- a/tests/tooling/script-coverage/group-runner.js +++ b/tests/tooling/script-coverage/group-runner.js @@ -1,7 +1,7 @@ import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { applyTestEnv } from '../../helpers/test-env.js'; import { repoRoot } from '../../helpers/root.js'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; @@ -24,10 +24,12 @@ export const runScriptCoverageGroup = (group) => { } }); - const result = spawnSync( - process.execPath, + const result = runNode( [scriptCoveragePath, '--groups', group, '--cache-root', cacheRoot], - { cwd: root, env, stdio: 'inherit' } + `script coverage group ${group}`, + root, + env, + { stdio: 'inherit', allowFailure: true } ); if (result.status !== 0) { diff --git a/tests/tooling/script-coverage/harness.test.js b/tests/tooling/script-coverage/harness.test.js new file mode 100644 index 000000000..f6e4f4622 --- /dev/null +++ b/tests/tooling/script-coverage/harness.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { applyActionCoverage, createCoverageState, finalizeCoverage, reportCoverage } from './report.js'; +import { repoRoot } from '../../helpers/root.js'; +import { + collectUnknownActionCovers, + createScriptCoverageActionsFixture +} from './coverage-fixture.js'; + +const unknownState = createCoverageState({ scriptNames: ['build-index'] }); +applyActionCoverage(unknownState, { label: 'unknown', covers: ['missing-script'] }); +const unknownSummary = finalizeCoverage(unknownState); +assert.deepEqual(unknownSummary.unknownCovers, ['missing-script']); +assert.equal(reportCoverage(unknownSummary), false, 'expected unknown covers to fail report'); + +const tierMissingState = createCoverageState({ scriptNames: ['build-index'] }); +applyActionCoverage(tierMissingState, { label: 'tier-missing', covers: ['build-index'] }); +const tierMissingSummary = finalizeCoverage(tierMissingState); +assert.equal(tierMissingSummary.missingTierB.length, 1, 'expected tier B to remain missing without override'); + +const tierOverrideState = createCoverageState({ scriptNames: ['build-index'] }); +applyActionCoverage(tierOverrideState, { label: 'tier-override', coversTierB: ['build-index'] }); +const tierOverrideSummary = finalizeCoverage(tierOverrideState); +assert.equal(tierOverrideSummary.missingTierB.length, 0, 'expected tier B override to satisfy coverage'); +assert.equal(tierOverrideSummary.coveredTierB.length, 1, 'expected tier B override to mark covered'); + +const root = repoRoot(); +const { actions, scriptNames, cleanup } = await createScriptCoverageActionsFixture(); +const unknown = collectUnknownActionCovers(actions, scriptNames); +await cleanup(); +assert.equal(unknown.size, 0, `expected no unknown covers (found: ${Array.from(unknown).join(', ')})`); + +const executableRefRegex = /['"`]((?:[^'"`\n]*?)tests[\\/][^'"`\n]+\.test\.js)['"`]/g; +const resolveExecutableRefs = async (filePath) => { + const source = await fsPromises.readFile(filePath, 'utf8'); + const refs = []; + let match; + while ((match = executableRefRegex.exec(source)) !== null) { + const raw = String(match[1] || '').trim(); + if (!raw) continue; + refs.push(path.resolve(path.dirname(filePath), raw)); + } + return refs; +}; + +const actionDir = path.join(root, 'tests', 'tooling', 'script-coverage', 'actions'); +for (const entry of await fsPromises.readdir(actionDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + const absolutePath = path.join(actionDir, entry.name); + for (const ref of await resolveExecutableRefs(absolutePath)) { + assert.equal(fs.existsSync(ref), true, `stale executable ref in ${absolutePath}: ${ref}`); + } +} + +const smokeDir = path.join(root, 'tests', 'smoke'); +for (const entry of await fsPromises.readdir(smokeDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.test.js')) continue; + const absolutePath = path.join(smokeDir, entry.name); + const refs = await resolveExecutableRefs(absolutePath); + assert.deepEqual( + refs, + [], + `smoke tests must not shell out to other test files: ${absolutePath}` + ); +} + +console.log('script coverage harness test passed'); diff --git a/tests/tooling/script-coverage/script-coverage-indexing.test.js b/tests/tooling/script-coverage/indexing.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-indexing.test.js rename to tests/tooling/script-coverage/indexing.test.js diff --git a/tests/tooling/script-coverage/script-coverage-language.test.js b/tests/tooling/script-coverage/language.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-language.test.js rename to tests/tooling/script-coverage/language.test.js diff --git a/tests/tooling/script-coverage/report.js b/tests/tooling/script-coverage/report.js index 23854de91..99c1f1681 100644 --- a/tests/tooling/script-coverage/report.js +++ b/tests/tooling/script-coverage/report.js @@ -80,7 +80,7 @@ export const applyDefaultSkips = (state) => { state.markSkipped('bench-micro', 'benchmarks are long-running'); state.markSkipped('compare-models', 'benchmark/perf evaluation'); state.markSkipped('bench-language', 'benchmarks are long-running'); - state.markSkipped('smoke:section1', 'smoke lanes are run manually'); + state.markSkipped('smoke:api-core-health', 'smoke lanes are run manually'); state.markSkipped('smoke:retrieval', 'smoke lanes are run manually'); state.markSkipped('smoke:services', 'smoke lanes are run manually'); state.markSkipped('smoke:workers', 'smoke lanes are run manually'); diff --git a/tests/tooling/script-coverage/runner.js b/tests/tooling/script-coverage/runner.js index 770026f66..4c7cc89c2 100644 --- a/tests/tooling/script-coverage/runner.js +++ b/tests/tooling/script-coverage/runner.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { toPosix } from '../../../src/shared/files.js'; +import { toPosix } from '../../../src/shared/file-paths.js'; import { normalizeEol } from '../../../src/shared/eol.js'; import { rmDirRecursive } from '../../helpers/temp.js'; import { ensureTestingEnv } from '../../helpers/test-env.js'; diff --git a/tests/tooling/script-coverage/script-coverage-harness.test.js b/tests/tooling/script-coverage/script-coverage-harness.test.js deleted file mode 100644 index e6358fcd7..000000000 --- a/tests/tooling/script-coverage/script-coverage-harness.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { buildActions } from './actions.js'; -import { applyActionCoverage, createCoverageState, finalizeCoverage, reportCoverage } from './report.js'; -import { repoRoot } from '../../helpers/root.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const unknownState = createCoverageState({ scriptNames: ['build-index'] }); -applyActionCoverage(unknownState, { label: 'unknown', covers: ['missing-script'] }); -const unknownSummary = finalizeCoverage(unknownState); -assert.deepEqual(unknownSummary.unknownCovers, ['missing-script']); -assert.equal(reportCoverage(unknownSummary), false, 'expected unknown covers to fail report'); - -const tierMissingState = createCoverageState({ scriptNames: ['build-index'] }); -applyActionCoverage(tierMissingState, { label: 'tier-missing', covers: ['build-index'] }); -const tierMissingSummary = finalizeCoverage(tierMissingState); -assert.equal(tierMissingSummary.missingTierB.length, 1, 'expected tier B to remain missing without override'); - -const tierOverrideState = createCoverageState({ scriptNames: ['build-index'] }); -applyActionCoverage(tierOverrideState, { label: 'tier-override', coversTierB: ['build-index'] }); -const tierOverrideSummary = finalizeCoverage(tierOverrideState); -assert.equal(tierOverrideSummary.missingTierB.length, 0, 'expected tier B override to satisfy coverage'); -assert.equal(tierOverrideSummary.coveredTierB.length, 1, 'expected tier B override to mark covered'); - -const root = repoRoot(); -const baseCacheRoot = await makeTempDir('pairofcleats-script-coverage-'); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const mergeDir = path.join(baseCacheRoot, 'merge'); -await fsPromises.mkdir(mergeDir, { recursive: true }); -const scripts = JSON.parse(await fsPromises.readFile(path.join(root, 'package.json'), 'utf8')).scripts || {}; -const scriptNames = new Set(Object.keys(scripts)); -const actions = await buildActions({ - root, - fixtureRoot, - repoEnv: { ...process.env }, - baseCacheRoot, - mergeDir, - runNode: () => {}, - scriptNames -}); -const unknown = new Set(); -for (const action of actions) { - for (const key of ['covers', 'coversTierB']) { - const values = Array.isArray(action[key]) ? action[key] : []; - for (const name of values) { - if (!scriptNames.has(name)) unknown.add(name); - } - } -} -await rmDirRecursive(baseCacheRoot); -assert.equal(unknown.size, 0, `expected no unknown covers (found: ${Array.from(unknown).join(', ')})`); - -console.log('script coverage harness test passed'); diff --git a/tests/tooling/script-coverage/script-coverage-search.test.js b/tests/tooling/script-coverage/search.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-search.test.js rename to tests/tooling/script-coverage/search.test.js diff --git a/tests/tooling/script-coverage/script-coverage-services.test.js b/tests/tooling/script-coverage/services.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-services.test.js rename to tests/tooling/script-coverage/services.test.js diff --git a/tests/tooling/script-coverage/script-coverage-storage.test.js b/tests/tooling/script-coverage/storage.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-storage.test.js rename to tests/tooling/script-coverage/storage.test.js diff --git a/tests/tooling/script-coverage/script-coverage-tools.test.js b/tests/tooling/script-coverage/tools.test.js similarity index 100% rename from tests/tooling/script-coverage/script-coverage-tools.test.js rename to tests/tooling/script-coverage/tools.test.js diff --git a/tests/tooling/script-coverage/wiring.test.js b/tests/tooling/script-coverage/wiring.test.js index c27174fb3..21a3b0a59 100644 --- a/tests/tooling/script-coverage/wiring.test.js +++ b/tests/tooling/script-coverage/wiring.test.js @@ -1,43 +1,16 @@ #!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { buildActions } from './actions.js'; -import { repoRoot } from '../../helpers/root.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { + collectUnknownActionCovers, + createScriptCoverageActionsFixture +} from './coverage-fixture.js'; -const root = repoRoot(); -const fixtureRoot = path.join(root, 'tests', 'fixtures', 'sample'); -const baseCacheRoot = await makeTempDir('pairofcleats-script-coverage-'); -const mergeDir = path.join(baseCacheRoot, 'merge'); -await fsPromises.mkdir(mergeDir, { recursive: true }); - -const scripts = JSON.parse(await fsPromises.readFile(path.join(root, 'package.json'), 'utf8')).scripts || {}; -const scriptNames = new Set(Object.keys(scripts)); - -const actions = await buildActions({ - root, - fixtureRoot, - repoEnv: { ...process.env }, - baseCacheRoot, - mergeDir, - runNode: () => {}, - scriptNames -}); - -const unknown = new Set(); -for (const action of actions) { - for (const key of ['covers', 'coversTierB']) { - const values = Array.isArray(action[key]) ? action[key] : []; - for (const name of values) { - if (!scriptNames.has(name)) unknown.add(name); - } - } -} +const { actions, scriptNames, cleanup } = await createScriptCoverageActionsFixture(); +const unknown = collectUnknownActionCovers(actions, scriptNames); if (unknown.size) { console.error(`script coverage wiring invalid: ${Array.from(unknown).sort().join(', ')}`); process.exit(1); } -await rmDirRecursive(baseCacheRoot); +await cleanup(); console.log('script coverage wiring test passed'); diff --git a/tests/tooling/service/indexer-config-mtime-import.test.js b/tests/tooling/service/indexer-config-mtime-import.test.js new file mode 100644 index 000000000..d538523c9 --- /dev/null +++ b/tests/tooling/service/indexer-config-mtime-import.test.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const sourcePath = path.join(root, 'tools', 'service', 'indexer-service.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const helperPath = path.join(root, 'tools', 'service', 'indexer-service-helpers.js'); +const helperSource = fs.readFileSync(helperPath, 'utf8'); + +assert.match( + source, + /createServiceRuntimeEnvResolver/, + 'expected indexer-service to create the service runtime env resolver' +); +assert.match( + helperSource, + /import\s+fs\s+from\s+'node:fs';/, + 'expected indexer-service helper to import node:fs for config mtime checks' +); +assert.match( + helperSource, + /\bfs\.statSync\(/, + 'expected indexer-service helper to use fs.statSync for runtime config cache invalidation' +); +assert.match( + source, + /exitLikeCommandResult\(\{\s*status:\s*result\.exitCode,\s*signal:\s*result\.signal\s*\}\)/, + 'expected indexer-service serve mode to preserve signal-based child exits' +); + +console.log('indexer service config mtime import contract test passed'); diff --git a/tests/tooling/service/indexer-job-completion-replay.test.js b/tests/tooling/service/indexer-job-completion-replay.test.js new file mode 100644 index 000000000..664c94a5e --- /dev/null +++ b/tests/tooling/service/indexer-job-completion-replay.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createJobCompletion } from '../../../tools/service/indexer-service/job-completion.js'; + +const completions = []; +const quarantines = []; +const metrics = { processed: 0, succeeded: 0, failed: 0, retried: 0 }; + +const completion = createJobCompletion({ + queueDir: '/tmp/queue', + resolvedQueueName: 'embeddings', + queueMaxRetries: 0, + completeJob: async (queueDir, jobId, status, result, queueName, options) => { + completions.push({ queueDir, jobId, status, result, queueName, options }); + }, + quarantineJob: async (queueDir, jobId, reason, queueName, options) => { + quarantines.push({ queueDir, jobId, reason, queueName, options }); + } +}); + +const replay = { + version: 1, + repair: { + repaired: true, + actions: [{ type: 'remove-backend-stage-dir' }] + }, + current: { + partialDurableState: true + } +}; + +await completion.finalizeJobRun({ + job: { + id: 'embed-cancelled', + attempts: 0, + maxRetries: 0, + lease: { owner: 'worker-1', version: 3 } + }, + runResult: { + exitCode: 130, + signal: null, + executionMode: 'subprocess', + executionClass: 'daemon-governed', + cancelled: true, + shutdownMode: 'cancel', + governance: { + policy: 'daemon', + decision: 'subprocess-fallback', + reason: 'daemon-failure-burst', + sessionKey: 'daemon-session-1', + sessionEpoch: 1, + recycleCount: 1, + subprocessCooldownRemaining: 0 + }, + replay + }, + metrics +}); +assert.equal(completions[0]?.status, 'queued'); +assert.deepEqual(completions[0]?.result?.replay, replay, 'expected cancelled retry payload to preserve replay metadata'); +assert.equal(completions[0]?.result?.executionClass, 'daemon-governed'); +assert.equal(completions[0]?.result?.governance?.decision, 'subprocess-fallback'); + +await completion.finalizeJobRun({ + job: { + id: 'embed-failed', + attempts: 0, + maxRetries: 0, + lease: { owner: 'worker-2', version: 4 } + }, + runResult: { + exitCode: 1, + signal: null, + executionMode: 'subprocess', + executionClass: 'daemon-governed', + cancelled: false, + governance: { + policy: 'daemon', + decision: 'subprocess-fallback', + reason: 'daemon-failure-burst', + sessionKey: 'daemon-session-1', + sessionEpoch: 1, + recycleCount: 1, + subprocessCooldownRemaining: 0 + }, + replay + }, + metrics +}); +assert.equal(quarantines[0]?.reason, 'retry-exhausted'); +assert.deepEqual(quarantines[0]?.options?.result?.replay, replay, 'expected quarantine payload to preserve replay metadata'); +assert.equal(quarantines[0]?.options?.result?.executionClass, 'daemon-governed'); +assert.equal(quarantines[0]?.options?.result?.governance?.decision, 'subprocess-fallback'); + +console.log('indexer service job-completion replay test passed'); diff --git a/tests/tooling/service/indexer-job-completion-signal.test.js b/tests/tooling/service/indexer-job-completion-signal.test.js new file mode 100644 index 000000000..1c3024d8b --- /dev/null +++ b/tests/tooling/service/indexer-job-completion-signal.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createJobCompletion } from '../../../tools/service/indexer-service/job-completion.js'; + +const calls = []; +const quarantineCalls = []; +const metrics = { processed: 0, succeeded: 0, failed: 0, retried: 0 }; + +const completion = createJobCompletion({ + queueDir: '/tmp/queue', + resolvedQueueName: 'index', + queueMaxRetries: 1, + completeJob: async (queueDir, jobId, status, result, queueName) => { + calls.push({ queueDir, jobId, status, result, queueName }); + }, + quarantineJob: async (queueDir, jobId, reason, queueName, options) => { + quarantineCalls.push({ queueDir, jobId, reason, queueName, options }); + } +}); + +const normalizedSignalFailure = completion.normalizeRunResult({ + exitCode: 1, + signal: 'SIGTERM', + executionMode: 'subprocess', + executionClass: 'subprocess-isolated' +}); +assert.equal(normalizedSignalFailure.status, 'failed'); +assert.equal(normalizedSignalFailure.signal, 'SIGTERM'); +assert.equal(normalizedSignalFailure.executionClass, 'subprocess-isolated'); + +await completion.finalizeJobRun({ + job: { id: 'job-1', attempts: 0, maxRetries: 0 }, + runResult: { exitCode: 1, signal: 'SIGTERM', executionMode: 'subprocess' }, + metrics +}); +assert.equal(quarantineCalls[0].reason, 'retry-exhausted'); +assert.equal(quarantineCalls[0].options.result.signal, 'SIGTERM'); +assert.equal(quarantineCalls[0].options.result.error, 'signal SIGTERM'); +assert.equal(quarantineCalls[0].options.result.executionClass, 'subprocess-isolated'); +assert.equal(metrics.failed, 1); + +calls.length = 0; +quarantineCalls.length = 0; +await completion.finalizeJobRun({ + job: { id: 'job-2', attempts: 0, maxRetries: 2 }, + runResult: { exitCode: 1, signal: 'SIGINT', executionMode: 'subprocess' }, + metrics +}); +assert.equal(calls[0].status, 'queued'); +assert.equal(calls[0].result.retry, true); +assert.equal(calls[0].result.signal, 'SIGINT'); +assert.equal(calls[0].result.error, 'signal SIGINT'); +assert.equal(calls[0].result.executionClass, 'subprocess-isolated'); +assert.equal(metrics.retried, 1); + +const normalizedSuccess = completion.normalizeRunResult({ + exitCode: 0, + signal: null, + executionMode: 'daemon', + executionClass: 'daemon-governed', + governance: { + policy: 'daemon', + decision: 'daemon', + sessionKey: 'daemon-session-1', + sessionEpoch: 1, + recycleCount: 2, + subprocessCooldownRemaining: 0 + } +}); +assert.equal(normalizedSuccess.status, 'done'); +assert.equal(normalizedSuccess.signal, null); +assert.equal(normalizedSuccess.executionClass, 'daemon-governed'); +assert.equal(normalizedSuccess.governance?.sessionKey, 'daemon-session-1'); + +const normalizedStringExit = completion.normalizeRunResult({ + exitCode: '2', + signal: null, + executionMode: 'subprocess' +}); +assert.equal(normalizedStringExit.exitCode, 2); +assert.equal(normalizedStringExit.status, 'failed'); + +calls.length = 0; +await completion.finalizeJobRun({ + job: { id: 'job-3', attempts: 0, maxRetries: 0 }, + runResult: { exitCode: 0, signal: null, executionMode: 'daemon' }, + metrics +}); +assert.equal(calls[0].status, 'done'); +assert.equal(calls[0].result.error, null, 'successful jobs should not emit failure error strings'); +assert.equal(calls[0].result.executionClass, 'daemon-governed'); +assert.equal(metrics.succeeded, 1); + +calls.length = 0; +quarantineCalls.length = 0; +await completion.finalizeJobRun({ + job: { id: 'job-4', attempts: '1', maxRetries: '2' }, + runResult: { exitCode: 1, signal: null, executionMode: 'subprocess' }, + metrics +}); +assert.equal(calls[0].status, 'queued', 'string attempts/maxRetries should be parsed numerically'); +assert.equal(calls[0].result.attempts, 2); + +console.log('indexer service job-completion signal test passed'); diff --git a/tests/tooling/service/indexer-job-executor-daemon-governance.test.js b/tests/tooling/service/indexer-job-executor-daemon-governance.test.js new file mode 100644 index 000000000..c0f66f193 --- /dev/null +++ b/tests/tooling/service/indexer-job-executor-daemon-governance.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createJobExecutor } from '../../../tools/service/indexer-service/job-executor.js'; + +const daemonRuns = []; +const subprocessRuns = []; + +const daemonResults = [ + { exitCode: 1, signal: null, executionMode: 'daemon', cancelled: false, shutdownMode: null, daemon: { sessionKey: 'daemon-session-0' } }, + { exitCode: 1, signal: null, executionMode: 'daemon', cancelled: false, shutdownMode: null, daemon: { sessionKey: 'daemon-session-0' } }, + { exitCode: 0, signal: null, executionMode: 'daemon', cancelled: false, shutdownMode: null, daemon: { sessionKey: 'daemon-session-1' } } +]; +const subprocessResults = [ + { exitCode: 0, signal: null, cancelled: false, errorCode: null, errorMessage: null } +]; + +const executor = createJobExecutor({ + isEmbeddingsQueue: false, + serviceExecutionMode: 'daemon', + daemonWorkerConfig: { + deterministic: true, + sessionNamespace: 'svc-indexer', + governance: { + maxConsecutiveFailures: 2, + subprocessCooldownJobs: 1 + } + }, + resolvedQueueName: 'index', + embeddingExtraEnv: {}, + resolveRepoRuntimeEnv: () => ({}), + toolRoot: process.cwd(), + completeNonRetriableFailure: async () => {}, + runBuildIndexDaemonImpl: async (repoPath, mode, stage, extraArgs, logPath, daemonOptions) => { + daemonRuns.push({ repoPath, mode, stage, extraArgs, logPath, daemonOptions }); + const next = daemonResults.shift(); + if (!next) throw new Error('unexpected daemon invocation'); + return { + ...next, + daemon: { + ...next.daemon, + deterministic: daemonOptions?.deterministic !== false + } + }; + }, + runBuildIndexSubprocessImpl: async (repoPath, mode, stage, extraArgs, logPath) => { + subprocessRuns.push({ repoPath, mode, stage, extraArgs, logPath }); + const next = subprocessResults.shift(); + if (!next) throw new Error('unexpected subprocess invocation'); + return next; + } +}); + +const fakeLifecycle = { + async registerPromise(promise) { + return await promise; + } +}; + +const job = { + id: 'job-daemon', + repo: '/tmp/daemon-repo', + mode: 'code', + stage: 'stage1', + args: ['--repo', '/tmp/daemon-repo', '--mode', 'code'] +}; + +const first = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-0.log' +}); +assert.equal(first.runResult.executionMode, 'daemon'); +assert.equal(first.runResult.executionClass, 'daemon-governed'); +assert.equal(first.runResult.governance?.decision, 'daemon'); +assert.equal(first.runResult.governance?.recycleRequested, false); +assert.equal(first.runResult.governance?.sessionEpoch, 0); + +const second = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-1.log' +}); +assert.equal(second.runResult.executionMode, 'daemon'); +assert.equal(second.runResult.governance?.decision, 'daemon'); +assert.equal(second.runResult.governance?.recycleRequested, true); +assert.equal(second.runResult.governance?.nextSessionEpoch, 1); +assert.equal(second.runResult.daemon?.recycleRequested, true); + +const third = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/subprocess-fallback.log' +}); +assert.equal(third.runResult.executionMode, 'subprocess'); +assert.equal(third.runResult.executionClass, 'daemon-governed'); +assert.equal(third.runResult.governance?.decision, 'subprocess-fallback'); +assert.equal(third.runResult.governance?.cooldownBeforeJob, 1); +assert.equal(third.runResult.governance?.subprocessCooldownRemaining, 0); +assert.equal(third.runResult.daemon?.fallback, true); +assert.equal(subprocessRuns.length, 1); + +const fourth = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-2.log' +}); +assert.equal(fourth.runResult.executionMode, 'daemon'); +assert.equal(fourth.runResult.executionClass, 'daemon-governed'); +assert.equal(fourth.runResult.governance?.sessionEpoch, 1); +assert.equal(fourth.runResult.governance?.recycleCount, 1); +assert.equal(daemonRuns[2]?.daemonOptions?.sessionNamespace, 'svc-indexer:epoch-1'); + +console.log('indexer service job-executor daemon governance test passed'); diff --git a/tests/tooling/service/indexer-job-executor-daemon-session-budget.test.js b/tests/tooling/service/indexer-job-executor-daemon-session-budget.test.js new file mode 100644 index 000000000..481144f31 --- /dev/null +++ b/tests/tooling/service/indexer-job-executor-daemon-session-budget.test.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { createJobExecutor } from '../../../tools/service/indexer-service/job-executor.js'; + +const daemonRuns = []; + +const executor = createJobExecutor({ + isEmbeddingsQueue: false, + serviceExecutionMode: 'daemon', + daemonWorkerConfig: { + deterministic: true, + sessionNamespace: 'svc-indexer', + governance: { + maxConsecutiveFailures: 3, + maxJobsPerSession: 2, + subprocessCooldownJobs: 1 + } + }, + resolvedQueueName: 'index', + embeddingExtraEnv: {}, + resolveRepoRuntimeEnv: () => ({}), + toolRoot: process.cwd(), + completeNonRetriableFailure: async () => {}, + runBuildIndexDaemonImpl: async (repoPath, mode, stage, extraArgs, logPath, daemonOptions) => { + daemonRuns.push({ repoPath, mode, stage, extraArgs, logPath, daemonOptions }); + const sessionNamespace = daemonOptions?.sessionNamespace || 'missing'; + return { + exitCode: 0, + signal: null, + executionMode: 'daemon', + cancelled: false, + shutdownMode: null, + daemon: { + sessionKey: `daemon:${sessionNamespace}` + } + }; + } +}); + +const fakeLifecycle = { + async registerPromise(promise) { + return await promise; + } +}; + +const job = { + id: 'job-daemon-budget', + repo: '/tmp/daemon-budget-repo', + mode: 'code', + stage: 'stage1', + args: ['--repo', '/tmp/daemon-budget-repo', '--mode', 'code'] +}; + +const first = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-budget-0.log' +}); +assert.equal(first.runResult.executionMode, 'daemon'); +assert.equal(first.runResult.governance?.recycleRequested, false); +assert.equal(first.runResult.governance?.sessionEpoch, 0); +assert.equal(first.runResult.governance?.sessionJobCount, 1); +assert.equal(first.runResult.governance?.nextSessionJobCount, 1); +assert.equal(first.runResult.governance?.maxJobsPerSession, 2); + +const second = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-budget-1.log' +}); +assert.equal(second.runResult.executionMode, 'daemon'); +assert.equal(second.runResult.governance?.recycleRequested, true); +assert.equal(second.runResult.governance?.reason, 'daemon-session-job-budget'); +assert.equal(second.runResult.governance?.sessionEpoch, 0); +assert.equal(second.runResult.governance?.nextSessionEpoch, 1); +assert.equal(second.runResult.governance?.sessionJobCount, 2); +assert.equal(second.runResult.governance?.nextSessionJobCount, 0); +assert.equal(second.runResult.daemon?.recycleReason, 'daemon-session-job-budget'); +assert.equal(second.runResult.daemon?.sessionJobCount, 2); + +const third = await executor.executeClaimedJob({ + job, + jobLifecycle: fakeLifecycle, + logPath: '/tmp/daemon-budget-2.log' +}); +assert.equal(third.runResult.executionMode, 'daemon'); +assert.equal(third.runResult.governance?.sessionEpoch, 1); +assert.equal(third.runResult.governance?.sessionJobCount, 1); +assert.equal(third.runResult.governance?.recycleCount, 1); +assert.equal(daemonRuns[2]?.daemonOptions?.sessionNamespace, 'svc-indexer:epoch-1'); + +console.log('indexer service daemon session budget test passed'); diff --git a/tests/tooling/service/indexer-job-executor-embedding-guards.test.js b/tests/tooling/service/indexer-job-executor-embedding-guards.test.js new file mode 100644 index 000000000..c41a53c65 --- /dev/null +++ b/tests/tooling/service/indexer-job-executor-embedding-guards.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createJobExecutor } from '../../../tools/service/indexer-service/job-executor.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-job-executor-')); +const buildRoot = path.join(tempRoot, 'build'); +await fs.mkdir(buildRoot, { recursive: true }); + +const nonRetriableCalls = []; +const baseExecutorInput = { + isEmbeddingsQueue: true, + serviceExecutionMode: 'subprocess', + daemonWorkerConfig: {}, + resolvedQueueName: 'embeddings', + embeddingExtraEnv: {}, + resolveRepoRuntimeEnv: () => ({}), + completeNonRetriableFailure: async (job, error) => { + nonRetriableCalls.push({ jobId: job.id, error }); + } +}; +const executor = createJobExecutor({ + ...baseExecutorInput, + toolRoot: process.cwd() +}); + +const result = await executor.executeClaimedJob({ + job: { + id: 'job-1', + repo: null, + repoRoot: null, + buildRoot, + mode: 'code' + }, + jobLifecycle: { + registerPromise: async (promise) => promise + }, + logPath: null +}); + +assert.equal(result.handled, true, 'missing repo path should be handled as non-retriable'); +assert.equal(nonRetriableCalls.length, 1); +assert.equal(nonRetriableCalls[0].jobId, 'job-1'); +assert.equal(nonRetriableCalls[0].error, 'missing repo path for embedding job'); + +const toolRoot = path.join(tempRoot, 'tool-root'); +const fakeEmbeddingsScript = path.join(toolRoot, 'tools', 'build', 'embeddings.js'); +const indexDir = path.join(buildRoot, 'index-code'); +const backendStageDir = path.join(buildRoot, '.embeddings-backend-staging', 'index-code'); +await fs.mkdir(path.dirname(fakeEmbeddingsScript), { recursive: true }); +await fs.mkdir(path.join(indexDir, 'pieces'), { recursive: true }); +await fs.mkdir(backendStageDir, { recursive: true }); +await fs.writeFile(fakeEmbeddingsScript, 'process.exit(0);\n'); +await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ + generatedAt: '2026-03-18T00:00:00.000Z', + updatedAt: '2026-03-18T00:00:00.000Z', + embeddings: { + pending: true, + ready: false + } +}, null, 2)); +await fs.writeFile(path.join(indexDir, 'dense_vectors_uint8.bin'), 'artifact'); +await fs.writeFile(path.join(indexDir, 'pieces', 'manifest.json'), JSON.stringify({ ok: true }, null, 2)); + +const replayExecutor = createJobExecutor({ + ...baseExecutorInput, + toolRoot +}); + +const validResult = await replayExecutor.executeClaimedJob({ + job: { + id: 'job-2', + repo: tempRoot, + repoRoot: tempRoot, + buildRoot, + indexDir, + mode: 'code', + embeddingPayloadFormatVersion: 2 + }, + jobLifecycle: { + registerPromise: async (promise) => promise + }, + logPath: path.join(tempRoot, 'embeddings.log') +}); + +assert.equal(validResult.handled, false, 'expected runnable embeddings job to fall through to subprocess execution'); +assert.equal(validResult.runResult?.exitCode, 0); +assert.equal(validResult.runResult?.replay?.repair?.repaired, true, 'expected executor to surface replay repair metadata'); +assert.equal(validResult.runResult?.replay?.current?.backendStage?.exists, false, 'expected stale backend stage to be repaired before execution'); +assert.equal(validResult.runResult?.replay?.current?.embeddings?.pending, false, 'expected repaired replay state to clear stale pending bit'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('indexer service job-executor embedding guards test passed'); diff --git a/tests/tooling/service/indexer-service-queue-worker-contract.test.js b/tests/tooling/service/indexer-queue-worker-contract.test.js similarity index 100% rename from tests/tooling/service/indexer-service-queue-worker-contract.test.js rename to tests/tooling/service/indexer-queue-worker-contract.test.js diff --git a/tests/tooling/service/indexer-runtime-env-helper.test.js b/tests/tooling/service/indexer-runtime-env-helper.test.js new file mode 100644 index 000000000..2843a9617 --- /dev/null +++ b/tests/tooling/service/indexer-runtime-env-helper.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + createServiceRuntimeEnvResolver, + logThreadpoolInfo, + normalizeRuntimeConfigCacheKey, + readRepoConfigMtime +} from '../../../tools/service/indexer-service-helpers.js'; + +const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-indexer-runtime-env-')); +await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + runtime: { + uvThreadpoolSize: 7, + nodeOptions: '--trace-warnings', + maxOldSpaceMb: 768 + } +}), 'utf8'); + +const cacheKey = normalizeRuntimeConfigCacheKey(repoRoot); +assert.equal( + cacheKey, + process.platform === 'win32' ? path.resolve(repoRoot).toLowerCase() : path.resolve(repoRoot) +); + +const mtime = readRepoConfigMtime(repoRoot); +assert.equal(mtime.configPath, path.join(repoRoot, '.pairofcleats.json')); +assert.equal(Number.isFinite(mtime.mtimeMs), true); + +const resolver = createServiceRuntimeEnvResolver({ + baseEnv: { + PATH: 'test-path' + } +}); +const runtimeEnv = resolver.resolveRepoRuntimeEnv(repoRoot); +assert.equal(runtimeEnv.PATH, 'test-path'); +assert.equal(runtimeEnv.UV_THREADPOOL_SIZE, '7'); + +const overriddenEnv = resolver.resolveRepoRuntimeEnv(repoRoot, { + UV_THREADPOOL_SIZE: '3', + NODE_OPTIONS: '--enable-source-maps', + POC_RUNTIME_MARKER: 'extra' +}); +assert.equal(overriddenEnv.UV_THREADPOOL_SIZE, '7'); +assert.equal(overriddenEnv.POC_RUNTIME_MARKER, 'extra'); + +const errors = []; +const originalError = console.error; +console.error = (line) => { + errors.push(String(line)); +}; +try { + logThreadpoolInfo(repoRoot, 'indexer-test', { + UV_THREADPOOL_SIZE: '7' + }); +} finally { + console.error = originalError; +} +assert.ok( + errors.some((line) => line.includes('[indexer-test] UV_THREADPOOL_SIZE=7')), + 'expected service threadpool diagnostics to report the effective UV threadpool size' +); + +console.log('indexer runtime env helper test passed'); diff --git a/tests/tooling/service/indexer-service-config-mtime-import.test.js b/tests/tooling/service/indexer-service-config-mtime-import.test.js deleted file mode 100644 index 95e7ff5f0..000000000 --- a/tests/tooling/service/indexer-service-config-mtime-import.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -const root = process.cwd(); -const sourcePath = path.join(root, 'tools', 'service', 'indexer-service.js'); -const source = fs.readFileSync(sourcePath, 'utf8'); - -assert.match( - source, - /import\s+fs\s+from\s+'node:fs';/, - 'expected indexer-service to import node:fs for config mtime checks' -); -assert.match( - source, - /\bfs\.statSync\(/, - 'expected indexer-service to use fs.statSync for runtime config cache invalidation' -); -assert.match( - source, - /exitLikeCommandResult\(\{\s*status:\s*result\.exitCode,\s*signal:\s*result\.signal\s*\}\)/, - 'expected indexer-service serve mode to preserve signal-based child exits' -); - -console.log('indexer service config mtime import contract test passed'); diff --git a/tests/tooling/service/indexer-service-job-completion-signal.test.js b/tests/tooling/service/indexer-service-job-completion-signal.test.js deleted file mode 100644 index 6ec547e60..000000000 --- a/tests/tooling/service/indexer-service-job-completion-signal.test.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { createJobCompletion } from '../../../tools/service/indexer-service/job-completion.js'; - -const calls = []; -const metrics = { processed: 0, succeeded: 0, failed: 0, retried: 0 }; - -const completion = createJobCompletion({ - queueDir: '/tmp/queue', - resolvedQueueName: 'index', - queueMaxRetries: 1, - completeJob: async (queueDir, jobId, status, result, queueName) => { - calls.push({ queueDir, jobId, status, result, queueName }); - } -}); - -const normalizedSignalFailure = completion.normalizeRunResult({ - exitCode: 1, - signal: 'SIGTERM', - executionMode: 'subprocess' -}); -assert.equal(normalizedSignalFailure.status, 'failed'); -assert.equal(normalizedSignalFailure.signal, 'SIGTERM'); - -await completion.finalizeJobRun({ - job: { id: 'job-1', attempts: 0, maxRetries: 0 }, - runResult: { exitCode: 1, signal: 'SIGTERM', executionMode: 'subprocess' }, - metrics -}); -assert.equal(calls[0].status, 'failed'); -assert.equal(calls[0].result.signal, 'SIGTERM'); -assert.equal(calls[0].result.error, 'signal SIGTERM'); -assert.equal(metrics.failed, 1); - -calls.length = 0; -await completion.finalizeJobRun({ - job: { id: 'job-2', attempts: 0, maxRetries: 2 }, - runResult: { exitCode: 1, signal: 'SIGINT', executionMode: 'subprocess' }, - metrics -}); -assert.equal(calls[0].status, 'queued'); -assert.equal(calls[0].result.retry, true); -assert.equal(calls[0].result.signal, 'SIGINT'); -assert.equal(calls[0].result.error, 'signal SIGINT'); -assert.equal(metrics.retried, 1); - -const normalizedSuccess = completion.normalizeRunResult({ - exitCode: 0, - signal: null, - executionMode: 'daemon' -}); -assert.equal(normalizedSuccess.status, 'done'); -assert.equal(normalizedSuccess.signal, null); - -const normalizedStringExit = completion.normalizeRunResult({ - exitCode: '2', - signal: null, - executionMode: 'subprocess' -}); -assert.equal(normalizedStringExit.exitCode, 2); -assert.equal(normalizedStringExit.status, 'failed'); - -calls.length = 0; -await completion.finalizeJobRun({ - job: { id: 'job-3', attempts: 0, maxRetries: 0 }, - runResult: { exitCode: 0, signal: null, executionMode: 'daemon' }, - metrics -}); -assert.equal(calls[0].status, 'done'); -assert.equal(calls[0].result.error, null, 'successful jobs should not emit failure error strings'); -assert.equal(metrics.succeeded, 1); - -calls.length = 0; -await completion.finalizeJobRun({ - job: { id: 'job-4', attempts: '1', maxRetries: '2' }, - runResult: { exitCode: 1, signal: null, executionMode: 'subprocess' }, - metrics -}); -assert.equal(calls[0].status, 'queued', 'string attempts/maxRetries should be parsed numerically'); -assert.equal(calls[0].result.attempts, 2); - -console.log('indexer service job-completion signal test passed'); diff --git a/tests/tooling/service/indexer-service-job-executor-embedding-guards.test.js b/tests/tooling/service/indexer-service-job-executor-embedding-guards.test.js deleted file mode 100644 index 43f5d96db..000000000 --- a/tests/tooling/service/indexer-service-job-executor-embedding-guards.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createJobExecutor } from '../../../tools/service/indexer-service/job-executor.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-job-executor-')); -const buildRoot = path.join(tempRoot, 'build'); -await fs.mkdir(buildRoot, { recursive: true }); - -const nonRetriableCalls = []; -const executor = createJobExecutor({ - isEmbeddingsQueue: true, - serviceExecutionMode: 'subprocess', - daemonWorkerConfig: {}, - resolvedQueueName: 'embeddings', - embeddingExtraEnv: {}, - resolveRepoRuntimeEnv: () => ({}), - toolRoot: process.cwd(), - completeNonRetriableFailure: async (job, error) => { - nonRetriableCalls.push({ jobId: job.id, error }); - } -}); - -const result = await executor.executeClaimedJob({ - job: { - id: 'job-1', - repo: null, - repoRoot: null, - buildRoot, - mode: 'code' - }, - jobLifecycle: { - registerPromise: async (promise) => promise - }, - logPath: null -}); - -assert.equal(result.handled, true, 'missing repo path should be handled as non-retriable'); -assert.equal(nonRetriableCalls.length, 1); -assert.equal(nonRetriableCalls[0].jobId, 'job-1'); -assert.equal(nonRetriableCalls[0].error, 'missing repo path for embedding job'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('indexer service job-executor embedding guards test passed'); diff --git a/tests/tooling/service/service-config-validation.test.js b/tests/tooling/service/service-config-validation.test.js new file mode 100644 index 000000000..c16aef028 --- /dev/null +++ b/tests/tooling/service/service-config-validation.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { buildDiagnosticsReport } from '../../../tools/reports/diagnostics-report.js'; +import { loadServiceConfig } from '../../../tools/service/config.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +applyTestEnv(); + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'service-config-validation'); +fs.rmSync(tempRoot, { recursive: true, force: true }); +fs.mkdirSync(tempRoot, { recursive: true }); + +const validConfigPath = path.join(tempRoot, 'valid-service.json'); +const invalidConfigPath = path.join(tempRoot, 'invalid-service.json'); +const indexerScriptPath = path.join(root, 'tools', 'service', 'indexer-service.js'); + +fs.writeFileSync(validConfigPath, `${JSON.stringify({ + queueDir: path.join(tempRoot, 'queue'), + queue: { + maxQueued: '4', + maxRetries: '2' + }, + worker: { + concurrency: '0', + shutdownTimeoutMs: '500' + }, + embeddings: { + queue: { + maxQueued: '3' + }, + worker: { + concurrency: '1', + maxMemoryMb: '2048', + shutdownTimeoutMs: '500' + } + }, + sync: { + policy: 'fetch', + intervalMs: '60000' + }, + security: { + allowShell: false, + allowPathEscape: false + } +}, null, 2)}\n`); + +fs.writeFileSync(invalidConfigPath, `${JSON.stringify({ + queue: { + maxQueued: -1 + }, + sync: { + policy: 'fetch' + } +}, null, 2)}\n`); + +const loaded = loadServiceConfig(validConfigPath); +assert.equal(typeof loaded.queue.maxQueued, 'number', 'expected queue.maxQueued to be coerced to a number'); +assert.equal(loaded.queue.maxQueued, 4, 'expected maxQueued string to be normalized'); +assert.equal(loaded.worker.concurrency, 0, 'expected explicit zero concurrency to survive service-config loading'); +assert.equal(typeof loaded.worker.concurrency, 'number', 'expected worker concurrency to be numeric after load'); +assert.equal(typeof loaded.embeddings.worker.maxMemoryMb, 'number', 'expected embeddings maxMemoryMb to be numeric after load'); +assert.equal(typeof loaded.sync.intervalMs, 'number', 'expected sync interval to be numeric after load'); +assert.equal(loaded.sync.policy, 'fetch', 'expected valid sync policy to load'); + +assert.throws( + () => loadServiceConfig(invalidConfigPath), + /queue\.maxQueued/, + 'expected invalid queue limit to fail with field path' +); + +await assert.rejects( + () => buildDiagnosticsReport({ + reportKinds: 'queue-health', + configPath: invalidConfigPath + }), + /queue\.maxQueued/, + 'expected diagnostics report to surface invalid service config' +); + +const cliRun = runNode( + [indexerScriptPath, 'status', '--config', invalidConfigPath, '--json'], + 'indexer service invalid config', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +assert.notEqual(cliRun.status, 0, 'expected invalid service config to fail the CLI'); +const payload = JSON.parse(String(cliRun.stdout || '').trim()); +assert.equal(payload.ok, false, 'expected JSON bootstrap error payload'); +assert.equal(payload.code, 'INVALID_REQUEST', 'expected invalid-request error code'); +assert.equal(payload.fieldPath, 'queue.maxQueued', 'expected offending field path'); +assert.equal(typeof payload.hint, 'string', 'expected actionable hint'); + +console.log('service config validation test passed'); diff --git a/tests/tooling/service/subprocess-buffer-bounds.test.js b/tests/tooling/service/subprocess-buffer-bounds.test.js deleted file mode 100644 index 4246492ad..000000000 --- a/tests/tooling/service/subprocess-buffer-bounds.test.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { runLoggedSubprocess } from '../../../tools/service/subprocess-log.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-subprocess-bounds-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const boundedLogPath = path.join(tempRoot, 'bounded.log'); -const bounded = await runLoggedSubprocess({ - command: process.execPath, - args: ['-e', "process.stdout.write('x'.repeat(4096));"], - logPath: boundedLogPath, - maxOutputBytes: 64 -}); - -assert.equal(bounded.exitCode, 0); -assert.equal(bounded.stdoutBytes, 4096); - -const boundedLog = await fs.readFile(boundedLogPath, 'utf8'); -assert.match(boundedLog, new RegExp(`maxCaptureBytes=${bounded.maxOutputBytes}`)); -const stdoutSection = boundedLog.match(/\[stdout\]\n([\s\S]*?)\n\[\/stdout\]/); -assert.ok(stdoutSection, 'expected stdout section in log'); -assert.ok( - Buffer.byteLength(stdoutSection[1], 'utf8') <= bounded.maxOutputBytes, - 'expected capped captured stdout' -); - -const timeoutLogPath = path.join(tempRoot, 'timeout.log'); -const timedOut = await runLoggedSubprocess({ - command: process.execPath, - args: ['-e', "setTimeout(() => process.stdout.write('late'), 2000);"], - logPath: timeoutLogPath, - timeoutMs: 1000 -}); - -assert.equal(timedOut.timedOut, true, 'expected timeout to be reported'); -assert.equal(timedOut.exitCode, 1); -assert.ok(Number.isFinite(timedOut.logBytesWritten) && timedOut.logBytesWritten > 0); - -const timeoutLog = await fs.readFile(timeoutLogPath, 'utf8'); -assert.match(timeoutLog, /job timeout/); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('service subprocess buffer bounds test passed'); diff --git a/tests/tooling/service/subprocess-cancellation-contract.test.js b/tests/tooling/service/subprocess-cancellation-contract.test.js deleted file mode 100644 index b6d6ca5f2..000000000 --- a/tests/tooling/service/subprocess-cancellation-contract.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { runLoggedSubprocess } from '../../../tools/service/subprocess-log.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-subprocess-cancel-')); -applyTestEnv({ cacheRoot: tempRoot }); - -const logPath = path.join(tempRoot, 'cancel.log'); -const controller = new AbortController(); -setTimeout(() => controller.abort(), 100); - -const result = await runLoggedSubprocess({ - command: process.execPath, - args: ['-e', "setTimeout(() => process.stdout.write('late'), 5000);"], - logPath, - signal: controller.signal, - timeoutMs: 5000 -}); - -assert.equal(result.exitCode, 1); -assert.equal(result.timedOut, false, 'abort should not be reported as timeout'); -assert.ok( - result.errorCode === 'SUBPROCESS_ABORT' || result.errorCode === 'ABORT_ERR', - `expected abort error code, got ${result.errorCode}` -); -assert.ok(Number.isFinite(result.logBytesWritten) && result.logBytesWritten > 0); - -const logText = await fs.readFile(logPath, 'utf8'); -assert.match(logText, /job error Operation aborted/); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('service subprocess cancellation contract test passed'); diff --git a/tests/tooling/service/subprocess-contract-matrix.test.js b/tests/tooling/service/subprocess-contract-matrix.test.js new file mode 100644 index 000000000..a584b4c08 --- /dev/null +++ b/tests/tooling/service/subprocess-contract-matrix.test.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { runLoggedSubprocess } from '../../../tools/service/subprocess-log.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-subprocess-contract-')); +applyTestEnv({ cacheRoot: tempRoot }); + +try { + const cancelLogPath = path.join(tempRoot, 'cancel.log'); + const cancelController = new AbortController(); + setTimeout(() => cancelController.abort(), 100); + const cancelled = await runLoggedSubprocess({ + command: process.execPath, + args: ['-e', "setTimeout(() => process.stdout.write('late'), 5000);"], + logPath: cancelLogPath, + signal: cancelController.signal, + timeoutMs: 5000 + }); + assert.equal(cancelled.exitCode, 1); + assert.equal(cancelled.timedOut, false); + assert.ok(cancelled.errorCode === 'SUBPROCESS_ABORT' || cancelled.errorCode === 'ABORT_ERR'); + assert.ok(Number.isFinite(cancelled.logBytesWritten) && cancelled.logBytesWritten > 0); + assert.match(await fs.readFile(cancelLogPath, 'utf8'), /job error Operation aborted/); + + const boundedLogPath = path.join(tempRoot, 'bounded.log'); + const bounded = await runLoggedSubprocess({ + command: process.execPath, + args: ['-e', "process.stdout.write('x'.repeat(4096));"], + logPath: boundedLogPath, + maxOutputBytes: 64 + }); + assert.equal(bounded.exitCode, 0); + assert.equal(bounded.stdoutBytes, 4096); + const boundedLog = await fs.readFile(boundedLogPath, 'utf8'); + assert.match(boundedLog, new RegExp(`maxCaptureBytes=${bounded.maxOutputBytes}`)); + const stdoutSection = boundedLog.match(/\[stdout\]\n([\s\S]*?)\n\[\/stdout\]/); + assert.ok(stdoutSection); + assert.ok(Buffer.byteLength(stdoutSection[1], 'utf8') <= bounded.maxOutputBytes); + + const timeoutLogPath = path.join(tempRoot, 'timeout.log'); + const timedOut = await runLoggedSubprocess({ + command: process.execPath, + args: ['-e', "setTimeout(() => process.stdout.write('late'), 2000);"], + logPath: timeoutLogPath, + timeoutMs: 1000 + }); + assert.equal(timedOut.timedOut, true); + assert.equal(timedOut.exitCode, 1); + assert.ok(Number.isFinite(timedOut.logBytesWritten) && timedOut.logBytesWritten > 0); + assert.match(await fs.readFile(timeoutLogPath, 'utf8'), /job timeout/); + + const signalLogPath = path.join(tempRoot, 'signal.log'); + const signaled = await runLoggedSubprocess({ + command: process.execPath, + args: ['-e', "process.stdout.write('ignored');"], + logPath: signalLogPath, + spawnSubprocessImpl: async () => ({ + exitCode: null, + signal: 'SIGINT', + durationMs: 11, + stdout: '', + stderr: 'interrupted' + }) + }); + assert.equal(signaled.exitCode, 1); + assert.equal(signaled.signal, 'SIGINT'); + assert.equal(signaled.timedOut, false); + assert.match(await fs.readFile(signalLogPath, 'utf8'), /job exit 1 signal=SIGINT/); + + console.log('tooling service subprocess contract matrix test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/service/subprocess-signal-contract.test.js b/tests/tooling/service/subprocess-signal-contract.test.js deleted file mode 100644 index e8b739629..000000000 --- a/tests/tooling/service/subprocess-signal-contract.test.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { runLoggedSubprocess } from '../../../tools/service/subprocess-log.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-subprocess-signal-')); -const logPath = path.join(tempRoot, 'signal.log'); - -const result = await runLoggedSubprocess({ - command: process.execPath, - args: ['-e', "process.stdout.write('ignored');"], - logPath, - spawnSubprocessImpl: async () => ({ - exitCode: null, - signal: 'SIGINT', - durationMs: 11, - stdout: '', - stderr: 'interrupted' - }) -}); - -assert.equal(result.exitCode, 1); -assert.equal(result.signal, 'SIGINT'); -assert.equal(result.timedOut, false); - -const logText = await fs.readFile(logPath, 'utf8'); -assert.match(logText, /job exit 1 signal=SIGINT/); - -await fs.rm(tempRoot, { recursive: true, force: true }); -console.log('service subprocess signal contract test passed'); diff --git a/tests/tooling/shared-adoption-contract.test.js b/tests/tooling/shared-adoption-contract.test.js new file mode 100644 index 000000000..d88d161c4 --- /dev/null +++ b/tests/tooling/shared-adoption-contract.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); + +const directExecutionTargets = [ + 'tools/index-snapshot.js', + 'tools/index-diff.js', + 'tools/workspace/status.js', + 'tools/workspace/manifest.js', + 'tools/workspace/build.js', + 'tools/build/embeddings.js', + 'tools/tooling/doctor.js', + 'tools/tooling/install-lua-language-server.js', + 'tools/ci/run-suite.js', + 'tools/config/contract-doc.js', + 'tools/index/validate.js', + 'tools/index/reconcile-identity.js', + 'tools/eval/risk-pack.js', + 'tools/mcp/server-sdk.js', + 'tools/reports/diagnostics-report.js', + 'tools/bench/query-generator.js', + 'tools/bench/graph-caps-harness.js', + 'tools/bench/graph/neighborhood-index-dir.js', + 'tools/bench/graph/context-pack-latency.js', + 'tools/analysis/delta-risk.js', + 'tools/analysis/explain-risk.js', + 'src/retrieval/cli.js', + 'src/integrations/tooling/suggest-tests.js', + 'src/integrations/tooling/impact.js', + 'src/integrations/tooling/graph-context.js', + 'src/integrations/tooling/context-pack.js', + 'src/integrations/tooling/architecture-check.js', + 'src/integrations/tooling/api-contracts.js' +]; + +const runtimeBootstrapTargets = [ + 'tools/analysis/map-iso-serve.js', + 'tools/bench/language-matrix.js', + 'tools/bench/embeddings/model-bakeoff.js' +]; + +const scanJsFiles = (relativeDir) => { + const absoluteDir = path.join(root, relativeDir); + const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + if (['.testLogs', '.testCache', 'fixtures', 'suggest-tests'].includes(entry.name)) continue; + files.push(...scanJsFiles(relativePath)); + continue; + } + if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(relativePath.split(path.sep).join('/')); + } + } + return files; +}; + +for (const relativePath of directExecutionTargets) { + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + assert.match( + source, + /\bisDirectExecution\s*\(/, + `${relativePath} should use shared direct execution detection` + ); + assert.doesNotMatch( + source, + /process\.argv\[1\]\s*===\s*fileURLToPath\(import\.meta\.url\)/, + `${relativePath} should not use symlink-unsafe direct execution guards` + ); +} + +for (const relativePath of runtimeBootstrapTargets) { + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + assert.match( + source, + /\bbootstrapRuntime\s*\(/, + `${relativePath} should use bootstrapRuntime for repo/runtime env shaping` + ); +} + +{ + const rootEnvImports = []; + const rootEnvImportPattern = /(?:\bfrom\s+|\bimport\s+|\bimport\s*\(\s*)['"][^'"]*shared\/env\.js['"]/; + for (const scanRoot of ['bin', 'src', 'tools', 'tests']) { + for (const relativePath of scanJsFiles(scanRoot)) { + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + if (rootEnvImportPattern.test(source)) { + rootEnvImports.push(relativePath); + } + } + } + assert.deepEqual( + rootEnvImports, + [], + 'internal callers should import src/shared/env leaf modules instead of the root facade' + ); +} + +console.log('shared adoption contract test passed'); diff --git a/tests/tooling/shared-module-cycles.test.js b/tests/tooling/shared-module-cycles.test.js new file mode 100644 index 000000000..5a28a4b79 --- /dev/null +++ b/tests/tooling/shared-module-cycles.test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { findSharedModuleCycles } from '../../tools/testing/shared-module-cycles.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'shared-module-cycles-')); + +try { + const sharedRoot = path.join(tempRoot, 'src', 'shared'); + await fs.mkdir(sharedRoot, { recursive: true }); + await fs.writeFile(path.join(sharedRoot, 'alpha.js'), "import './beta.js';\nexport const alpha = true;\n", 'utf8'); + await fs.writeFile(path.join(sharedRoot, 'beta.js'), "import './alpha.js';\nexport const beta = true;\n", 'utf8'); + await fs.writeFile(path.join(sharedRoot, 'gamma.js'), "export const gamma = true;\n", 'utf8'); + + const report = await findSharedModuleCycles({ + root: tempRoot, + roots: ['src/shared'] + }); + + assert.equal(report.fileCount, 3); + assert.equal(report.cycleCount, 1, 'expected one synthetic cycle'); + assert.deepEqual(report.cycles[0].nodes, ['src/shared/alpha.js', 'src/shared/beta.js']); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('shared module cycles test passed'); diff --git a/tests/tooling/shared-module-migration.test.js b/tests/tooling/shared-module-migration.test.js new file mode 100644 index 000000000..183d5d62d --- /dev/null +++ b/tests/tooling/shared-module-migration.test.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { runNode } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +const repoRoot = process.cwd(); +const toolPath = path.join(repoRoot, 'tools', 'testing', 'shared-module-migration.js'); + +const makeTempDir = async () => { + return await fsPromises.mkdtemp(path.join(os.tmpdir(), 'shared-module-migration-')); +}; + +const writeFixture = async (rootDir) => { + await fsPromises.mkdir(path.join(rootDir, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(rootDir, 'src', 'consumer.js'), + [ + "import { loadUserConfig } from 'tools/shared/dict-utils.js';", + "export { normalizeSearchRequest } from 'tools/shared/search-request.js';", + '', + 'void loadUserConfig;', + '' + ].join('\n'), + 'utf8' + ); + await fsPromises.writeFile( + path.join(rootDir, 'recipe.json'), + JSON.stringify( + { + schemaVersion: '1.0.0', + roots: ['src'], + recipes: [ + { + id: 'dict-utils-tools-to-src', + from: 'tools/shared/dict-utils.js', + to: 'src/shared/dict-utils.js', + roots: ['src'], + renames: {} + }, + { + id: 'search-request-tools-to-src', + from: 'tools/shared/search-request.js', + to: 'src/shared/search-request.js', + roots: ['src'], + renames: {} + } + ] + }, + null, + 2 + ), + 'utf8' + ); +}; + +const runTool = (cwd, args, { allowFailure = false } = {}) => runNode( + [toolPath, ...args], + 'shared-module migration tool', + cwd, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe', allowFailure } +); + +const tempRoot = await makeTempDir(); + +try { + await writeFixture(tempRoot); + + const dryRun = runTool(tempRoot, ['--recipe', 'recipe.json', '--json']); + assert.equal(dryRun.status, 0, dryRun.stderr); + const drySummary = JSON.parse(dryRun.stdout); + assert.equal(drySummary.filesChanged, 1, 'expected dry-run to report one changed file'); + assert.deepEqual( + drySummary.selectedRecipes, + ['dict-utils-tools-to-src', 'search-request-tools-to-src'], + 'expected both recipes to be selected' + ); + + const beforeWrite = fs.readFileSync(path.join(tempRoot, 'src', 'consumer.js'), 'utf8'); + assert.match(beforeWrite, /tools\/shared\/dict-utils\.js/, 'fixture should keep original import before write mode'); + + const checkRun = runTool(tempRoot, ['--recipe', 'recipe.json', '--check', '--json'], { allowFailure: true }); + assert.equal(checkRun.status, 1, 'check mode should fail when changes are pending'); + const checkSummary = JSON.parse(checkRun.stdout); + assert.equal(checkSummary.filesChanged, 1, 'check mode should still report pending changes'); + + const writeRun = runTool(tempRoot, ['--recipe', 'recipe.json', '--write', '--json']); + assert.equal(writeRun.status, 0, writeRun.stderr); + const writeSummary = JSON.parse(writeRun.stdout); + assert.equal(writeSummary.filesChanged, 1, 'write mode should update one file'); + + const afterWrite = fs.readFileSync(path.join(tempRoot, 'src', 'consumer.js'), 'utf8'); + assert.match(afterWrite, /src\/shared\/dict-utils\.js/, 'write mode should rewrite dict-utils import'); + assert.match(afterWrite, /src\/shared\/search-request\.js/, 'write mode should rewrite search-request export'); + assert.doesNotMatch(afterWrite, /tools\/shared\//, 'write mode should remove the legacy specifiers'); + + const cleanCheck = runTool(tempRoot, ['--recipe', 'recipe.json', '--check', '--json']); + assert.equal(cleanCheck.status, 0, cleanCheck.stderr); + const cleanSummary = JSON.parse(cleanCheck.stdout); + assert.equal(cleanSummary.filesChanged, 0, 'check mode should pass after write mode applies changes'); +} finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('shared-module migration tooling test passed'); diff --git a/tests/tooling/shared-module-performance.test.js b/tests/tooling/shared-module-performance.test.js new file mode 100644 index 000000000..1cbec9d82 --- /dev/null +++ b/tests/tooling/shared-module-performance.test.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { runNode } from '../helpers/run-node.js'; +import { applyTestEnv } from '../helpers/test-env.js'; + +const root = process.cwd(); +const toolPath = path.join(root, 'tools', 'testing', 'shared-module-performance.js'); + +const result = runNode( + [toolPath, '--check', '--json'], + 'shared-module performance check', + root, + applyTestEnv(), + { stdio: 'pipe', allowFailure: true } +); + +assert.equal(result.status, 0, result.stderr || result.stdout); + +const payload = JSON.parse(result.stdout || '{}'); +assert.equal(payload.schemaVersion, '1.0.0'); +assert.ok(Array.isArray(payload.modules) && payload.modules.length > 0, 'expected module metrics'); +assert.ok(Array.isArray(payload.commands) && payload.commands.length > 0, 'expected command metrics'); +assert.ok(Array.isArray(payload.regressions), 'expected regression list'); +assert.equal(payload.regressions.length, 0, 'expected current metrics to satisfy the baseline'); + +const moduleIds = new Set(payload.modules.map((entry) => entry.id)); +for (const expectedId of [ + 'shared.search-request', + 'shared.command-registry.query', + 'shared.runtime-capability-manifest', + 'shared.artifact-io', + 'shared.subprocess.runner', + 'retrieval.cli' +]) { + assert.equal(moduleIds.has(expectedId), true, `missing module metric for ${expectedId}`); +} + +for (const entry of payload.modules) { + assert.equal(Number.isInteger(entry.directLocalImportCount), true, 'expected direct import count'); + assert.equal(Number.isInteger(entry.transitiveLocalModuleCount), true, 'expected transitive module count'); + assert.equal(Number.isFinite(entry.importMs), true, 'expected numeric import timing'); +} + +for (const entry of payload.commands) { + assert.equal(Number.isFinite(entry.wallMs), true, 'expected numeric command timing'); + assert.equal(entry.exitCode, 0, `expected successful command exit for ${entry.id}`); +} + +console.log('shared module performance test passed'); diff --git a/tests/tooling/signature-parse/shared-splitter.test.js b/tests/tooling/signature-parse/shared-splitter.test.js deleted file mode 100644 index 5ee0a9032..000000000 --- a/tests/tooling/signature-parse/shared-splitter.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - findTopLevelIndex, - splitTopLevel, - stripTopLevelAssignment -} from '../../../src/index/tooling/signature-parse/shared.js'; -import { parseClikeSignature } from '../../../src/index/tooling/signature-parse/clike.js'; -import { parsePythonSignature } from '../../../src/index/tooling/signature-parse/python.js'; -import { parseSwiftSignature } from '../../../src/index/tooling/signature-parse/swift.js'; - -const split = splitTopLevel('Map>, "x,y", fn(a, b), value', ','); -assert.deepEqual(split, ['Map>', '"x,y"', 'fn(a, b)', 'value']); - -assert.equal(findTopLevelIndex('param: Dictionary = [:]', '='), 33); -assert.equal(stripTopLevelAssignment('value: String = "a,b"'), 'value: String '); - -const clike = parseClikeSignature('const std::vector& build(const std::string& name, int count)', 'build'); -assert.equal(clike?.returnType, 'const std::vector&'); -assert.deepEqual(clike?.paramNames, ['name', 'count']); - -const python = parsePythonSignature('def run(name: str, options: dict[str, str] = {"a": "b"}) -> list[str]:'); -assert.equal(python?.returnType, 'list[str]'); -assert.deepEqual(python?.paramNames, ['name', 'options']); - -const swift = parseSwiftSignature('func run(name: String, payload: [String: String] = ["a": "b"]) -> Result'); -assert.equal(swift?.returnType, 'Result'); -assert.deepEqual(swift?.paramNames, ['name', 'payload']); - -console.log('signature parse shared splitter test passed'); diff --git a/tests/tooling/signature-parse/signature-contract-matrix.test.js b/tests/tooling/signature-parse/signature-contract-matrix.test.js new file mode 100644 index 000000000..af4a8e70d --- /dev/null +++ b/tests/tooling/signature-parse/signature-contract-matrix.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { parseClikeSignature } from '../../../src/index/tooling/signature-parse/clike.js'; +import { parseGoSignature } from '../../../src/index/tooling/signature-parse/go.js'; +import { parseLuaSignature } from '../../../src/index/tooling/signature-parse/lua.js'; +import { parsePythonSignature } from '../../../src/index/tooling/signature-parse/python.js'; +import { parseRustSignature } from '../../../src/index/tooling/signature-parse/rust.js'; +import { + findTopLevelIndex, + splitTopLevel, + stripTopLevelAssignment +} from '../../../src/index/tooling/signature-parse/shared.js'; +import { parseSwiftSignature } from '../../../src/index/tooling/signature-parse/swift.js'; +import { parseZigSignature } from '../../../src/index/tooling/signature-parse/zig.js'; + +assert.deepEqual( + splitTopLevel('Map>, "x,y", fn(a, b), value', ','), + ['Map>', '"x,y"', 'fn(a, b)', 'value'] +); +assert.equal(findTopLevelIndex('param: Dictionary = [:]', '='), 33); +assert.equal(stripTopLevelAssignment('value: String = "a,b"'), 'value: String '); + +const clike = parseClikeSignature('const std::vector& build(const std::string& name, int count)', 'build'); +assert.equal(clike?.returnType, 'const std::vector&'); +assert.deepEqual(clike?.paramNames, ['name', 'count']); + +const python = parsePythonSignature('def run(name: str, options: dict[str, str] = {"a": "b"}) -> list[str]:'); +assert.equal(python?.returnType, 'list[str]'); +assert.deepEqual(python?.paramNames, ['name', 'options']); + +const swift = parseSwiftSignature('func run(name: String, payload: [String: String] = ["a": "b"]) -> Result'); +assert.equal(swift?.returnType, 'Result'); +assert.deepEqual(swift?.paramNames, ['name', 'payload']); + +const goSimple = parseGoSignature('func Add(a int, b int) int'); +assert.equal(goSimple?.returnType, 'int'); +assert.deepEqual(goSimple?.paramNames, ['a', 'b']); +assert.equal(goSimple?.paramTypes?.a, 'int'); +assert.equal(goSimple?.paramTypes?.b, 'int'); + +const goReceiver = parseGoSignature('func (s *Server) Run(ctx context.Context, args ...string) error'); +assert.equal(goReceiver?.returnType, 'error'); +assert.deepEqual(goReceiver?.paramNames, ['ctx', 'args']); +assert.equal(goReceiver?.paramTypes?.ctx, 'context.Context'); +assert.equal(goReceiver?.paramTypes?.args, '...string'); + +const goGeneric = parseGoSignature('func Map[T any](in []T, fn func(T) T) []T'); +assert.equal(goGeneric?.returnType, '[]T'); +assert.deepEqual(goGeneric?.paramNames, ['in', 'fn']); +assert.equal(goGeneric?.paramTypes?.in, '[]T'); +assert.equal(goGeneric?.paramTypes?.fn, 'func(T) T'); + +const rustSimple = parseRustSignature('fn add(a: i32, b: i32) -> i32'); +assert.equal(rustSimple?.returnType, 'i32'); +assert.deepEqual(rustSimple?.paramNames, ['a', 'b']); +assert.equal(rustSimple?.paramTypes?.a, 'i32'); +assert.equal(rustSimple?.paramTypes?.b, 'i32'); + +const rustSelf = parseRustSignature("pub fn run(&self, ctx: Context<'_>) -> Result<(), Error>"); +assert.equal(rustSelf?.returnType, 'Result<(), Error>'); +assert.deepEqual(rustSelf?.paramNames, ['ctx']); +assert.equal(rustSelf?.paramTypes?.ctx, "Context<'_>"); + +const rustWhere = parseRustSignature('fn map(input: Vec) -> Vec where T: Clone'); +assert.equal(rustWhere?.returnType, 'Vec'); +assert.deepEqual(rustWhere?.paramNames, ['input']); + +const luaSimple = parseLuaSignature('function greet(name: string): string'); +assert.equal(luaSimple?.returnType, 'string'); +assert.deepEqual(luaSimple?.paramNames, ['name']); +assert.equal(luaSimple?.paramTypes?.name, 'string'); + +const luaLocal = parseLuaSignature('local function module.run(path: string, opts: table): boolean'); +assert.equal(luaLocal?.returnType, 'boolean'); +assert.deepEqual(luaLocal?.paramNames, ['path', 'opts']); + +const zigSimple = parseZigSignature('fn add(a: i32, b: i32) i32'); +assert.equal(zigSimple?.returnType, 'i32'); +assert.deepEqual(zigSimple?.paramNames, ['a', 'b']); +assert.equal(zigSimple?.paramTypes?.a, 'i32'); +assert.equal(zigSimple?.paramTypes?.b, 'i32'); + +const zigErrorUnion = parseZigSignature('pub fn run(self: *Self, input: []const u8) !void'); +assert.equal(zigErrorUnion?.returnType, '!void'); +assert.deepEqual(zigErrorUnion?.paramNames, ['self', 'input']); + +console.log('tooling signature parse contract matrix test passed'); diff --git a/tests/tooling/structural/binary-default-timeout.test.js b/tests/tooling/structural/binary-default-timeout.test.js new file mode 100644 index 000000000..548f99d23 --- /dev/null +++ b/tests/tooling/structural/binary-default-timeout.test.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { runBinary } from '../../../src/experimental/structural/binaries.js'; + +const startedAt = Date.now(); +const result = runBinary( + { command: process.execPath, argsPrefix: [] }, + ['-e', 'setTimeout(() => process.exit(0), 5200);'], + { + stdio: 'ignore', + encoding: 'utf8' + } +); +const elapsedMs = Date.now() - startedAt; + +assert.equal(result.status, 0, 'expected structural binary execution to complete without implicit 5s timeout'); +assert.equal(result.error, undefined, 'expected no timeout error for structural binary default run'); +assert.equal(elapsedMs >= 5000, true, `expected structural binary run to outlive default sync timeout (elapsed=${elapsedMs}ms)`); + +console.log('structural binary default timeout test passed'); diff --git a/tests/tooling/structural/filters.test.js b/tests/tooling/structural/filters.test.js new file mode 100644 index 000000000..7673c9013 --- /dev/null +++ b/tests/tooling/structural/filters.test.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getIndexDir, getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; +import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; +import { filterChunks } from '../../../src/retrieval/output.js'; +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'structural-filters'); +const repoRoot = path.join(tempRoot, 'repo'); +const srcDir = path.join(repoRoot, 'src'); +const cacheRoot = path.join(tempRoot, 'cache'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(srcDir, { recursive: true }); +await fsPromises.mkdir(cacheRoot, { recursive: true }); +await fsPromises.writeFile(path.join(srcDir, 'example.js'), 'eval("x");\n', 'utf8'); + +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + workerPool: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const userConfig = loadUserConfig(repoRoot); +const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); +const structuralDir = path.join(repoCacheRoot, 'structural'); +await fsPromises.mkdir(structuralDir, { recursive: true }); +const match = { + engine: 'semgrep', + pack: 'test-pack', + ruleId: 'no-eval', + tags: ['security'], + path: 'src/example.js', + startLine: 1, + endLine: 1, + snippet: 'eval("x")' +}; +await fsPromises.writeFile( + path.join(structuralDir, 'structural.jsonl'), + `${JSON.stringify(match)}\n`, + 'utf8' +); + +const buildResult = runNode([ + path.join(root, 'build_index.js'), + '--stub-embeddings', + '--stage', + 'stage1', + '--mode', + 'code', + '--repo', + repoRoot +], 'build structural filter fixture', root, env, { stdio: 'pipe', allowFailure: true }); +if (buildResult.status !== 0) { + console.error(buildResult.stderr || buildResult.stdout || 'build_index failed'); + process.exit(buildResult.status ?? 1); +} + +const indexDir = getIndexDir(repoRoot, 'code', userConfig); +const chunkMeta = await loadChunkMeta(indexDir); +const fileMeta = readJsonFile(path.join(indexDir, 'file_meta.json')); +const fileMetaById = new Map( + Array.isArray(fileMeta) ? fileMeta.map((entry) => [entry.id, entry]) : [] +); +for (const chunk of chunkMeta) { + if (!chunk || chunk.file || chunk.fileId == null) continue; + const meta = fileMetaById.get(chunk.fileId); + if (meta?.file) chunk.file = meta.file; +} +const target = chunkMeta.find((chunk) => chunk.file === 'src/example.js'); +assert.ok(target, 'expected example.js chunk to exist'); +assert.ok(Array.isArray(target.docmeta?.structural), 'expected structural metadata on chunk'); +assert.equal(target.docmeta.structural[0]?.pack, 'test-pack'); +assert.equal(target.docmeta.structural[0]?.ruleId, 'no-eval'); + +const packFiltered = filterChunks(chunkMeta, { structPack: 'test-pack' }); +assert.ok(packFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-pack filter to match'); + +const ruleFiltered = filterChunks(chunkMeta, { structRule: 'no-eval' }); +assert.ok(ruleFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-rule filter to match'); + +const tagFiltered = filterChunks(chunkMeta, { structTag: 'security' }); +assert.ok(tagFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-tag filter to match'); + +console.log('structural filters test passed'); + diff --git a/tests/tooling/structural/structural-search-paths.test.js b/tests/tooling/structural/search-paths.test.js similarity index 100% rename from tests/tooling/structural/structural-search-paths.test.js rename to tests/tooling/structural/search-paths.test.js diff --git a/tests/tooling/structural/search.test.js b/tests/tooling/structural/search.test.js new file mode 100644 index 000000000..ef052d115 --- /dev/null +++ b/tests/tooling/structural/search.test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'structural-search'); +const repoRoot = path.join(tempRoot, 'repo'); +const srcDir = path.join(repoRoot, 'src'); +const docsDir = path.join(repoRoot, 'docs'); +const binRoot = path.join(root, 'tests', 'fixtures', 'structural', 'bin'); + +await fsPromises.rm(tempRoot, { recursive: true, force: true }); +await fsPromises.mkdir(srcDir, { recursive: true }); +await fsPromises.mkdir(docsDir, { recursive: true }); +await fsPromises.writeFile(path.join(srcDir, 'example.js'), 'eval(\"x\");\n'); +await fsPromises.writeFile(path.join(srcDir, 'example.ts'), 'eval(x);\n'); +await fsPromises.writeFile(path.join(docsDir, 'notes.md'), 'TODO: update\n'); + +for (const binName of ['semgrep', 'sg', 'comby']) { + try { + await fsPromises.chmod(path.join(binRoot, binName), 0o755); + } catch {} +} + +const env = { + ...process.env, + PATH: `${binRoot}${path.delimiter}${process.env.PATH || ''}`, + PAIROFCLEATS_PROFILE: 'full' +}; + +const result = runNode( + [ + path.join(root, 'tools', 'analysis', 'structural-search.js'), + '--repo', repoRoot, + '--pack', 'semgrep-security', + '--pack', 'astgrep-js-safety', + '--pack', 'comby-docs', + '--format', 'json' + ], + 'structural search', + root, + env, + { stdio: 'pipe', allowFailure: true } +); + +if (result.status !== 0) { + console.error(result.stderr || result.stdout || 'structural-search failed'); + process.exit(result.status ?? 1); +} + +const payload = JSON.parse(result.stdout || '{}'); +assert.ok(Array.isArray(payload.results), 'expected results array'); +assert.ok(payload.results.length >= 3, 'expected at least 3 results'); + +const engines = new Set(payload.results.map((entry) => entry.engine)); +assert.ok(engines.has('semgrep'), 'expected semgrep result'); +assert.ok(engines.has('ast-grep'), 'expected ast-grep result'); +assert.ok(engines.has('comby'), 'expected comby result'); + +const comby = payload.results.find((entry) => entry.engine === 'comby'); +assert.equal(comby.path, 'docs/notes.md'); + +console.log('structural search test passed'); + diff --git a/tests/tooling/structural/structural-filters.test.js b/tests/tooling/structural/structural-filters.test.js deleted file mode 100644 index 6c197b87b..000000000 --- a/tests/tooling/structural/structural-filters.test.js +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getIndexDir, getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; -import { loadChunkMeta, readJsonFile } from '../../../src/shared/artifact-io.js'; -import { filterChunks } from '../../../src/retrieval/output.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'structural-filters'); -const repoRoot = path.join(tempRoot, 'repo'); -const srcDir = path.join(repoRoot, 'src'); -const cacheRoot = path.join(tempRoot, 'cache'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(srcDir, { recursive: true }); -await fsPromises.mkdir(cacheRoot, { recursive: true }); -await fsPromises.writeFile(path.join(srcDir, 'example.js'), 'eval("x");\n', 'utf8'); - -const env = applyTestEnv({ - cacheRoot, - embeddings: 'stub', - testConfig: { - indexing: { - scm: { provider: 'none' } - } - } -}); - -const userConfig = loadUserConfig(repoRoot); -const repoCacheRoot = getRepoCacheRoot(repoRoot, userConfig); -const structuralDir = path.join(repoCacheRoot, 'structural'); -await fsPromises.mkdir(structuralDir, { recursive: true }); -const match = { - engine: 'semgrep', - pack: 'test-pack', - ruleId: 'no-eval', - tags: ['security'], - path: 'src/example.js', - startLine: 1, - endLine: 1, - snippet: 'eval("x")' -}; -await fsPromises.writeFile( - path.join(structuralDir, 'structural.jsonl'), - `${JSON.stringify(match)}\n`, - 'utf8' -); - -const buildResult = spawnSync(process.execPath, [ - path.join(root, 'build_index.js'), - '--stub-embeddings', - '--repo', - repoRoot -], { encoding: 'utf8', env }); -if (buildResult.status !== 0) { - console.error(buildResult.stderr || buildResult.stdout || 'build_index failed'); - process.exit(buildResult.status ?? 1); -} - -const indexDir = getIndexDir(repoRoot, 'code', userConfig); -const chunkMeta = await loadChunkMeta(indexDir); -const fileMeta = readJsonFile(path.join(indexDir, 'file_meta.json')); -const fileMetaById = new Map( - Array.isArray(fileMeta) ? fileMeta.map((entry) => [entry.id, entry]) : [] -); -for (const chunk of chunkMeta) { - if (!chunk || chunk.file || chunk.fileId == null) continue; - const meta = fileMetaById.get(chunk.fileId); - if (meta?.file) chunk.file = meta.file; -} -const target = chunkMeta.find((chunk) => chunk.file === 'src/example.js'); -assert.ok(target, 'expected example.js chunk to exist'); -assert.ok(Array.isArray(target.docmeta?.structural), 'expected structural metadata on chunk'); -assert.equal(target.docmeta.structural[0]?.pack, 'test-pack'); -assert.equal(target.docmeta.structural[0]?.ruleId, 'no-eval'); - -const packFiltered = filterChunks(chunkMeta, { structPack: 'test-pack' }); -assert.ok(packFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-pack filter to match'); - -const ruleFiltered = filterChunks(chunkMeta, { structRule: 'no-eval' }); -assert.ok(ruleFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-rule filter to match'); - -const tagFiltered = filterChunks(chunkMeta, { structTag: 'security' }); -assert.ok(tagFiltered.find((chunk) => chunk.file === 'src/example.js'), 'expected struct-tag filter to match'); - -console.log('structural filters test passed'); - diff --git a/tests/tooling/structural/structural-search.test.js b/tests/tooling/structural/structural-search.test.js deleted file mode 100644 index 64f1a1b73..000000000 --- a/tests/tooling/structural/structural-search.test.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'structural-search'); -const repoRoot = path.join(tempRoot, 'repo'); -const srcDir = path.join(repoRoot, 'src'); -const docsDir = path.join(repoRoot, 'docs'); -const binRoot = path.join(root, 'tests', 'fixtures', 'structural', 'bin'); - -await fsPromises.rm(tempRoot, { recursive: true, force: true }); -await fsPromises.mkdir(srcDir, { recursive: true }); -await fsPromises.mkdir(docsDir, { recursive: true }); -await fsPromises.writeFile(path.join(srcDir, 'example.js'), 'eval(\"x\");\n'); -await fsPromises.writeFile(path.join(srcDir, 'example.ts'), 'eval(x);\n'); -await fsPromises.writeFile(path.join(docsDir, 'notes.md'), 'TODO: update\n'); - -for (const binName of ['semgrep', 'sg', 'comby']) { - try { - await fsPromises.chmod(path.join(binRoot, binName), 0o755); - } catch {} -} - -const env = { - ...process.env, - PATH: `${binRoot}${path.delimiter}${process.env.PATH || ''}`, - PAIROFCLEATS_PROFILE: 'full' -}; - -const result = spawnSync( - process.execPath, - [ - path.join(root, 'tools', 'analysis', 'structural-search.js'), - '--repo', repoRoot, - '--pack', 'semgrep-security', - '--pack', 'astgrep-js-safety', - '--pack', 'comby-docs', - '--format', 'json' - ], - { encoding: 'utf8', env } -); - -if (result.status !== 0) { - console.error(result.stderr || result.stdout || 'structural-search failed'); - process.exit(result.status ?? 1); -} - -const payload = JSON.parse(result.stdout || '{}'); -assert.ok(Array.isArray(payload.results), 'expected results array'); -assert.ok(payload.results.length >= 3, 'expected at least 3 results'); - -const engines = new Set(payload.results.map((entry) => entry.engine)); -assert.ok(engines.has('semgrep'), 'expected semgrep result'); -assert.ok(engines.has('ast-grep'), 'expected ast-grep result'); -assert.ok(engines.has('comby'), 'expected comby result'); - -const comby = payload.results.find((entry) => entry.engine === 'comby'); -assert.equal(comby.path, 'docs/notes.md'); - -console.log('structural search test passed'); - diff --git a/tests/tooling/sublime/behavior-contract-matrix.test.js b/tests/tooling/sublime/behavior-contract-matrix.test.js new file mode 100644 index 000000000..675db4818 --- /dev/null +++ b/tests/tooling/sublime/behavior-contract-matrix.test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const checker = path.join(root, 'tools', 'tooling', 'python-check.js'); + +const pythonPolicy = runNode( + [checker, '--json'], + 'sublime python policy check', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe' } +); + +if (pythonPolicy.status !== 0) { + if (pythonPolicy.stdout) console.error(pythonPolicy.stdout.trim()); + if (pythonPolicy.stderr) console.error(pythonPolicy.stderr.trim()); + throw new Error(`python policy check failed with exit ${pythonPolicy.status}`); +} + +const pythonInfo = JSON.parse(pythonPolicy.stdout || '{}'); +const python = pythonInfo.python || process.env.PYTHON || 'python'; + +const cases = [ + ['paths', 'paths_behavior.py'], + ['index', 'index_behavior.py'], + ['results', 'results_behavior.py'], + ['navigation', 'navigation_behavior.py'], + ['watch', 'watch_behavior.py'], + ['analysis', 'analysis_behavior.py'], + ['operator', 'operator_behavior.py'], + ['settings', 'settings_behavior.py'], + ['search', 'search_behavior.py'], + ['map', 'map_behavior.py'], + ['api-server', 'api_server.py'], + ['runner', 'runner_behavior.py'], + ['task', 'task_behavior.py'], + ['visibility', 'visibility_behavior.py'] +]; + +for (const [label, scriptName] of cases) { + const helper = path.join(root, 'tests', 'helpers', 'sublime', scriptName); + const result = spawnSync(python, [helper], { + cwd: root, + encoding: 'utf8' + }); + + if (result.stdout) console.error(result.stdout); + if (result.stderr) console.error(result.stderr); + assert.equal(result.status, 0, `sublime ${label} behavior helper failed`); +} + +console.log('sublime behavior contract matrix test passed'); diff --git a/tests/tooling/sublime/config-runtime-contract.test.js b/tests/tooling/sublime/config-runtime-contract.test.js new file mode 100644 index 000000000..885b87653 --- /dev/null +++ b/tests/tooling/sublime/config-runtime-contract.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const configPath = path.join(root, 'sublime', 'PairOfCleats', 'lib', 'config.py'); +const contractPath = path.join(root, 'src', 'contracts', 'editor-config-contract.json'); + +for (const target of [configPath, contractPath]) { + if (!fs.existsSync(target)) { + console.error(`Sublime runtime contract test missing required file: ${target}`); + process.exit(1); + } +} + +const configSource = fs.readFileSync(configPath, 'utf8'); +if (configSource.includes('docs/tooling/editor-config-contract.json')) { + console.error('Sublime config must not load editor config contract from docs/tooling.'); + process.exit(1); +} + +const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); +const sublimeSettings = contract?.settings?.sublime || {}; +for (const key of ['cliPathKey', 'nodePathKey', 'apiServerUrlKey', 'apiTimeoutKey', 'apiExecutionModeKey', 'envKey']) { + if (typeof sublimeSettings[key] !== 'string' || sublimeSettings[key].trim().length === 0) { + console.error(`Runtime editor config contract missing sublime settings.${key}.`); + process.exit(1); + } +} + +console.log('sublime runtime contract source test passed'); diff --git a/tests/tooling/sublime/package-archive-metadata.test.js b/tests/tooling/sublime/package-archive-metadata.test.js index 85bc44348..e8c72502c 100644 --- a/tests/tooling/sublime/package-archive-metadata.test.js +++ b/tests/tooling/sublime/package-archive-metadata.test.js @@ -1,17 +1,19 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const outDir = resolveTestCachePath(root, 'package-sublime-metadata'); -const run = spawnSync( - process.execPath, +const run = runNode( [path.join(root, 'tools', 'package-sublime.js'), '--out-dir', outDir], - { cwd: root, encoding: 'utf8' } + 'sublime package archive metadata', + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); if (run.status !== 0) { console.error('package-archive-metadata test failed: package-sublime command failed'); diff --git a/tests/tooling/sublime/package-determinism.test.js b/tests/tooling/sublime/package-determinism.test.js index 0e986d5b5..3ffb2b7f0 100644 --- a/tests/tooling/sublime/package-determinism.test.js +++ b/tests/tooling/sublime/package-determinism.test.js @@ -1,18 +1,20 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const outA = resolveTestCachePath(root, 'package-sublime-determinism-a'); const outB = resolveTestCachePath(root, 'package-sublime-determinism-b'); -const runPack = (outDir) => spawnSync( - process.execPath, +const runPack = (outDir) => runNode( [path.join(root, 'tools', 'package-sublime.js'), '--out-dir', outDir], - { cwd: root, encoding: 'utf8' } + `sublime package determinism ${path.basename(outDir)}`, + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); const first = runPack(outA); diff --git a/tests/tooling/sublime/package-harness.test.js b/tests/tooling/sublime/package-harness.test.js new file mode 100644 index 000000000..9f2b59205 --- /dev/null +++ b/tests/tooling/sublime/package-harness.test.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { prepareIsolatedTestCacheDir } from '../../helpers/test-cache.js'; + +const root = process.cwd(); + +const pythonPolicy = runNode( + [path.join(root, 'tools', 'tooling', 'python-check.js'), '--json'], + 'python-check for sublime package harness', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (pythonPolicy.status !== 0) { + console.error('sublime-package-harness: required python toolchain is missing'); + if (pythonPolicy.stdout) console.error(pythonPolicy.stdout.trim()); + if (pythonPolicy.stderr) console.error(pythonPolicy.stderr.trim()); + process.exit(pythonPolicy.status ?? 1); +} + +let pythonInfo = null; +try { + pythonInfo = JSON.parse(pythonPolicy.stdout || '{}'); +} catch { + pythonInfo = null; +} +const python = pythonInfo?.python || process.env.PYTHON || 'python'; +const script = path.join(root, 'tests', 'helpers', 'sublime', 'package_harness.py'); +const fixtureRepo = path.join((await prepareIsolatedTestCacheDir('sublime-package-fixture', { root, clean: true })).dir, 'repo'); +await fsPromises.mkdir(path.join(fixtureRepo, 'src'), { recursive: true }); +await fsPromises.writeFile( + path.join(fixtureRepo, 'src', 'index.js'), + [ + 'export function greet(name = "world") {', + ' return `hello ${name}`;', + '}', + '' + ].join('\n'), + 'utf8' +); +await fsPromises.writeFile( + path.join(fixtureRepo, 'README.md'), + '# Sublime package fixture\n\nminimal repo for package harness\n', + 'utf8' +); +const cacheRoot = (await prepareIsolatedTestCacheDir('sublime-package-harness', { root, clean: true })).dir; +const env = applyTestEnv({ + cacheRoot, + embeddings: 'stub', + testConfig: { + sqlite: { use: false }, + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false, + embeddings: { + enabled: false, + mode: 'off', + lancedb: { enabled: false }, + hnsw: { enabled: false } + } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + extraEnv: { + PAIROFCLEATS_SUBLIME_TEST_NODE: process.execPath, + PAIROFCLEATS_SUBLIME_TEST_CLI: path.join(root, 'bin', 'pairofcleats.js'), + PAIROFCLEATS_SUBLIME_TEST_FIXTURE_REPO: fixtureRepo, + PAIROFCLEATS_SUBLIME_PACKAGE_HARNESS_TRACE: process.env.PAIROFCLEATS_SUBLIME_PACKAGE_HARNESS_TRACE || null + }, + syncProcess: false +}); + +const result = spawnSync(python, [script], { + encoding: 'utf8', + env, + stdio: process.env.PAIROFCLEATS_TEST_LOG_SILENT ? 'inherit' : 'pipe' +}); + +if (result.status !== 0) { + console.error('sublime-package-harness: python harness failed'); + if (result.stdout) console.error(result.stdout); + if (result.stderr) console.error(result.stderr); + process.exit(result.status || 1); +} + +console.log('sublime package harness test passed'); diff --git a/tests/tooling/sublime/package-release-sanity.test.js b/tests/tooling/sublime/package-release-sanity.test.js new file mode 100644 index 000000000..91a654fee --- /dev/null +++ b/tests/tooling/sublime/package-release-sanity.test.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const outDir = resolveTestCachePath(root, 'package-sublime-release-sanity'); + +const run = runNode( + [path.join(root, 'tools', 'package-sublime.js'), '--out-dir', outDir, '--smoke'], + 'sublime package release sanity', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (run.status !== 0) { + console.error('package-release-sanity test failed: package-sublime command failed'); + if (run.stderr) console.error(run.stderr.trim()); + process.exit(run.status ?? 1); +} + +const manifestPath = path.join(outDir, 'pairofcleats.sublime-package.manifest.json'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const shippedPaths = new Set( + Array.isArray(manifest.entries) ? manifest.entries.map((entry) => entry.path) : [] +); + +const requiredShippedPaths = [ + 'PairOfCleats/plugin.py', + 'PairOfCleats/README.md', + 'PairOfCleats/Default.sublime-commands', + 'PairOfCleats/Default (Windows).sublime-keymap', + 'PairOfCleats/Default (Linux).sublime-keymap', + 'PairOfCleats/Default (OSX).sublime-keymap', + 'PairOfCleats/Context.sublime-menu', + 'PairOfCleats/Main.sublime-menu', + 'PairOfCleats/PairOfCleats.sublime-settings', + 'PairOfCleats/commands/search.py', + 'PairOfCleats/commands/index.py', + 'PairOfCleats/commands/map.py', + 'PairOfCleats/commands/operator.py', + 'PairOfCleats/commands/runtime.py', + 'PairOfCleats/commands/analysis.py', + 'PairOfCleats/commands/settings.py', + 'PairOfCleats/commands/validate.py', +]; +for (const entry of requiredShippedPaths) { + if (!shippedPaths.has(entry)) { + console.error(`package-release-sanity test failed: missing shipped entry ${entry}`); + process.exit(1); + } +} + +const commandPalettePath = path.join(root, 'sublime', 'PairOfCleats', 'Default.sublime-commands'); +const menuPath = path.join(root, 'sublime', 'PairOfCleats', 'Main.sublime-menu'); +const contextMenuPath = path.join(root, 'sublime', 'PairOfCleats', 'Context.sublime-menu'); +const keymapPaths = [ + path.join(root, 'sublime', 'PairOfCleats', 'Default (Windows).sublime-keymap'), + path.join(root, 'sublime', 'PairOfCleats', 'Default (Linux).sublime-keymap'), + path.join(root, 'sublime', 'PairOfCleats', 'Default (OSX).sublime-keymap'), +]; +const settingsPath = path.join(root, 'sublime', 'PairOfCleats', 'PairOfCleats.sublime-settings'); +const pluginPath = path.join(root, 'sublime', 'PairOfCleats', 'plugin.py'); + +const commandEntries = JSON.parse(fs.readFileSync(commandPalettePath, 'utf8')); +const menuEntries = JSON.parse(fs.readFileSync(menuPath, 'utf8')); +const contextMenuEntries = JSON.parse(fs.readFileSync(contextMenuPath, 'utf8')); +const keymapEntries = keymapPaths.flatMap((keymapPath) => JSON.parse(fs.readFileSync(keymapPath, 'utf8'))); +const settingsText = fs.readFileSync(settingsPath, 'utf8'); +const pluginText = fs.readFileSync(pluginPath, 'utf8'); + +const commandKeys = commandEntries.map((entry) => JSON.stringify({ + command: entry.command, + args: entry.args || null, +})); +const uniqueCommandKeys = new Set(commandKeys); +if (uniqueCommandKeys.size !== commandKeys.length) { + console.error('package-release-sanity test failed: duplicate command palette entries'); + process.exit(1); +} +const commandNames = commandEntries.map((entry) => entry.command); +const uniqueCommandNames = new Set(commandNames); + +const requiredCommands = [ + 'pair_of_cleats_open_settings', + 'pair_of_cleats_open_project_settings', + 'pair_of_cleats_validate_settings', + 'pair_of_cleats_show_config_dump', + 'pair_of_cleats_tooling_doctor', + 'pair_of_cleats_server_health', + 'pair_of_cleats_server_status', + 'pair_of_cleats_index_health', + 'pair_of_cleats_search', + 'pair_of_cleats_architecture_check', + 'pair_of_cleats_impact', + 'pair_of_cleats_suggest_tests', + 'pair_of_cleats_workspace_manifest', + 'pair_of_cleats_workspace_status', + 'pair_of_cleats_workspace_build', + 'pair_of_cleats_workspace_catalog', + 'pair_of_cleats_reopen_last_results', + 'pair_of_cleats_reopen_analysis', + 'pair_of_cleats_show_progress', + 'pair_of_cleats_cancel_active_task', + 'pair_of_cleats_index_build_all', + 'pair_of_cleats_index_validate', + 'pair_of_cleats_map_repo', + 'pair_of_cleats_map_jump_to_node', + 'pair_of_cleats_map_show_last_report', +]; +for (const command of requiredCommands) { + if (!uniqueCommandNames.has(command)) { + console.error(`package-release-sanity test failed: missing command palette entry ${command}`); + process.exit(1); + } +} + +const preferenceMenu = Array.isArray(menuEntries) + ? menuEntries.find((entry) => entry.id === 'preferences') + : null; +const toolsMenu = Array.isArray(menuEntries) + ? menuEntries.find((entry) => entry.id === 'tools') + : null; +const preferenceCommands = new Set( + Array.isArray(preferenceMenu?.children) + ? preferenceMenu.children.map((entry) => entry.command).filter(Boolean) + : [] +); +const toolsCommands = new Set( + Array.isArray(toolsMenu?.children?.[0]?.children) + ? toolsMenu.children[0].children.map((entry) => entry.command).filter(Boolean) + : [] +); +const contextCommands = new Set( + Array.isArray(contextMenuEntries?.[0]?.children) + ? contextMenuEntries[0].children.map((entry) => entry.command).filter(Boolean) + : [] +); +const keymapCommands = new Set( + keymapEntries.map((entry) => entry.command).filter(Boolean) +); +const requiredPreferenceCommands = [ + 'pair_of_cleats_open_settings', + 'pair_of_cleats_open_project_settings', + 'pair_of_cleats_project_settings_template', + 'pair_of_cleats_show_effective_settings', + 'pair_of_cleats_validate_settings', +]; +for (const command of requiredPreferenceCommands) { + if (!preferenceCommands.has(command)) { + console.error(`package-release-sanity test failed: missing preferences menu command ${command}`); + process.exit(1); + } +} + +const requiredToolsCommands = [ + 'pair_of_cleats_search', + 'pair_of_cleats_reopen_last_results', + 'pair_of_cleats_show_progress', + 'pair_of_cleats_index_build_all', + 'pair_of_cleats_index_watch_start', + 'pair_of_cleats_index_watch_stop', + 'pair_of_cleats_map_current_file', + 'pair_of_cleats_show_config_dump', + 'pair_of_cleats_tooling_doctor', +]; +for (const command of requiredToolsCommands) { + if (!toolsCommands.has(command)) { + console.error(`package-release-sanity test failed: missing tools menu command ${command}`); + process.exit(1); + } +} + +const requiredContextCommands = [ + 'pair_of_cleats_search_selection', + 'pair_of_cleats_search_symbol_under_cursor', + 'pair_of_cleats_goto_definition', + 'pair_of_cleats_find_references', + 'pair_of_cleats_complete_symbol', + 'pair_of_cleats_map_current_file', + 'pair_of_cleats_map_symbol_under_cursor', + 'pair_of_cleats_map_selection', +]; +for (const command of requiredContextCommands) { + if (!contextCommands.has(command)) { + console.error(`package-release-sanity test failed: missing context menu command ${command}`); + process.exit(1); + } +} + +const requiredKeymapCommands = [ + 'pair_of_cleats_search', + 'pair_of_cleats_search_selection', + 'pair_of_cleats_goto_definition', + 'pair_of_cleats_find_references', + 'pair_of_cleats_show_progress', +]; +for (const command of requiredKeymapCommands) { + if (!keymapCommands.has(command)) { + console.error(`package-release-sanity test failed: missing keybinding command ${command}`); + process.exit(1); + } +} + +const requiredSettingKeys = [ + 'api_server_url', + 'api_timeout_ms', + 'api_execution_mode', + 'open_results_in', + 'results_buffer_threshold', + 'progress_panel_on_start', + 'progress_watchdog_ms', + 'index_watch_scope', + 'index_watch_mode', + 'map_show_report_panel', + 'map_stream_output', +]; +for (const key of requiredSettingKeys) { + const pattern = new RegExp(`"${key}"\\s*:`); + if (!pattern.test(settingsText)) { + console.error(`package-release-sanity test failed: missing settings key ${key}`); + process.exit(1); + } +} + +const requiredPluginImports = [ + 'from .commands import analysis as _analysis_commands', + 'from .commands import index as _index_commands', + 'from .commands import map as _map_commands', + 'from .commands import operator as _operator_commands', + 'from .commands import runtime as _runtime_commands', + 'from .commands import search as _search_commands', + 'from .commands import settings as _settings_commands', + 'from .commands import validate as _validate_commands', +]; +for (const fragment of requiredPluginImports) { + if (!pluginText.includes(fragment)) { + console.error(`package-release-sanity test failed: missing plugin import ${fragment}`); + process.exit(1); + } +} + +console.log('sublime package release sanity test passed'); diff --git a/tests/tooling/sublime/package-structure.test.js b/tests/tooling/sublime/package-structure.test.js index 76a2c3c9d..8e0ea3069 100644 --- a/tests/tooling/sublime/package-structure.test.js +++ b/tests/tooling/sublime/package-structure.test.js @@ -1,17 +1,19 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { runNode } from '../../helpers/run-node.js'; const root = process.cwd(); const outDir = resolveTestCachePath(root, 'package-sublime-structure'); -const run = spawnSync( - process.execPath, +const run = runNode( [path.join(root, 'tools', 'package-sublime.js'), '--out-dir', outDir, '--smoke'], - { cwd: root, encoding: 'utf8' } + 'sublime package structure', + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); if (run.status !== 0) { diff --git a/tests/tooling/sublime/pycompile.test.js b/tests/tooling/sublime/pycompile.test.js new file mode 100644 index 000000000..838abbfbd --- /dev/null +++ b/tests/tooling/sublime/pycompile.test.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const pkgDir = path.join(root, 'sublime', 'PairOfCleats'); + +const collectPyFiles = (dir) => { + const out = []; + const stack = [dir]; + while (stack.length) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile() && entry.name.endsWith('.py')) { + out.push(full); + } + } + } + out.sort(); + return out; +}; + +const pyFiles = collectPyFiles(pkgDir); +if (!pyFiles.length) { + console.error('sublime-pycompile: no python files found under', pkgDir); + process.exit(1); +} + +const pythonPolicy = runNode( + [path.join(root, 'tools', 'tooling', 'python-check.js'), '--json'], + 'python-check for sublime pycompile', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (pythonPolicy.status !== 0) { + console.error('sublime-pycompile: required python toolchain is missing'); + if (pythonPolicy.stdout) console.error(pythonPolicy.stdout.trim()); + if (pythonPolicy.stderr) console.error(pythonPolicy.stderr.trim()); + process.exit(pythonPolicy.status ?? 1); +} + +let pythonInfo = null; +try { + pythonInfo = JSON.parse(pythonPolicy.stdout || '{}'); +} catch { + pythonInfo = null; +} +const python = pythonInfo?.python || process.env.PYTHON || 'python'; +const result = spawnSync( + python, + ['-m', 'py_compile', ...pyFiles], + { encoding: 'utf8' } +); + +if (result.status !== 0) { + console.error('sublime-pycompile: python -m py_compile failed'); + if (result.stdout) console.error(result.stdout); + if (result.stderr) console.error(result.stderr); + process.exit(result.status || 1); +} + +console.log(`sublime-pycompile: ok (compiled ${pyFiles.length} files)`); diff --git a/tests/tooling/sublime/python-toolchain-policy.test.js b/tests/tooling/sublime/python-toolchain-policy.test.js index 6fd25824d..619bd2ebf 100644 --- a/tests/tooling/sublime/python-toolchain-policy.test.js +++ b/tests/tooling/sublime/python-toolchain-policy.test.js @@ -1,13 +1,15 @@ #!/usr/bin/env node import path from 'node:path'; -import { spawnSync } from 'node:child_process'; + +import { runNode } from '../../helpers/run-node.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const root = process.cwd(); const checker = path.join(root, 'tools', 'tooling', 'python-check.js'); +const env = applyTestEnv({ syncProcess: false }); -const okRun = spawnSync(process.execPath, [checker, '--json'], { - cwd: root, - encoding: 'utf8' +const okRun = runNode([checker, '--json'], 'python-check normal environment', root, env, { + stdio: 'pipe' }); if (okRun.status !== 0) { console.error('python-toolchain-policy test failed: expected python-check to succeed in normal environment'); @@ -16,16 +18,23 @@ if (okRun.status !== 0) { process.exit(okRun.status ?? 1); } -const missingRun = spawnSync(process.execPath, [checker, '--json'], { - cwd: root, - encoding: 'utf8', - env: { - ...process.env, - PATH: '', - Path: '', - PYTHON: '' +const missingRun = runNode( + [checker, '--json'], + 'python-check missing toolchain', + root, + applyTestEnv({ + extraEnv: { + PATH: '', + Path: '', + PYTHON: '' + }, + syncProcess: false + }), + { + stdio: 'pipe', + allowFailure: true } -}); +); if (missingRun.status === 0) { console.error('python-toolchain-policy test failed: expected missing-toolchain run to fail'); process.exit(1); diff --git a/tests/tooling/sublime/sublime-pycompile.test.js b/tests/tooling/sublime/sublime-pycompile.test.js deleted file mode 100644 index e3553a9ab..000000000 --- a/tests/tooling/sublime/sublime-pycompile.test.js +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -const root = process.cwd(); -const pkgDir = path.join(root, 'sublime', 'PairOfCleats'); - -const collectPyFiles = (dir) => { - const out = []; - const stack = [dir]; - while (stack.length) { - const current = stack.pop(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - for (const entry of entries) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(full); - } else if (entry.isFile() && entry.name.endsWith('.py')) { - out.push(full); - } - } - } - out.sort(); - return out; -}; - -const pyFiles = collectPyFiles(pkgDir); -if (!pyFiles.length) { - console.error('sublime-pycompile: no python files found under', pkgDir); - process.exit(1); -} - -const pythonPolicy = spawnSync( - process.execPath, - [path.join(root, 'tools', 'tooling', 'python-check.js'), '--json'], - { encoding: 'utf8' } -); -if (pythonPolicy.status !== 0) { - console.error('sublime-pycompile: required python toolchain is missing'); - if (pythonPolicy.stdout) console.error(pythonPolicy.stdout.trim()); - if (pythonPolicy.stderr) console.error(pythonPolicy.stderr.trim()); - process.exit(pythonPolicy.status ?? 1); -} - -let pythonInfo = null; -try { - pythonInfo = JSON.parse(pythonPolicy.stdout || '{}'); -} catch { - pythonInfo = null; -} -const python = pythonInfo?.python || process.env.PYTHON || 'python'; -const result = spawnSync( - python, - ['-m', 'py_compile', ...pyFiles], - { encoding: 'utf8' } -); - -if (result.status !== 0) { - console.error('sublime-pycompile: python -m py_compile failed'); - if (result.stdout) console.error(result.stdout); - if (result.stderr) console.error(result.stderr); - process.exit(result.status || 1); -} - -console.log(`sublime-pycompile: ok (compiled ${pyFiles.length} files)`); diff --git a/tests/tooling/test-selection/contract-matrix.test.js b/tests/tooling/test-selection/contract-matrix.test.js new file mode 100644 index 000000000..b419d25d2 --- /dev/null +++ b/tests/tooling/test-selection/contract-matrix.test.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.resolve(__dirname, '..', '..', 'fixtures', 'tooling', 'suggest-tests'); +const graphPath = path.join(fixtureRoot, 'graph-relations.json'); +const graphRelations = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + +{ + const report = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations, + repoRoot: fixtureRoot, + caps: { maxSuggestions: 5, maxCandidates: 10 }, + indexCompatKey: 'compat-suggest-tests-basic', + now: () => '2026-01-01T00:00:00.000Z' + }); + + assert.equal(report.fidelity?.source, 'graph'); + assert.equal(report.fidelity?.state, 'complete'); + assert.deepEqual(report.fidelity?.reasonCodes, []); + const libSuggestion = report.suggestions.find((entry) => entry.testPath === 'tests/unit/lib.test.js'); + assert.ok(libSuggestion, 'expected lib.test.js to be suggested'); + assert.equal(libSuggestion?.fidelity?.source, 'graph'); + assert.equal(libSuggestion?.fidelity?.matchKind, 'graph-distance'); +} + +{ + const report = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations, + repoRoot: fixtureRoot, + caps: { maxSuggestions: 5, maxCandidates: 10 }, + indexCompatKey: 'compat-suggest-tests-witness', + now: () => '2026-01-01T00:00:00.000Z' + }); + + const entry = report.suggestions.find((item) => item.testPath === 'tests/unit/lib.test.js'); + assert(entry, 'expected lib.test.js suggestion'); + assert(entry.witnessPath, 'expected witnessPath to be present'); + assert(entry.witnessPath.nodes.length >= 2); + assert.equal(entry.fidelity?.graphDistance, 1); + assert.deepEqual(entry.fidelity?.reasonCodes, []); +} + +{ + const deterministicGraph = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { + nodeCount: 2, + edgeCount: 1, + nodes: [ + { id: 'tests/a.test.js', file: 'tests/a.test.js', out: ['src/lib.js'], in: [] }, + { id: 'src/lib.js', file: 'src/lib.js', out: [], in: ['tests/a.test.js'] } + ] + } + }; + const now = () => '2026-01-01T00:00:00.000Z'; + const reportA = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: deterministicGraph, + tests: ['tests/a.test.js'], + caps: { maxSuggestions: 5 }, + indexCompatKey: 'compat-suggest-tests-determinism', + now + }); + const reportB = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: deterministicGraph, + tests: ['tests/a.test.js'], + caps: { maxSuggestions: 5 }, + indexCompatKey: 'compat-suggest-tests-determinism', + now + }); + assert.deepStrictEqual(reportA, reportB); +} + +{ + const boundedGraph = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { + nodeCount: 4, + edgeCount: 3, + nodes: [ + { id: 'tests/a.test.js', file: 'tests/a.test.js', out: ['src/lib.js'], in: [] }, + { id: 'tests/b.test.js', file: 'tests/b.test.js', out: ['src/lib.js'], in: [] }, + { id: 'tests/c.test.js', file: 'tests/c.test.js', out: ['src/lib.js'], in: [] }, + { id: 'src/lib.js', file: 'src/lib.js', out: [], in: ['tests/a.test.js', 'tests/b.test.js', 'tests/c.test.js'] } + ] + } + }; + + const report = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: boundedGraph, + tests: ['tests/a.test.js', 'tests/b.test.js', 'tests/c.test.js'], + caps: { maxSuggestions: 1 }, + indexCompatKey: 'compat-suggest-tests-bounded', + now: () => '2026-01-01T00:00:00.000Z' + }); + + assert.strictEqual(report.suggestions.length, 1); + assert.equal(report.fidelity?.source, 'graph'); + assert.equal(report.fidelity?.state, 'complete'); + assert(Array.isArray(report.truncation) && report.truncation.some((entry) => entry.cap === 'maxSuggestions')); +} + +console.log('suggest-tests contract matrix test passed'); diff --git a/tests/tooling/test-selection/suggest-tests-basic.test.js b/tests/tooling/test-selection/suggest-tests-basic.test.js deleted file mode 100644 index 439a51423..000000000 --- a/tests/tooling/test-selection/suggest-tests-basic.test.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixtureRoot = path.resolve(__dirname, '..', '..', 'fixtures', 'tooling', 'suggest-tests'); -const graphPath = path.join(fixtureRoot, 'graph-relations.json'); -const graphRelations = JSON.parse(fs.readFileSync(graphPath, 'utf8')); - -const report = buildSuggestTestsReport({ - changed: ['src/lib.js'], - graphRelations, - repoRoot: fixtureRoot, - caps: { maxSuggestions: 5, maxCandidates: 10 }, - indexCompatKey: 'compat-suggest-tests-basic', - now: () => '2026-01-01T00:00:00.000Z' -}); - -const suggested = report.suggestions.map((entry) => entry.testPath); -assert( - suggested.includes('tests/unit/lib.test.js'), - 'expected lib.test.js to be suggested' -); - -console.log('suggest-tests basic test passed'); diff --git a/tests/tooling/test-selection/suggest-tests-bounded.test.js b/tests/tooling/test-selection/suggest-tests-bounded.test.js deleted file mode 100644 index 9199cfadd..000000000 --- a/tests/tooling/test-selection/suggest-tests-bounded.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; - -const graphRelations = { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { - nodeCount: 4, - edgeCount: 3, - nodes: [ - { id: 'tests/a.test.js', file: 'tests/a.test.js', out: ['src/lib.js'], in: [] }, - { id: 'tests/b.test.js', file: 'tests/b.test.js', out: ['src/lib.js'], in: [] }, - { id: 'tests/c.test.js', file: 'tests/c.test.js', out: ['src/lib.js'], in: [] }, - { id: 'src/lib.js', file: 'src/lib.js', out: [], in: ['tests/a.test.js', 'tests/b.test.js', 'tests/c.test.js'] } - ] - } -}; - -const report = buildSuggestTestsReport({ - changed: ['src/lib.js'], - graphRelations, - tests: ['tests/a.test.js', 'tests/b.test.js', 'tests/c.test.js'], - caps: { maxSuggestions: 1 }, - indexCompatKey: 'compat-suggest-tests-bounded', - now: () => '2026-01-01T00:00:00.000Z' -}); - -assert.strictEqual(report.suggestions.length, 1, 'expected suggestions to be bounded'); -assert( - Array.isArray(report.truncation) && report.truncation.some((entry) => entry.cap === 'maxSuggestions'), - 'expected maxSuggestions truncation record' -); - -console.log('suggest-tests bounded test passed'); diff --git a/tests/tooling/test-selection/suggest-tests-determinism.test.js b/tests/tooling/test-selection/suggest-tests-determinism.test.js deleted file mode 100644 index 343d9c2d3..000000000 --- a/tests/tooling/test-selection/suggest-tests-determinism.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; - -const graphRelations = { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, - importGraph: { - nodeCount: 2, - edgeCount: 1, - nodes: [ - { id: 'tests/a.test.js', file: 'tests/a.test.js', out: ['src/lib.js'], in: [] }, - { id: 'src/lib.js', file: 'src/lib.js', out: [], in: ['tests/a.test.js'] } - ] - } -}; - -const now = () => '2026-01-01T00:00:00.000Z'; -const reportA = buildSuggestTestsReport({ - changed: ['src/lib.js'], - graphRelations, - tests: ['tests/a.test.js'], - caps: { maxSuggestions: 5 }, - indexCompatKey: 'compat-suggest-tests-determinism', - now -}); -const reportB = buildSuggestTestsReport({ - changed: ['src/lib.js'], - graphRelations, - tests: ['tests/a.test.js'], - caps: { maxSuggestions: 5 }, - indexCompatKey: 'compat-suggest-tests-determinism', - now -}); - -assert.deepStrictEqual(reportA, reportB, 'expected deterministic suggest-tests output'); - -console.log('suggest-tests determinism test passed'); diff --git a/tests/tooling/test-selection/suggest-tests-fidelity.test.js b/tests/tooling/test-selection/suggest-tests-fidelity.test.js new file mode 100644 index 000000000..6ec9fa4b7 --- /dev/null +++ b/tests/tooling/test-selection/suggest-tests-fidelity.test.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; +import { validateSuggestTests } from '../../../src/contracts/validators/analysis.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.resolve(__dirname, '..', '..', 'fixtures', 'tooling', 'suggest-tests'); +const graphPath = path.join(fixtureRoot, 'graph-relations.json'); +const graphRelations = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + +const graphMissing = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: null, + tests: ['tests/unit/lib.test.js'], + repoRoot: fixtureRoot, + indexCompatKey: 'compat-suggest-tests-graph-missing', + now: () => '2026-01-01T00:00:00.000Z' +}); +assert.equal(graphMissing.fidelity?.source, 'heuristic'); +assert.equal(graphMissing.fidelity?.state, 'fallback'); +assert.deepEqual(graphMissing.fidelity?.reasonCodes, ['graph_missing']); +assert.equal(graphMissing.suggestions[0]?.fidelity?.matchKind, 'name'); +assert.deepEqual(graphMissing.suggestions[0]?.fidelity?.reasonCodes, ['graph_missing']); + +const graphNoMatches = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations, + tests: ['tests/unit/lib.test.js'], + repoRoot: fixtureRoot, + caps: { + maxDepth: 0 + }, + indexCompatKey: 'compat-suggest-tests-graph-no-match', + now: () => '2026-01-01T00:00:00.000Z' +}); +assert.equal(graphNoMatches.fidelity?.source, 'heuristic'); +assert.equal(graphNoMatches.fidelity?.state, 'fallback'); +assert.deepEqual(graphNoMatches.fidelity?.reasonCodes, ['graph_no_matches']); +assert.deepEqual(graphNoMatches.suggestions[0]?.fidelity?.reasonCodes, ['graph_no_matches']); + +const traversalCapped = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + callGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + usageGraph: { nodeCount: 0, edgeCount: 0, nodes: [] }, + importGraph: { + nodeCount: 3, + edgeCount: 2, + nodes: [ + { id: 'tests/unit/lib.test.js', file: 'tests/unit/lib.test.js', out: ['src/support.js'], in: [] }, + { id: 'src/support.js', file: 'src/support.js', out: ['src/lib.js'], in: ['tests/unit/lib.test.js'] }, + { id: 'src/lib.js', file: 'src/lib.js', out: [], in: ['src/support.js'] } + ] + } + }, + tests: ['tests/unit/lib.test.js'], + repoRoot: fixtureRoot, + caps: { + maxEdges: 1 + }, + indexCompatKey: 'compat-suggest-tests-traversal-capped', + now: () => '2026-01-01T00:00:00.000Z' +}); +assert.equal(traversalCapped.fidelity?.source, 'heuristic'); +assert.equal(traversalCapped.fidelity?.state, 'fallback'); +assert.deepEqual(traversalCapped.fidelity?.reasonCodes, ['graph_no_matches', 'traversal_capped']); +assert.deepEqual( + traversalCapped.suggestions[0]?.fidelity?.reasonCodes, + ['graph_no_matches', 'traversal_capped'] +); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'suggest-tests-fidelity-')); +fs.mkdirSync(path.join(tempRoot, 'src'), { recursive: true }); +fs.mkdirSync(path.join(tempRoot, 'tests'), { recursive: true }); +fs.writeFileSync(path.join(tempRoot, 'src', 'lib.js'), 'export const lib = 1;\n', 'utf8'); +fs.writeFileSync(path.join(tempRoot, 'tests', 'lib.test.js'), 'test("lib", () => {});\n', 'utf8'); +fs.writeFileSync(path.join(tempRoot, 'tests', 'other.test.js'), 'test("other", () => {});\n', 'utf8'); + +const candidateTruncated = buildSuggestTestsReport({ + changed: ['src/lib.js'], + graphRelations: null, + repoRoot: tempRoot, + caps: { + maxCandidates: 1 + }, + indexCompatKey: 'compat-suggest-tests-candidate-truncated', + now: () => '2026-01-01T00:00:00.000Z' +}); +assert.equal(candidateTruncated.fidelity?.source, 'heuristic'); +assert.equal(candidateTruncated.fidelity?.state, 'fallback'); +assert.deepEqual(candidateTruncated.fidelity?.reasonCodes, ['graph_missing', 'candidate_truncated']); +assert.deepEqual( + candidateTruncated.suggestions[0]?.fidelity?.reasonCodes, + ['graph_missing', 'candidate_truncated'] +); + +const validation = validateSuggestTests(candidateTruncated); +assert.equal(validation.ok, true, `expected fidelity-rich suggest-tests output to validate: ${validation.errors.join(', ')}`); + +console.log('suggest-tests fidelity test passed'); diff --git a/tests/tooling/test-selection/suggest-tests-witness-path.test.js b/tests/tooling/test-selection/suggest-tests-witness-path.test.js deleted file mode 100644 index 5d0fc72e8..000000000 --- a/tests/tooling/test-selection/suggest-tests-witness-path.test.js +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildSuggestTestsReport } from '../../../src/graph/suggest-tests.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixtureRoot = path.resolve(__dirname, '..', '..', 'fixtures', 'tooling', 'suggest-tests'); -const graphPath = path.join(fixtureRoot, 'graph-relations.json'); -const graphRelations = JSON.parse(fs.readFileSync(graphPath, 'utf8')); - -const report = buildSuggestTestsReport({ - changed: ['src/lib.js'], - graphRelations, - repoRoot: fixtureRoot, - caps: { maxSuggestions: 5, maxCandidates: 10 }, - indexCompatKey: 'compat-suggest-tests-witness', - now: () => '2026-01-01T00:00:00.000Z' -}); - -const entry = report.suggestions.find((item) => item.testPath === 'tests/unit/lib.test.js'); -assert(entry, 'expected lib.test.js suggestion'); -assert(entry.witnessPath, 'expected witnessPath to be present'); -assert(entry.witnessPath.nodes.length >= 2, 'expected witness path to include at least two nodes'); - -console.log('suggest-tests witness path test passed'); diff --git a/tests/tooling/testing/search-debug-capture.test.js b/tests/tooling/testing/search-debug-capture.test.js new file mode 100644 index 000000000..4eaab6197 --- /dev/null +++ b/tests/tooling/testing/search-debug-capture.test.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + captureCommandDebugRun, + captureCommandTerminalRun +} from '../../../tools/testing/search-debug-capture.js'; +import { runCaptureSearchDebugCli } from '../../../tools/testing/capture-search-debug.js'; + +const root = process.cwd(); +const logsRoot = path.join(root, '.testLogs', 'search-debug-tests', `${process.pid}-${Date.now()}`); +await fs.rm(logsRoot, { recursive: true, force: true }); +await fs.mkdir(logsRoot, { recursive: true }); + +const genericCapture = await captureCommandDebugRun({ + command: process.execPath, + args: [ + '-e', + 'process.stdout.write("\\u001b[32mgreen\\u001b[0m\\n");process.stderr.write("\\u001b[31mred\\u001b[0m\\n");' + ], + cwd: root, + logsRoot, + label: 'ansi-generic', + env: { + ...process.env, + COLUMNS: '188', + LINES: '30' + }, + trackedEnvKeys: ['COLUMNS', 'LINES'] +}); + +assert.equal(genericCapture.status, 'passed', 'expected generic capture command to succeed'); +assert.match( + path.basename(genericCapture.outputDir), + /ansi-generic-188x30-/u, + 'expected output directory name to include terminal size suffix' +); +const stdoutRaw = await fs.readFile(genericCapture.files.stdoutRaw, 'utf8'); +const stderrRaw = await fs.readFile(genericCapture.files.stderrRaw, 'utf8'); +const stdoutPlain = await fs.readFile(genericCapture.files.stdoutPlain, 'utf8'); +const stderrPlain = await fs.readFile(genericCapture.files.stderrPlain, 'utf8'); +const combinedRaw = await fs.readFile(genericCapture.files.combinedRaw, 'utf8'); +const eventsJsonl = await fs.readFile(genericCapture.files.eventsJsonl, 'utf8'); + +assert.match(stdoutRaw, /\u001b\[32m/u, 'expected raw stdout log to preserve ANSI codes'); +assert.match(stderrRaw, /\u001b\[31m/u, 'expected raw stderr log to preserve ANSI codes'); +assert.doesNotMatch(stdoutPlain, /\u001b\[/u, 'expected stripped stdout log to remove ANSI codes'); +assert.doesNotMatch(stderrPlain, /\u001b\[/u, 'expected stripped stderr log to remove ANSI codes'); +assert.match(combinedRaw, /stdout/u, 'expected combined log to annotate stdout chunks'); +assert.match(combinedRaw, /stderr/u, 'expected combined log to annotate stderr chunks'); +assert.ok(eventsJsonl.trim().split(/\r?\n/u).length >= 2, 'expected event log entries for both streams'); + +const ptyCapture = await captureCommandTerminalRun({ + command: process.execPath, + args: [ + '-e', + 'process.stdout.write("\\u001b[36mpty\\u001b[0m\\n");' + ], + cwd: root, + logsRoot, + label: 'ansi-pty', + env: { + ...process.env, + COLUMNS: '72', + LINES: '20' + }, + trackedEnvKeys: ['COLUMNS', 'LINES'] +}); + +assert.equal(ptyCapture.status, 'passed', 'expected PTY capture command to succeed'); +assert.equal(ptyCapture.mode, 'pty', 'expected PTY capture metadata mode'); +assert.match( + path.basename(ptyCapture.outputDir), + /ansi-pty-72x20-/u, + 'expected PTY output directory name to include terminal size suffix' +); +const terminalRaw = await fs.readFile(ptyCapture.files.terminalRaw, 'utf8'); +const terminalPlain = await fs.readFile(ptyCapture.files.terminalPlain, 'utf8'); +const screenTxt = await fs.readFile(ptyCapture.files.screenTxt, 'utf8'); +const screenJson = JSON.parse(await fs.readFile(ptyCapture.files.screenJson, 'utf8')); +assert.match(terminalRaw, /\u001b\[36m/u, 'expected PTY raw log to preserve ANSI codes'); +assert.doesNotMatch(terminalPlain, /\u001b\[/u, 'expected PTY plain log to strip ANSI codes'); +assert.equal(screenTxt.trim(), 'pty', 'expected reconstructed PTY screen to preserve visible terminal content'); +assert.equal(screenJson.usedWidth >= 3, true, 'expected PTY screen summary to record visible width'); + +const alignedPtyCapture = await captureCommandTerminalRun({ + command: process.execPath, + args: [ + '-e', + 'process.stdout.write("\\u001b[2J\\u001b[HSearch Results\\u001b[20Celapsed 10ms\\r\\n");' + ], + cwd: root, + logsRoot, + label: 'aligned-pty', + env: { + ...process.env, + COLUMNS: '60', + LINES: '12' + }, + trackedEnvKeys: ['COLUMNS', 'LINES'] +}); +const alignedScreen = await fs.readFile(alignedPtyCapture.files.screenTxt, 'utf8'); +assert.match(alignedScreen, /Search Results\s+elapsed 10ms/u, 'expected reconstructed PTY screen to preserve aligned spacing'); + +const cliLabel = 'search-help'; +const beforeEntries = new Set(await fs.readdir(logsRoot)); +const cliExitCode = await runCaptureSearchDebugCli([ + '--logs-root', + logsRoot, + '--label', + cliLabel, + '--', + '--help' +]); + +assert.equal(cliExitCode, 0, 'expected capture-search-debug CLI to succeed for search --help'); +const afterEntries = (await fs.readdir(logsRoot)).filter((entry) => !beforeEntries.has(entry)); +assert.equal(afterEntries.length, 1, 'expected one new search debug capture directory'); +const cliDir = path.join(logsRoot, afterEntries[0]); +const cliMeta = JSON.parse(await fs.readFile(path.join(cliDir, 'meta.json'), 'utf8')); +assert.equal(cliMeta.status, 'passed', 'expected search debug capture status=passed'); +assert.equal(cliMeta.command, process.execPath, 'expected search capture to spawn node'); +assert.equal( + path.basename(String(cliMeta.args?.[0] || '')), + 'search.js', + 'expected search capture to invoke search.js' +); +const cliStdoutRaw = await fs.readFile(cliMeta.files.stdoutRaw, 'utf8'); +const cliStderrRaw = await fs.readFile(cliMeta.files.stderrRaw, 'utf8'); +assert.equal( + cliStdoutRaw.length > 0 || cliStderrRaw.length > 0, + true, + 'expected search debug capture to persist search output' +); + +const ptyCliLabel = 'search-help-pty'; +const beforePtyEntries = new Set(await fs.readdir(logsRoot)); +const ptyCliExitCode = await runCaptureSearchDebugCli([ + '--logs-root', + logsRoot, + '--label', + ptyCliLabel, + '--pty', + '--', + '--help' +]); +assert.equal(ptyCliExitCode, 0, 'expected PTY capture-search-debug CLI to succeed for search --help'); +const afterPtyEntries = (await fs.readdir(logsRoot)).filter((entry) => !beforePtyEntries.has(entry)); +assert.equal(afterPtyEntries.length, 1, 'expected one new PTY search debug capture directory'); +const ptyCliDir = path.join(logsRoot, afterPtyEntries[0]); +const ptyCliMeta = JSON.parse(await fs.readFile(path.join(ptyCliDir, 'meta.json'), 'utf8')); +assert.equal(ptyCliMeta.mode, 'pty', 'expected PTY search debug capture status mode=pty'); +assert.equal(typeof ptyCliMeta.files.terminalRaw, 'string', 'expected PTY capture to persist terminal transcript'); +assert.equal(typeof ptyCliMeta.files.screenTxt, 'string', 'expected PTY capture to persist reconstructed screen'); + +console.log('search debug capture test passed'); diff --git a/tests/tooling/testing/search-showcase-fixture.test.js b/tests/tooling/testing/search-showcase-fixture.test.js new file mode 100644 index 000000000..9cb86b2db --- /dev/null +++ b/tests/tooling/testing/search-showcase-fixture.test.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + buildSearchShowcaseReviewReport, + loadSearchShowcaseDataset, + selectSearchShowcaseCases, + resolveTerminalSizeMatrix +} from '../../../tools/testing/run-search-showcase.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const root = process.cwd(); +const fixtureDir = path.join(root, 'tests', 'fixtures', 'pairofcleats-search-showcase'); +const showcasePath = path.join(fixtureDir, 'showcase.json'); +const displayReviewPath = path.join(fixtureDir, 'display-review.json'); +const evalPath = path.join(fixtureDir, 'eval.json'); + +assert.equal(fs.existsSync(showcasePath), true, 'expected showcase dataset to exist'); +assert.equal(fs.existsSync(displayReviewPath), true, 'expected display review dataset to exist'); +assert.equal(fs.existsSync(evalPath), true, 'expected eval dataset to exist'); + +const { dataset } = loadSearchShowcaseDataset(showcasePath); +assert.equal(dataset.tool, 'pairofcleats'); +assert.equal(dataset.schemaVersion, 1); +assert.ok(Array.isArray(dataset.featureSets) && dataset.featureSets.length >= 4, 'expected feature sets'); +assert.ok(Array.isArray(dataset.cases) && dataset.cases.length >= 20, 'expected a broad case catalog'); + +const ids = new Set(); +for (const entry of dataset.cases) { + assert.equal(typeof entry.id, 'string', 'expected case id'); + assert.ok( + typeof entry.query === 'string' || (Array.isArray(entry.commandArgs) && entry.commandArgs.length > 0), + `expected query or commandArgs for ${entry.id}` + ); + if (typeof entry.query === 'string') { + assert.equal(typeof entry.mode, 'string', `expected mode for ${entry.id}`); + } + assert.equal(typeof entry.category, 'string', `expected category for ${entry.id}`); + assert.equal(typeof entry.stability, 'string', `expected stability for ${entry.id}`); + assert.equal(ids.has(entry.id), false, `duplicate showcase case id: ${entry.id}`); + ids.add(entry.id); +} + +const stableCases = selectSearchShowcaseCases(dataset, {}); +assert.ok(stableCases.length >= 10, 'expected stable default cases'); + +const allCases = selectSearchShowcaseCases(dataset, { + includeOptional: true, + includeExploratory: true +}); +assert.equal(allCases.length, dataset.cases.length, 'expected full selection to include every case'); + +const defaultSizes = resolveTerminalSizeMatrix(); +assert.ok(defaultSizes.length >= 6, 'expected a useful default terminal matrix'); +assert.equal(defaultSizes[0].id, 'default', 'expected matrix to start with default terminal size'); +assert.ok(defaultSizes.some((entry) => entry.id === '188x30'), 'expected large showcase terminal size 188x30'); + +const overriddenSizes = resolveTerminalSizeMatrix(['default', '80x24', '188x30']); +assert.deepEqual( + overriddenSizes.map((entry) => entry.id), + ['default', '80x24', '188x30'], + 'expected explicit terminal sizes to preserve order' +); + +const evalCases = JSON.parse(fs.readFileSync(evalPath, 'utf8')); +assert.ok(Array.isArray(evalCases) && evalCases.length >= 8, 'expected stable eval subset'); + +const runShowcase = (args) => runNode( + [path.join(root, 'tools', 'testing', 'run-search-showcase.js'), ...args], + 'search showcase fixture', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe' } +); + +const listRun = runShowcase([ + '--dataset', + showcasePath, + '--list' +]); +assert.equal(listRun.status, 0, `expected --list to succeed: ${listRun.stderr}`); +assert.match(listRun.stdout, /code-parse-search-args/, 'expected list output to include a stable case'); +assert.match(listRun.stdout, /output-json-compact-score-breakdown/, 'expected list output to include an output case'); + +const ptyListRun = runShowcase([ + '--dataset', + showcasePath, + '--pty', + '--list' +]); +assert.equal(ptyListRun.status, 0, `expected --pty --list to succeed: ${ptyListRun.stderr}`); +assert.match(ptyListRun.stdout, /prose-search-pipeline/, 'expected PTY list output to include a stable prose case'); + +const { dataset: displayReview } = loadSearchShowcaseDataset(displayReviewPath); +assert.ok(Array.isArray(displayReview.cases) && displayReview.cases.length >= 12, 'expected broad display review catalog'); +assert.ok(displayReview.cases.some((entry) => entry.id === 'cli-help'), 'expected help case in display review dataset'); +assert.ok(displayReview.cases.some((entry) => entry.id === 'human-no-results'), 'expected empty-state case in display review dataset'); +assert.ok(displayReview.cases.some((entry) => entry.id === 'human-records-hit'), 'expected positive records case in display review dataset'); +assert.ok( + displayReview.cases.some((entry) => entry.id === 'human-default-mixed' && Array.isArray(entry.reviewExpect?.contains)), + 'expected review expectations on mixed human output case' +); + +const reviewReport = buildSearchShowcaseReviewReport({ + suiteDir: path.join(root, '.testLogs', 'fake-search-review'), + runs: [ + { + id: 'demo', + status: 'ok', + captureMode: 'pty', + outputDir: path.join(root, '.testLogs', 'fake-search-review', 'demo'), + terminalSize: { id: '72x20', columns: 72, lines: 20 }, + review: { + overflowCount: 2, + blankPairCount: 1, + missingExpectedCount: 1, + emptySectionCount: 0, + sectionCount: 3, + usedWidth: 72 + } + } + ] +}); +assert.equal(reviewReport.overflowCount, 2, 'expected review report to aggregate overflow counts'); +assert.equal(reviewReport.blankPairCount, 1, 'expected review report to aggregate blank pairs'); +assert.equal(reviewReport.missingExpectedCount, 1, 'expected review report to aggregate missing expected content'); +assert.equal(reviewReport.worstRuns[0].id, 'demo', 'expected review report to rank captured runs'); + +console.log('search showcase fixture test passed'); diff --git a/tests/tooling/triage/context-pack.test.js b/tests/tooling/triage/context-pack.test.js index 914750728..daccce5e5 100644 --- a/tests/tooling/triage/context-pack.test.js +++ b/tests/tooling/triage/context-pack.test.js @@ -4,8 +4,50 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { getTriageContext, run, runJson } from '../../helpers/triage.js'; +const buildTriageEvidenceRepo = async (repoRoot) => { + await fsPromises.mkdir(path.join(repoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(repoRoot, '.pairofcleats.json'), + `${JSON.stringify({ + indexing: { + embeddings: { + enabled: false + } + } + }, null, 2)}\n`, + 'utf8' + ); + await fsPromises.writeFile( + path.join(repoRoot, 'src', 'util.js'), + [ + "import addHelper from 'add-helper';", + '', + 'export function handlePublicApiInput(left, right) {', + ' return addHelper(left, right);', + '}', + '' + ].join('\n'), + 'utf8' + ); +}; + const { root, repoRoot, triageFixtureRoot, env, cacheRoot, writeTestLog } = await getTriageContext({ - name: 'triage-context-pack' + name: 'triage-context-pack', + fixtureBuilder: buildTriageEvidenceRepo, + testConfig: { + indexing: { + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { + enabled: false + } + } + } }); const ingestGeneric = runJson('ingest-generic', [ @@ -34,13 +76,8 @@ runJson('decision', [ run('build-index', [ path.join(root, 'build_index.js'), - '--stub-embeddings', - '--repo', repoRoot -], { cwd: repoRoot, env }); - -run('build-records-index', [ - path.join(root, 'build_index.js'), - '--mode', 'records', + '--stage', 'stage2', + '--mode', 'code', '--stub-embeddings', '--repo', repoRoot ], { cwd: repoRoot, env }); diff --git a/tests/tooling/triage/decision-finding-path-safety.test.js b/tests/tooling/triage/decision-finding-path-safety.test.js index a778bb5a7..8061af69e 100644 --- a/tests/tooling/triage/decision-finding-path-safety.test.js +++ b/tests/tooling/triage/decision-finding-path-safety.test.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { getTriageContext, runJson } from '../../helpers/triage.js'; +import { runNode } from '../../helpers/run-node.js'; const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ name: 'triage-decision-finding-path-safety' @@ -30,18 +30,17 @@ await fsPromises.writeFile(escapedFindingPath, JSON.stringify({ updatedAt: new Date().toISOString() }, null, 2)); -const result = spawnSync( - process.execPath, +const result = runNode( [ path.join(root, 'tools', 'triage', 'decision.js'), '--repo', repoRoot, '--finding', '../outside-finding', '--status', 'accept' ], - { - encoding: 'utf8', - env - } + 'triage decision path safety', + root, + env, + { stdio: 'pipe', allowFailure: true } ); if (result.status === 0) { diff --git a/tests/tooling/triage/decision.test.js b/tests/tooling/triage/decision.test.js deleted file mode 100644 index 62c829d15..000000000 --- a/tests/tooling/triage/decision.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { getTriageContext, runJson } from '../../helpers/triage.js'; - -const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ - name: 'triage-decision' -}); - -const ingestGeneric = runJson('ingest-generic', [ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', 'generic', - '--in', path.join(triageFixtureRoot, 'generic.json'), - '--repo', repoRoot -], { env }); - -if (!Array.isArray(ingestGeneric.recordIds) || ingestGeneric.recordIds.length === 0) { - console.error('No records written for generic ingest.'); - process.exit(1); -} -const findingId = ingestGeneric.recordIds[0]; - -const decision = runJson('decision', [ - path.join(root, 'tools', 'triage', 'decision.js'), - '--repo', repoRoot, - '--finding', findingId, - '--status', 'accept', - '--justification', 'Fixture decision for tests', - '--reviewer', 'qa@example.com' -], { env }); - -if (decision.status !== 'accept') { - console.error('Decision output missing accept status.'); - process.exit(1); -} -if (!decision.jsonPath || !fs.existsSync(decision.jsonPath)) { - console.error('Decision JSON output missing on disk.'); - process.exit(1); -} - -console.log('Triage decision ok.'); diff --git a/tests/tooling/triage/ingest-decision-contract-matrix.test.js b/tests/tooling/triage/ingest-decision-contract-matrix.test.js new file mode 100644 index 000000000..f1555ad99 --- /dev/null +++ b/tests/tooling/triage/ingest-decision-contract-matrix.test.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { getTriageContext, runJson } from '../../helpers/triage.js'; + +const runLegacyWrapperCase = async () => { + const { root, repoRoot, cacheRoot, env } = await getTriageContext({ + name: 'triage-ingest-legacy-wrapper-matrix' + }); + + const inputPath = path.join(cacheRoot, 'legacy-wrapper.json'); + const payload = { + metadata: ['ignore-this-array'], + findings: [{ + recordType: 'finding', + source: 'generic', + stableKey: 'legacy-wrapper-findings-1', + service: 'api', + env: 'prod', + vuln: { + vulnId: 'CVE-2024-9999', + title: 'Legacy findings payload compatibility check', + description: 'Verify ingest selects findings array over unrelated arrays.', + severity: 'high' + } + }] + }; + + await fsPromises.writeFile(inputPath, JSON.stringify(payload, null, 2)); + const ingestResult = runJson('ingest-legacy-wrapper', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'generic', + '--in', inputPath, + '--repo', repoRoot + ], { env }); + + assert.equal(ingestResult.written, 1); + assert.ok(Array.isArray(ingestResult.recordIds) && ingestResult.recordIds.length === 1); + + const recordPath = ingestResult.records?.[0]?.jsonPath + || path.join(ingestResult.recordsDir, `${ingestResult.recordIds[0]}.json`); + const stored = JSON.parse(await fsPromises.readFile(recordPath, 'utf8')); + assert.equal(stored.stableKey, 'legacy-wrapper-findings-1'); + assert.equal(stored.vuln?.title, 'Legacy findings payload compatibility check'); +}; + +const runGenericExposureCase = async () => { + const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ + name: 'triage-ingest-generic-matrix' + }); + + const ingestGeneric = runJson('ingest-generic', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'generic', + '--in', path.join(triageFixtureRoot, 'generic.json'), + '--repo', repoRoot, + '--meta', 'service=api', + '--meta', 'env=prod' + ], { env }); + + assert.ok(Array.isArray(ingestGeneric.recordIds) && ingestGeneric.recordIds.length > 0); + const findingId = ingestGeneric.recordIds[0]; + const recordJsonPath = ingestGeneric.records?.[0]?.jsonPath + || path.join(ingestGeneric.recordsDir, `${findingId}.json`); + const recordMdPath = ingestGeneric.records?.[0]?.mdPath + || path.join(ingestGeneric.recordsDir, `${findingId}.md`); + + const storedRecord = JSON.parse(await fsPromises.readFile(recordJsonPath, 'utf8')); + assert.equal(storedRecord.exposure?.internetExposed, true); + assert.equal(storedRecord.idProvenance?.method, 'stable-key'); + assert.equal(storedRecord.idProvenance?.source, 'stableKey'); + + const recordMarkdown = await fsPromises.readFile(recordMdPath, 'utf8'); + assert.ok(recordMarkdown.includes('## Exposure')); + assert.ok(recordMarkdown.includes('Internet exposed')); + assert.ok(recordMarkdown.includes('## Record identity')); + assert.ok(recordMarkdown.includes('Method: stable-key')); +}; + +const runDecisionCase = async () => { + const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ + name: 'triage-decision-matrix' + }); + + const ingestGeneric = runJson('ingest-generic', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'generic', + '--in', path.join(triageFixtureRoot, 'generic.json'), + '--repo', repoRoot + ], { env }); + + assert.ok(Array.isArray(ingestGeneric.recordIds) && ingestGeneric.recordIds.length > 0); + const findingId = ingestGeneric.recordIds[0]; + + const decision = runJson('decision', [ + path.join(root, 'tools', 'triage', 'decision.js'), + '--repo', repoRoot, + '--finding', findingId, + '--status', 'accept', + '--justification', 'Fixture decision for tests', + '--reviewer', 'qa@example.com' + ], { env }); + + assert.equal(decision.status, 'accept'); + assert.ok(decision.jsonPath && fs.existsSync(decision.jsonPath)); +}; + +const runSourceMatrixCase = async () => { + const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ + name: 'triage-ingest-sources-matrix' + }); + + const dependabot = runJson('ingest-dependabot', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'dependabot', + '--in', path.join(triageFixtureRoot, 'dependabot.json'), + '--repo', repoRoot, + '--meta', 'service=api', + '--meta', 'env=prod' + ], { env }); + assert.ok(Array.isArray(dependabot.recordIds) && dependabot.recordIds.length > 0); + + const inspector = runJson('ingest-inspector', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'aws_inspector', + '--in', path.join(triageFixtureRoot, 'inspector.json'), + '--repo', repoRoot, + '--meta', 'service=api', + '--meta', 'env=prod' + ], { env }); + assert.ok(Array.isArray(inspector.recordIds) && inspector.recordIds.length > 0); +}; + +await runLegacyWrapperCase(); +await runGenericExposureCase(); +await runDecisionCase(); +await runSourceMatrixCase(); +console.log('tooling triage ingest/decision contract matrix test passed'); diff --git a/tests/tooling/triage/ingest-generic.exposure.test.js b/tests/tooling/triage/ingest-generic.exposure.test.js deleted file mode 100644 index 2a6bfe331..000000000 --- a/tests/tooling/triage/ingest-generic.exposure.test.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { getTriageContext, runJson } from '../../helpers/triage.js'; - -const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ - name: 'triage-ingest-generic' -}); - -const ingestGeneric = runJson('ingest-generic', [ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', 'generic', - '--in', path.join(triageFixtureRoot, 'generic.json'), - '--repo', repoRoot, - '--meta', 'service=api', - '--meta', 'env=prod' -], { env }); - -if (!Array.isArray(ingestGeneric.recordIds) || ingestGeneric.recordIds.length === 0) { - console.error('No records written for generic ingest.'); - process.exit(1); -} - -const findingId = ingestGeneric.recordIds[0]; -const recordJsonPath = ingestGeneric.records?.[0]?.jsonPath - || path.join(ingestGeneric.recordsDir, `${findingId}.json`); -const recordMdPath = ingestGeneric.records?.[0]?.mdPath - || path.join(ingestGeneric.recordsDir, `${findingId}.md`); - -const storedRecord = JSON.parse(await fsPromises.readFile(recordJsonPath, 'utf8')); -if (!storedRecord.exposure || storedRecord.exposure.internetExposed !== true) { - console.error('Exposure metadata not preserved in stored record.'); - process.exit(1); -} -const recordMarkdown = await fsPromises.readFile(recordMdPath, 'utf8'); -if (!recordMarkdown.includes('## Exposure') || !recordMarkdown.includes('Internet exposed')) { - console.error('Exposure metadata not rendered in record markdown.'); - process.exit(1); -} - -console.log('Triage ingest generic exposure ok.'); diff --git a/tests/tooling/triage/ingest-jsonl-fidelity.test.js b/tests/tooling/triage/ingest-jsonl-fidelity.test.js new file mode 100644 index 000000000..b140714d4 --- /dev/null +++ b/tests/tooling/triage/ingest-jsonl-fidelity.test.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getTriageContext, runJson } from '../../helpers/triage.js'; + +const { root, repoRoot, cacheRoot, env } = await getTriageContext({ + name: 'triage-ingest-jsonl-fidelity' +}); + +const inputPath = path.join(cacheRoot, 'mixed.jsonl'); +await fsPromises.writeFile(inputPath, [ + JSON.stringify({ + source: 'generic', + recordType: 'finding', + stableKey: 'jsonl-stable-1', + title: 'valid stable record' + }), + '{"source":"generic","recordType":"finding","title":"broken"', + JSON.stringify({ + source: 'generic', + recordType: 'finding', + title: 'fallback hash record' + }) +].join('\n')); + +const result = runJson('ingest-jsonl-fidelity', [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'generic', + '--in', inputPath, + '--repo', repoRoot +], { env }); + +if (result.written !== 2 || result.errors !== 0) { + console.error(`Expected 2 written records and 0 ingest errors; got written=${result.written} errors=${result.errors}`); + process.exit(1); +} +if (result.ingestAudit?.policy !== 'permissive' || result.ingestAudit?.inputFormat !== 'jsonl') { + console.error('Expected permissive JSONL ingest audit.'); + process.exit(1); +} +if (result.ingestAudit?.totalRecordsSeen !== 3 || result.ingestAudit?.parsedRecords !== 2) { + console.error('Unexpected JSONL audit totals.'); + process.exit(1); +} +if (result.ingestAudit?.malformedRecords !== 1 || result.ingestAudit?.skippedRecords !== 1) { + console.error('Expected one malformed/skipped JSONL record.'); + process.exit(1); +} +if (result.ingestAudit?.fallbackIdCount !== 1 || result.ingestAudit?.stableIdCount !== 1) { + console.error('Expected one fallback ID and one stable ID in ingest audit.'); + process.exit(1); +} +if (result.ingestAudit?.idStability !== 'mixed-lower-trust') { + console.error(`Expected mixed-lower-trust idStability; got ${result.ingestAudit?.idStability}`); + process.exit(1); +} +if (!Array.isArray(result.ingestAudit?.malformedLineDiagnostics) || result.ingestAudit.malformedLineDiagnostics.length !== 1) { + console.error('Expected one malformed line diagnostic.'); + process.exit(1); +} +if (result.ingestAudit.malformedLineDiagnostics[0]?.lineNumber !== 2) { + console.error('Expected malformed JSONL line diagnostic for line 2.'); + process.exit(1); +} + +const provenanceMethods = new Set(result.records.map((entry) => entry?.idProvenance?.method)); +if (!provenanceMethods.has('stable-key') || !provenanceMethods.has('fallback-hash')) { + console.error('Expected both stable-key and fallback-hash record provenance.'); + process.exit(1); +} + +console.log('Triage ingest JSONL fidelity ok.'); diff --git a/tests/tooling/triage/ingest-jsonl-strict.test.js b/tests/tooling/triage/ingest-jsonl-strict.test.js new file mode 100644 index 000000000..941b48a2c --- /dev/null +++ b/tests/tooling/triage/ingest-jsonl-strict.test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { getTriageContext } from '../../helpers/triage.js'; +import { runNode } from '../../helpers/run-node.js'; + +const { root, repoRoot, cacheRoot, env } = await getTriageContext({ + name: 'triage-ingest-jsonl-strict' +}); + +const inputPath = path.join(cacheRoot, 'mixed-strict.jsonl'); +await fsPromises.writeFile(inputPath, [ + JSON.stringify({ + source: 'generic', + recordType: 'finding', + stableKey: 'jsonl-strict-1', + title: 'valid stable record' + }), + '{"source":"generic","recordType":"finding","title":"broken"', + JSON.stringify({ + source: 'generic', + recordType: 'finding', + title: 'fallback hash record' + }) +].join('\n')); + +const result = runNode( + [ + path.join(root, 'tools', 'triage', 'ingest.js'), + '--source', 'generic', + '--in', inputPath, + '--repo', repoRoot, + '--strict' + ], + 'triage ingest strict JSONL', + repoRoot, + env, + { stdio: 'pipe', allowFailure: true } +); + +if (result.status === 0) { + console.error('Expected strict ingest to fail on malformed JSONL input.'); + process.exit(1); +} + +let payload = null; +try { + payload = JSON.parse(result.stderr || '{}'); +} catch (error) { + console.error(`Failed to parse strict-ingest error payload: ${error?.message || error}`); + process.exit(1); +} + +if (payload?.code !== 'ERR_TRIAGE_INGEST_STRICT_INPUT') { + console.error(`Expected strict ingest error code ERR_TRIAGE_INGEST_STRICT_INPUT; got ${payload?.code}`); + process.exit(1); +} +if (payload?.ingestAudit?.policy !== 'strict' || payload?.ingestAudit?.inputFormat !== 'jsonl') { + console.error('Expected strict JSONL ingest audit in error payload.'); + process.exit(1); +} +if (payload?.ingestAudit?.malformedRecords !== 1 || payload?.ingestAudit?.skippedRecords !== 1) { + console.error('Expected one malformed/skipped JSONL record in strict audit.'); + process.exit(1); +} +if (!Array.isArray(payload?.ingestAudit?.malformedLineDiagnostics) || payload.ingestAudit.malformedLineDiagnostics[0]?.lineNumber !== 2) { + console.error('Expected strict ingest error payload to include malformed line 2 diagnostics.'); + process.exit(1); +} + +console.log('Triage ingest strict JSONL ok.'); diff --git a/tests/tooling/triage/ingest-legacy-wrapper-compat.test.js b/tests/tooling/triage/ingest-legacy-wrapper-compat.test.js deleted file mode 100644 index ce6b82647..000000000 --- a/tests/tooling/triage/ingest-legacy-wrapper-compat.test.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fsPromises from 'node:fs/promises'; -import path from 'node:path'; -import { getTriageContext, runJson } from '../../helpers/triage.js'; - -const { root, repoRoot, cacheRoot, env } = await getTriageContext({ - name: 'triage-ingest-legacy-wrapper' -}); - -const inputPath = path.join(cacheRoot, 'legacy-wrapper.json'); -const payload = { - metadata: ['ignore-this-array'], - findings: [ - { - recordType: 'finding', - source: 'generic', - stableKey: 'legacy-wrapper-findings-1', - service: 'api', - env: 'prod', - vuln: { - vulnId: 'CVE-2024-9999', - title: 'Legacy findings payload compatibility check', - description: 'Verify ingest selects findings array over unrelated arrays.', - severity: 'high' - } - } - ] -}; - -await fsPromises.writeFile(inputPath, JSON.stringify(payload, null, 2)); - -const ingestResult = runJson('ingest-legacy-wrapper', [ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', 'generic', - '--in', inputPath, - '--repo', repoRoot -], { env }); - -assert.equal(ingestResult.written, 1, 'expected exactly one ingested record'); -assert.ok(Array.isArray(ingestResult.recordIds) && ingestResult.recordIds.length === 1, 'expected one record id'); - -const recordPath = ingestResult.records?.[0]?.jsonPath - || path.join(ingestResult.recordsDir, `${ingestResult.recordIds[0]}.json`); -const stored = JSON.parse(await fsPromises.readFile(recordPath, 'utf8')); - -assert.equal(stored.stableKey, 'legacy-wrapper-findings-1', 'expected record from findings array'); -assert.equal( - stored.vuln?.title, - 'Legacy findings payload compatibility check', - 'expected vulnerability details from findings payload' -); - -console.log('Triage ingest legacy wrapper compatibility ok.'); diff --git a/tests/tooling/triage/ingest-sources.smoke.test.js b/tests/tooling/triage/ingest-sources.smoke.test.js deleted file mode 100644 index 3ee831325..000000000 --- a/tests/tooling/triage/ingest-sources.smoke.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { getTriageContext, runJson } from '../../helpers/triage.js'; - -const { root, repoRoot, triageFixtureRoot, env } = await getTriageContext({ - name: 'triage-ingest-sources' -}); - -const dependabot = runJson('ingest-dependabot', [ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', 'dependabot', - '--in', path.join(triageFixtureRoot, 'dependabot.json'), - '--repo', repoRoot, - '--meta', 'service=api', - '--meta', 'env=prod' -], { env }); - -if (!Array.isArray(dependabot.recordIds) || dependabot.recordIds.length === 0) { - console.error('Dependabot ingest produced no records.'); - process.exit(1); -} - -const inspector = runJson('ingest-inspector', [ - path.join(root, 'tools', 'triage', 'ingest.js'), - '--source', 'aws_inspector', - '--in', path.join(triageFixtureRoot, 'inspector.json'), - '--repo', repoRoot, - '--meta', 'service=api', - '--meta', 'env=prod' -], { env }); - -if (!Array.isArray(inspector.recordIds) || inspector.recordIds.length === 0) { - console.error('Inspector ingest produced no records.'); - process.exit(1); -} - -console.log('Triage ingest sources ok.'); diff --git a/tests/tooling/triage/records-index-and-search.test.js b/tests/tooling/triage/records-index-and-search.test.js index 14bbfc24d..911669bb4 100644 --- a/tests/tooling/triage/records-index-and-search.test.js +++ b/tests/tooling/triage/records-index-and-search.test.js @@ -1,11 +1,43 @@ #!/usr/bin/env node import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { getTriageContext, run, runJson } from '../../helpers/triage.js'; import { getCurrentBuildInfo, getRepoCacheRoot, loadUserConfig } from '../../../tools/shared/dict-utils.js'; const { root, repoRoot, triageFixtureRoot, env, writeTestLog } = await getTriageContext({ - name: 'triage-records-index' + name: 'triage-records-index', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + riskAnalysis: false, + riskAnalysisCrossFile: false + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + }, + fixtureBuilder: async (targetRepoRoot) => { + await fsPromises.mkdir(path.join(targetRepoRoot, 'src'), { recursive: true }); + await fsPromises.writeFile( + path.join(targetRepoRoot, 'src', 'index.js'), + [ + 'export function greet(name = "world") {', + ' return `hello ${name}`;', + '}', + '' + ].join('\n'), + 'utf8' + ); + await fsPromises.writeFile( + path.join(targetRepoRoot, 'README.md'), + '# Triage records fixture\n\nminimal repo for triage records\n', + 'utf8' + ); + } }); const testEnv = { ...env }; const userConfig = loadUserConfig(repoRoot); @@ -122,12 +154,15 @@ if (!Array.isArray(ingestGeneric.recordIds) || ingestGeneric.recordIds.length == run('build-index', [ path.join(root, 'build_index.js'), '--stub-embeddings', + '--stage', 'stage1', + '--mode', 'code', '--repo', repoRoot ], { cwd: repoRoot, env: testEnv }); await logExpectedArtifacts({ label: 'post-build-index', mode: 'code' }); run('build-records-index', [ path.join(root, 'build_index.js'), + '--stage', 'stage1', '--mode', 'records', '--stub-embeddings', '--repo', repoRoot diff --git a/tests/tooling/type-inference/crossfile-budget.unit.test.js b/tests/tooling/type-inference/crossfile-budget.unit.test.js deleted file mode 100644 index 9df66a3b1..000000000 --- a/tests/tooling/type-inference/crossfile-budget.unit.test.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { ensureTestingEnv } from '../../helpers/test-env.js'; -import { applyCrossFileInference } from '../../../src/index/type-inference-crossfile.js'; - -ensureTestingEnv(process.env); - -const buildChunk = ({ file, name, uid, relations = null }) => ({ - file, - name, - kind: 'function', - chunkUid: uid, - start: 0, - end: 1, - docmeta: {}, - codeRelations: relations || {} -}); - -const targets = Array.from({ length: 320 }, (_, i) => buildChunk({ - file: `src/targets_${Math.floor(i / 40)}.js`, - name: `target_${i}`, - uid: `uid-target-${i}` -})); - -const largeCallList = targets.map((chunk) => ['caller', chunk.name]); -const largeUsageList = targets.map((chunk) => chunk.name); -const caller = buildChunk({ - file: 'src/caller.js', - name: 'caller', - uid: 'uid-caller', - relations: { - calls: largeCallList, - usages: largeUsageList - } -}); - -const fillers = Array.from({ length: 2700 }, (_, i) => buildChunk({ - file: `src/filler_${Math.floor(i / 40)}.js`, - name: `filler_${i}`, - uid: `uid-filler-${i}` -})); - -const logs = []; -const stats = await applyCrossFileInference({ - rootDir: process.cwd(), - chunks: [caller, ...targets, ...fillers], - enabled: true, - log: (line) => logs.push(String(line)), - useTooling: false, - enableTypeInference: false, - enableRiskCorrelation: false, - fileRelations: null -}); - -assert.ok(stats.linkedCalls <= 96, `expected call links to be capped, got ${stats.linkedCalls}`); -assert.ok(stats.linkedUsages <= 128, `expected usage links to be capped, got ${stats.linkedUsages}`); -assert.ok(stats.droppedCallLinks > 0, 'expected dropped call links with large repo budget'); -assert.ok(stats.droppedUsageLinks > 0, 'expected dropped usage links with large repo budget'); -assert.ok( - logs.some((line) => line.includes('[perf] cross-file budget enabled')), - 'expected cross-file budget enable log' -); - -console.log('cross-file budget unit test passed'); diff --git a/tests/tooling/type-inference/crossfile-stats.unit.test.js b/tests/tooling/type-inference/crossfile-stats.unit.test.js index cfe32e14d..593fd1cd6 100644 --- a/tests/tooling/type-inference/crossfile-stats.unit.test.js +++ b/tests/tooling/type-inference/crossfile-stats.unit.test.js @@ -65,7 +65,15 @@ const runStatsScenario = async (name, { ['linkedCalls', stats.linkedCalls, expect.linkedCalls], ['linkedUsages', stats.linkedUsages, expect.linkedUsages], ['inferredReturns', stats.inferredReturns, expect.inferredReturns], - ['riskFlows', stats.riskFlows, expect.riskFlows] + ['riskFlows', stats.riskFlows, expect.riskFlows], + ['toolingDegradedProviders', stats.toolingDegradedProviders, expect.toolingDegradedProviders ?? 0], + ['toolingDegradedWarnings', stats.toolingDegradedWarnings, expect.toolingDegradedWarnings ?? 0], + ['toolingDegradedErrors', stats.toolingDegradedErrors, expect.toolingDegradedErrors ?? 0], + ['toolingProvidersExecuted', stats.toolingProvidersExecuted, expect.toolingProvidersExecuted ?? 0], + ['toolingProvidersContributed', stats.toolingProvidersContributed, expect.toolingProvidersContributed ?? 0], + ['toolingRequests', stats.toolingRequests, expect.toolingRequests ?? 0], + ['toolingRequestFailures', stats.toolingRequestFailures, expect.toolingRequestFailures ?? 0], + ['toolingRequestTimeouts', stats.toolingRequestTimeouts, expect.toolingRequestTimeouts ?? 0] ]; for (const [label, actual, expected] of entries) { if (actual !== expected) { diff --git a/tests/tooling/type-inference/tooling-runtime-contract-matrix.test.js b/tests/tooling/type-inference/tooling-runtime-contract-matrix.test.js new file mode 100644 index 000000000..93204ffa7 --- /dev/null +++ b/tests/tooling/type-inference/tooling-runtime-contract-matrix.test.js @@ -0,0 +1,340 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { applyCrossFileInference } from '../../../src/index/type-inference-crossfile.js'; +import { + CROSS_FILE_CACHE_SCHEMA_VERSION, + readCrossFileInferenceCache, + resolveChunkIdentity +} from '../../../src/index/type-inference-crossfile/cache.js'; +import { runCrossFilePropagation } from '../../../src/index/type-inference-crossfile/propagation.js'; +import { + __resolveDefaultToolingCacheDirForTests, + runToolingPass +} from '../../../src/index/type-inference-crossfile/tooling.js'; +import { registerToolingProvider, TOOLING_PROVIDERS } from '../../../src/index/tooling/provider-registry.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, `tooling-runtime-contract-matrix-${process.pid}-${Date.now()}`); + +const buildChunk = ({ file, name, uid, relations = null }) => ({ + file, + name, + kind: 'function', + chunkUid: uid, + start: 0, + end: 1, + docmeta: {}, + codeRelations: relations || {} +}); + +const runCacheRootCases = () => { + const repoRoot = path.win32.resolve('C:\\repo\\project'); + const repoCacheBuildRoot = path.win32.resolve('C:\\cache-root\\repos\\project\\builds\\20260307T000000Z_deadbeef'); + assert.equal( + __resolveDefaultToolingCacheDirForTests({ rootDir: repoRoot, buildRoot: repoCacheBuildRoot }), + path.win32.resolve('C:\\cache-root\\repos\\project\\tooling-cache') + ); + + const explicitBuildRoot = path.win32.resolve('C:\\tmp\\explicit-index-root'); + assert.equal( + __resolveDefaultToolingCacheDirForTests({ rootDir: repoRoot, buildRoot: explicitBuildRoot }), + path.win32.resolve('C:\\repo\\project\\.build\\pairofcleats\\tooling-cache') + ); + + const posixRoot = path.posix.resolve('/repo/project'); + const posixBuildRoot = path.posix.resolve('/cache-root/repos/project/builds/20260307T000000Z_deadbeef'); + assert.equal( + __resolveDefaultToolingCacheDirForTests({ rootDir: posixRoot, buildRoot: posixBuildRoot }), + path.posix.resolve('/cache-root/repos/project/tooling-cache') + ); +}; + +const runCacheNormalizationCases = async () => { + const caseRoot = path.join(tempRoot, 'cache-normalization'); + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(caseRoot, { recursive: true }); + + const cachePath = path.join(caseRoot, 'output-cache.json'); + const chunks = [{ + chunkUid: 'uid-alpha', + file: 'src/alpha.js', + name: 'alpha', + start: 0, + end: 24, + codeRelations: {}, + docmeta: {} + }]; + const rowId = resolveChunkIdentity(chunks[0], 0); + + const writePayload = async (stats) => { + await fs.writeFile(cachePath, JSON.stringify({ + schemaVersion: CROSS_FILE_CACHE_SCHEMA_VERSION, + fingerprint: 'degraded-fingerprint', + stats, + rows: [{ + id: rowId, + codeRelations: { calls: [] }, + docmeta: { signature: 'alpha()' } + }] + }), 'utf8'); + }; + + await writePayload({ + linkedCalls: 1, + linkedUsages: 2, + inferredReturns: 3, + riskFlows: 4, + toolingDegradedProviders: 2, + toolingDegradedWarnings: 5, + toolingDegradedErrors: 1, + toolingProvidersExecuted: 3, + toolingProvidersContributed: 1, + toolingRequests: 7, + toolingRequestFailures: 2, + toolingRequestTimeouts: 1 + }); + + const withDegraded = await readCrossFileInferenceCache({ + cachePath, + chunks, + crossFileFingerprint: 'degraded-fingerprint', + log: () => {} + }); + assert.equal(withDegraded.toolingDegradedProviders, 2); + assert.equal(withDegraded.toolingDegradedWarnings, 5); + assert.equal(withDegraded.toolingDegradedErrors, 1); + assert.equal(withDegraded.toolingProvidersExecuted, 3); + assert.equal(withDegraded.toolingProvidersContributed, 1); + assert.equal(withDegraded.toolingRequests, 7); + assert.equal(withDegraded.toolingRequestFailures, 2); + assert.equal(withDegraded.toolingRequestTimeouts, 1); + + await writePayload({ + linkedCalls: 7, + linkedUsages: 8, + inferredReturns: 9, + riskFlows: 10 + }); + + const withoutDegraded = await readCrossFileInferenceCache({ + cachePath, + chunks, + crossFileFingerprint: 'degraded-fingerprint', + log: () => {} + }); + assert.equal(withoutDegraded.toolingDegradedProviders, 0); + assert.equal(withoutDegraded.toolingDegradedWarnings, 0); + assert.equal(withoutDegraded.toolingDegradedErrors, 0); + assert.equal(withoutDegraded.toolingProvidersExecuted, 0); + assert.equal(withoutDegraded.toolingProvidersContributed, 0); + assert.equal(withoutDegraded.toolingRequests, 0); + assert.equal(withoutDegraded.toolingRequestFailures, 0); + assert.equal(withoutDegraded.toolingRequestTimeouts, 0); +}; + +const runFailOpenRuntimeCase = async () => { + const caseRoot = path.join(tempRoot, 'runtime-fail-open'); + const relFile = 'lib/app.dart'; + const sourceText = 'String greet(String name) { return name; }\n'; + + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(caseRoot, 'lib'), { recursive: true }); + await fs.writeFile(path.join(caseRoot, relFile), sourceText, 'utf8'); + + const stats = await runCrossFilePropagation({ + rootDir: caseRoot, + buildRoot: caseRoot, + chunks: [{ + file: relFile, + name: 'greet', + kind: 'function', + lang: 'dart', + containerLanguageId: 'dart', + ext: '.dart', + chunkUid: 'ck64:v1:test:lib/app.dart:runtime-stats', + start: 0, + end: sourceText.length, + docmeta: { returnsValue: true }, + codeRelations: {} + }], + log: () => {}, + useTooling: true, + enableTypeInference: true, + enableRiskCorrelation: false, + toolingConfig: { + enabledTools: ['dart'], + dart: { enabled: true, cmd: 'dart-not-found' } + }, + toolingTimeoutMs: 1000, + toolingRetries: 0, + toolingBreaker: 1 + }); + + assert.equal(Number(stats.toolingProvidersExecuted) >= 1, true); + assert.equal(Number(stats.toolingDegradedProviders) >= 1, true); + assert.equal(Number(stats.toolingRequests), 0); + assert.equal(Number(stats.toolingRequestFailures), 0); + assert.equal(Number(stats.toolingRequestTimeouts), 0); +}; + +const runBudgetCase = async () => { + const targets = Array.from({ length: 320 }, (_, i) => buildChunk({ + file: `src/targets_${Math.floor(i / 40)}.js`, + name: `target_${i}`, + uid: `uid-target-${i}` + })); + + const largeCallList = targets.map((chunk) => ['caller', chunk.name]); + const largeUsageList = targets.map((chunk) => chunk.name); + const caller = buildChunk({ + file: 'src/caller.js', + name: 'caller', + uid: 'uid-caller', + relations: { + calls: largeCallList, + usages: largeUsageList + } + }); + + const fillers = Array.from({ length: 2700 }, (_, i) => buildChunk({ + file: `src/filler_${Math.floor(i / 40)}.js`, + name: `filler_${i}`, + uid: `uid-filler-${i}` + })); + + const logs = []; + const stats = await applyCrossFileInference({ + rootDir: root, + chunks: [caller, ...targets, ...fillers], + enabled: true, + log: (line) => logs.push(String(line)), + useTooling: false, + enableTypeInference: false, + enableRiskCorrelation: false, + fileRelations: null + }); + + assert.ok(stats.linkedCalls <= 96); + assert.ok(stats.linkedUsages <= 128); + assert.ok(stats.droppedCallLinks > 0); + assert.ok(stats.droppedUsageLinks > 0); + assert.ok(logs.some((line) => line.includes('[perf] cross-file budget enabled'))); +}; + +const runToolingPassCases = async () => { + TOOLING_PROVIDERS.clear(); + registerToolingProvider({ + id: 'configured-paths-fixture', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'configured-paths-fixture-v1', + async run() { + return { + byChunkUid: { + 'chunk-1': { returnType: 'number' } + }, + diagnostics: { + 'configured-paths-fixture': { + diagnosticsByChunkUid: { + 'chunk-1': [{ severity: 'info', message: 'fixture diag' }] + } + } + } + }; + } + }); + registerToolingProvider({ + id: 'doctor-scope-fixture', + version: '1.0.0', + kinds: ['types'], + capabilities: { supportsVirtualDocuments: true, supportsSegmentRouting: true }, + getConfigHash: () => 'doctor-scope-fixture-v1', + async run() { + return { + byChunkUid: { + 'chunk-1': { returnType: 'number' } + } + }; + } + }); + + const caseRoot = path.join(tempRoot, 'tooling-pass'); + const sourceDir = path.join(caseRoot, 'src'); + const absCacheDir = path.join(caseRoot, 'cache-root'); + const absLogDir = path.join(caseRoot, 'log-root'); + await fs.rm(caseRoot, { recursive: true, force: true }); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(absCacheDir, { recursive: true }); + await fs.mkdir(absLogDir, { recursive: true }); + + const chunks = [{ + chunkUid: 'chunk-1', + chunkId: 'chunk-1', + file: 'src/sample.js', + start: 0, + end: 34, + name: 'sum', + kind: 'function', + docmeta: {}, + metaV2: { symbol: { qualifiedName: 'sum' } } + }]; + + const entryByUid = new Map([[ + 'chunk-1', + { + name: 'sum', + file: 'src/sample.js', + kind: 'function', + chunkUid: 'chunk-1', + qualifiedName: 'sum', + paramTypes: {} + } + ]]); + + const logs = []; + const result = await runToolingPass({ + rootDir: caseRoot, + buildRoot: caseRoot, + chunks, + entryByUid, + log: (line) => logs.push(String(line || '')), + toolingConfig: { + enabledTools: ['configured-paths-fixture', 'doctor-scope-fixture'], + cache: { enabled: true, dir: absCacheDir }, + doctorCache: false + }, + toolingTimeoutMs: 2000, + toolingRetries: 0, + toolingBreaker: 1, + toolingLogDir: absLogDir, + fileTextByFile: new Map([ + ['src/sample.js', 'function sum(a, b) { return a + b; }\n'] + ]), + abortSignal: null + }); + + assert.equal(Number(result.toolingProvidersExecuted) >= 1, true); + assert.equal(logs.some((line) => line.includes('[tooling] providers:done')), true); + assert.ok(logs.some((line) => line.includes('[tooling] providers:selected count='))); + assert.ok(logs.some((line) => line.includes('[tooling] providers:start docs=1 targets=1.'))); + assert.equal(logs.some((line) => line.includes('[tooling] doctor:')), false); +}; + +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +try { + runCacheRootCases(); + await runCacheNormalizationCases(); + await runFailOpenRuntimeCase(); + await runBudgetCase(); + await runToolingPassCases(); + console.log('tooling runtime contract matrix test passed'); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} diff --git a/tests/tooling/typescript-provider-planner-skips-do-not-degrade.test.js b/tests/tooling/typescript-provider-planner-skips-do-not-degrade.test.js new file mode 100644 index 000000000..8f934ae63 --- /dev/null +++ b/tests/tooling/typescript-provider-planner-skips-do-not-degrade.test.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../src/index/tooling/provider-registry.js'; +import { createTypeScriptProvider } from '../../src/index/tooling/typescript-provider.js'; +import { runToolingProviders } from '../../src/index/tooling/orchestrator.js'; + +TOOLING_PROVIDERS.clear(); +registerToolingProvider(createTypeScriptProvider()); + +const chunkUid = 'ck:test:typescript:planner-skip'; +const result = await runToolingProviders({ + repoRoot: process.cwd(), + buildRoot: process.cwd(), + strict: true, + toolingConfig: {}, + cache: { enabled: false } +}, { + documents: [{ + virtualPath: 'src/sample.ts', + effectiveExt: '.ts', + languageId: 'typescript', + text: 'export function alpha(): number { return 1; }\n', + docHash: 'doc-typescript-planner-skip', + containerPath: 'src/sample.ts' + }], + targets: [{ + virtualPath: 'src/sample.ts', + languageId: 'typescript', + virtualRange: { start: 500, end: 550 }, + chunkRef: { + chunkUid, + chunkId: 'chunk_typescript_planner_skip', + file: 'src/sample.ts', + start: 500, + end: 550 + }, + symbolHint: { name: 'alpha', kind: 'function' } + }] +}); + +assert.equal(result.byChunkUid.has(chunkUid), false, 'expected planner miss to skip contribution'); +assert.equal( + Array.isArray(result.degradedProviders) + && result.degradedProviders.some((entry) => entry?.providerId === 'typescript'), + false, + 'expected planner-only TypeScript miss not to degrade provider health' +); +assert.equal( + Array.isArray(result.observations) + && result.observations.some((entry) => entry?.code === 'tooling_provider_degraded_mode' && entry?.context?.providerId === 'typescript'), + false, + 'expected no degraded-mode observation for planner-only TypeScript miss' +); +const checks = result.diagnostics?.typescript?.checks || []; +const nodeMatch = checks.find((check) => check?.name === 'node_match'); +assert.ok(nodeMatch, 'expected node_match diagnostic'); +assert.equal(nodeMatch?.triggerClass, 'planner_target_match'); +assert.equal(nodeMatch?.degradedEligible, false); +assert.equal(nodeMatch?.contributionState, 'skipped'); + +console.log('typescript planner skips do not degrade test passed'); diff --git a/tests/tooling/typescript-provider-runtime-unavailable-degrades.test.js b/tests/tooling/typescript-provider-runtime-unavailable-degrades.test.js new file mode 100644 index 000000000..285ecdc53 --- /dev/null +++ b/tests/tooling/typescript-provider-runtime-unavailable-degrades.test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { TOOLING_PROVIDERS, registerToolingProvider } from '../../src/index/tooling/provider-registry.js'; +import { createTypeScriptProvider } from '../../src/index/tooling/typescript-provider.js'; +import { runToolingProviders } from '../../src/index/tooling/orchestrator.js'; +import { resolveTestCachePath } from '../helpers/test-cache.js'; + +TOOLING_PROVIDERS.clear(); +registerToolingProvider(createTypeScriptProvider()); + +const repoRoot = resolveTestCachePath(process.cwd(), 'typescript-runtime-missing'); +const chunkUid = 'ck:test:typescript:runtime-missing'; +const result = await runToolingProviders({ + repoRoot, + buildRoot: repoRoot, + strict: true, + toolingConfig: { + typescript: { + resolveOrder: ['repo'] + } + }, + cache: { enabled: false } +}, { + documents: [{ + virtualPath: 'src/sample.ts', + effectiveExt: '.ts', + languageId: 'typescript', + text: 'export function alpha(): number { return 1; }\n', + docHash: 'doc-typescript-runtime-missing', + containerPath: 'src/sample.ts' + }], + targets: [{ + virtualPath: 'src/sample.ts', + languageId: 'typescript', + virtualRange: { start: 0, end: 20 }, + chunkRef: { + chunkUid, + chunkId: 'chunk_typescript_runtime_missing', + file: 'src/sample.ts', + start: 0, + end: 20 + }, + symbolHint: { name: 'alpha', kind: 'function' } + }] +}); + +const checks = result.diagnostics?.typescript?.checks || []; +const runtimeUnavailable = checks.find((check) => check?.name === 'typescript_runtime_unavailable'); +assert.ok(runtimeUnavailable, 'expected explicit runtime unavailable diagnostic'); +assert.equal(runtimeUnavailable?.triggerClass, 'runtime_unavailable'); +assert.equal(runtimeUnavailable?.degradedEligible, true); +assert.equal( + Array.isArray(result.degradedProviders) + && result.degradedProviders.some((entry) => entry?.providerId === 'typescript'), + true, + 'expected runtime-unavailable TypeScript provider to degrade' +); + +console.log('typescript runtime unavailable degrades test passed'); diff --git a/tests/tooling/usr/full-conformance-surface.test.js b/tests/tooling/usr/full-conformance-surface.test.js new file mode 100644 index 000000000..501cfca2b --- /dev/null +++ b/tests/tooling/usr/full-conformance-surface.test.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { buildUsrConformanceSurface } from '../../../tools/usr/conformance-surface.js'; + +const result = await buildUsrConformanceSurface({ + now: () => '2026-05-22T00:00:00.000Z' +}); + +assert.equal(result.ok, true, `expected full conformance surface to pass: ${result.errors.join('; ')}`); +assert.equal( + result.reportValidation.ok, + true, + `expected USR report validation to pass: ${result.reportValidation.errors.join('; ')}` +); + +const payload = result.payload; +assert.equal(payload.artifactId, 'usr-conformance-summary'); +assert.equal(payload.status, 'pass'); +assert.equal(payload.summary.dashboard, 'full-language-conformance-surface'); +assert.ok(payload.summary.languageProfileCount > 0, 'expected language profile coverage'); +assert.ok(payload.summary.frameworkProfileCount > 0, 'expected framework profile coverage'); +assert.ok(payload.summary.artifactExpectationRowCount > 0, 'expected artifact expectation coverage'); +assert.equal(payload.summary.selectorCount, 9, 'expected one selector per language shard'); +assert.ok( + payload.summary.selectors.includes('conformance/language-shards/foundation/validation'), + 'expected foundation language shard selector' +); +assert.equal(payload.rows.length, payload.summary.profileCount, 'expected row count to match profile count'); +assert.equal( + payload.rows.every((row) => row.pass === true), + true, + 'expected every conformance surface row to pass' +); + +console.log('usr full conformance surface test passed'); diff --git a/tests/tooling/vfs/bloom-load-error-fallback.test.js b/tests/tooling/vfs/bloom-load-error-fallback.test.js new file mode 100644 index 000000000..2d766ce28 --- /dev/null +++ b/tests/tooling/vfs/bloom-load-error-fallback.test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { loadVfsManifestRowByPath } from '../../../src/index/tooling/vfs.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +const tempRoot = await makeTempDir('pairofcleats-vfs-bloom-load-error-'); + +try { + const manifestPath = path.join(tempRoot, 'vfs_manifest.jsonl'); + const bloomPath = path.join(tempRoot, 'vfs_manifest.vfsbloom.json'); + const virtualPath = '.poc-vfs/src/app.js#seg:segu:v1:abc.js'; + const row = { virtualPath, chunks: [{ chunkUid: 'ck:1', symbol: 'x' }] }; + await fs.writeFile(manifestPath, `${JSON.stringify(row)}\n`, 'utf8'); + await fs.writeFile(bloomPath, '{not-valid-json', 'utf8'); + + const telemetry = []; + const resolved = await loadVfsManifestRowByPath({ + manifestPath, + bloomPath, + virtualPath, + allowScan: true, + telemetry + }); + + assert.deepEqual(resolved, row, 'expected scan fallback to recover row when bloom payload is invalid'); + assert.equal( + telemetry.some((event) => event?.path === 'bloom' && event?.outcome === 'load_error'), + true, + 'expected bloom load_error telemetry event' + ); + assert.equal( + telemetry.some((event) => event?.path === 'scan' && event?.outcome === 'hit'), + true, + 'expected scan hit telemetry event after bloom load error' + ); + console.log('vfs bloom load error fallback test passed'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/vfs-bloom-negative-lookup.test.js b/tests/tooling/vfs/bloom-negative-lookup.test.js similarity index 100% rename from tests/tooling/vfs/vfs-bloom-negative-lookup.test.js rename to tests/tooling/vfs/bloom-negative-lookup.test.js diff --git a/tests/tooling/vfs/vfs-cdc-segmentation-stability.test.js b/tests/tooling/vfs/cdc-segmentation-stability.test.js similarity index 100% rename from tests/tooling/vfs/vfs-cdc-segmentation-stability.test.js rename to tests/tooling/vfs/cdc-segmentation-stability.test.js diff --git a/tests/tooling/vfs/vfs-cold-start-cache.test.js b/tests/tooling/vfs/cold-start-cache.test.js similarity index 100% rename from tests/tooling/vfs/vfs-cold-start-cache.test.js rename to tests/tooling/vfs/cold-start-cache.test.js diff --git a/tests/tooling/vfs/cold-start-corrupt-cache-fail-open.test.js b/tests/tooling/vfs/cold-start-corrupt-cache-fail-open.test.js new file mode 100644 index 000000000..656a7d786 --- /dev/null +++ b/tests/tooling/vfs/cold-start-corrupt-cache-fail-open.test.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createVfsColdStartCache } from '../../../src/index/tooling/vfs/cold-start.js'; +import { + VFS_COLD_START_DATA, + VFS_COLD_START_DIR, + VFS_COLD_START_META +} from '../../../src/index/tooling/vfs/constants.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +const tempRoot = await makeTempDir('poc-vfs-cold-start-corrupt-'); +const coldStartDir = path.join(tempRoot, VFS_COLD_START_DIR); +const metaPath = path.join(coldStartDir, VFS_COLD_START_META); +const dataPath = path.join(coldStartDir, VFS_COLD_START_DATA); + +try { + await fs.mkdir(coldStartDir, { recursive: true }); + await fs.writeFile(metaPath, '{"broken":', 'utf8'); + await fs.writeFile(dataPath, '{not-jsonl}\n', 'utf8'); + + const cache = await createVfsColdStartCache({ + cacheRoot: tempRoot, + indexSignature: 'sig-1', + manifestHash: 'manifest-1', + config: { + enabled: true, + cacheRoot: tempRoot, + maxBytes: 1024 * 1024, + maxAgeDays: 1 + } + }); + + assert.ok(cache, 'expected cold-start cache creation to fail open on corrupt cache files'); + assert.equal(cache.size(), 0, 'expected corrupt cache rows to be dropped'); + assert.equal( + cache.get({ virtualPath: '.poc-vfs/src/app.js', docHash: 'xxh64:abc' }), + null, + 'expected empty cache after corrupt metadata/data files' + ); + + console.log('vfs cold-start corrupt cache fail-open test passed'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/vfs-collector-cleanup-on-error.test.js b/tests/tooling/vfs/collector-cleanup-on-error.test.js similarity index 100% rename from tests/tooling/vfs/vfs-collector-cleanup-on-error.test.js rename to tests/tooling/vfs/collector-cleanup-on-error.test.js diff --git a/tests/tooling/vfs/vfs-disk-path-safety.test.js b/tests/tooling/vfs/disk-path-safety.test.js similarity index 100% rename from tests/tooling/vfs/vfs-disk-path-safety.test.js rename to tests/tooling/vfs/disk-path-safety.test.js diff --git a/tests/tooling/vfs/doc-hash-skip-rewrite.test.js b/tests/tooling/vfs/doc-hash-skip-rewrite.test.js new file mode 100644 index 000000000..e6f018a50 --- /dev/null +++ b/tests/tooling/vfs/doc-hash-skip-rewrite.test.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { checksumString } from '../../../src/shared/hash.js'; +import { ensureVfsDiskDocument, resolveVfsDiskPath } from '../../../src/index/tooling/vfs.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +const tempRoot = await makeTempDir('pairofcleats-vfs-doc-hash-'); + +try { + const virtualPath = '.poc-vfs/src/app.js#seg:segu:v1:abc.js'; + const text = 'console.log("hello");\n'; + const hash = await checksumString(text); + const docHash = `xxh64:${hash.value}`; + + await ensureVfsDiskDocument({ baseDir: tempRoot, virtualPath, text, docHash }); + const absPath = resolveVfsDiskPath({ baseDir: tempRoot, virtualPath }); + + await fs.writeFile(absPath, 'SENTINEL', 'utf8'); + await ensureVfsDiskDocument({ baseDir: tempRoot, virtualPath, text, docHash }); + + const content = await fs.readFile(absPath, 'utf8'); + assert.equal(content, 'SENTINEL', 'expected docHash cache to skip rewrite'); + + await fs.writeFile(absPath, 'STALE_WITHOUT_HASH', 'utf8'); + await ensureVfsDiskDocument({ baseDir: tempRoot, virtualPath, text, docHash: null }); + const rewritten = await fs.readFile(absPath, 'utf8'); + assert.equal(rewritten, text, 'expected missing docHash to force rewrite and prevent stale cache hits'); + + console.log('VFS doc hash skip rewrite test passed'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/fastpath-telemetry-contract.test.js b/tests/tooling/vfs/fastpath-telemetry-contract.test.js new file mode 100644 index 000000000..ffeb799b6 --- /dev/null +++ b/tests/tooling/vfs/fastpath-telemetry-contract.test.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + buildVfsManifestRowsForFile, + createVfsManifestOffsetReader, + loadVfsManifestIndex, + loadVfsManifestRowByPath, + readVfsManifestRowsAtOffsets +} from '../../../src/index/tooling/vfs.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { runVfsManifestWriter } from '../../helpers/vfs-streaming-fixture.js'; + +applyTestEnv({ testing: '1' }); + +const tempRoot = await makeTempDir('pairofcleats-vfs-fastpath-telemetry-'); +const outDir = path.join(tempRoot, 'out'); +await fs.mkdir(outDir, { recursive: true }); + +try { + const fileText = 'const a = 1;\nconst b = 2;\n'; + const rows = await buildVfsManifestRowsForFile({ + chunks: [ + { + file: 'src/a.ts', + lang: 'typescript', + segment: { + segmentUid: 'segu:v1:a', + segmentId: 'seg-a', + start: 0, + end: 12, + languageId: 'typescript', + ext: '.ts' + }, + start: 0, + end: 12 + }, + { + file: 'src/a.ts', + lang: 'typescript', + segment: { + segmentUid: 'segu:v1:b', + segmentId: 'seg-b', + start: 13, + end: fileText.length, + languageId: 'typescript', + ext: '.ts' + }, + start: 13, + end: fileText.length + } + ], + fileText, + containerPath: 'src/a.ts', + containerExt: '.ts', + containerLanguageId: 'typescript' + }); + + await runVfsManifestWriter({ outDir, mode: 'code', rows }); + + const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); + const indexPath = path.join(outDir, 'vfs_manifest.vfsidx'); + const bloomPath = path.join(outDir, 'vfs_manifest.vfsbloom.json'); + const index = await loadVfsManifestIndex({ indexPath }); + const telemetry = []; + + const bloomMissPath = '.poc-vfs/src/missing.ts#seg:segu:v1:missing.ts'; + const bloomMiss = await loadVfsManifestRowByPath({ + manifestPath, + indexPath, + bloomPath, + virtualPath: bloomMissPath, + allowScan: true, + telemetry + }); + assert.equal(bloomMiss, null, 'expected bloom negative lookup to return null'); + assert.deepEqual( + telemetry.filter((event) => event.virtualPath === bloomMissPath).map((event) => `${event.path}:${event.outcome}`), + ['bloom:negative'], + 'expected bloom-negative lookup to short-circuit without vfsidx/scan' + ); + + telemetry.length = 0; + const expectedRow = rows[0]; + const indexedHit = await loadVfsManifestRowByPath({ + manifestPath, + indexPath, + bloomPath, + virtualPath: expectedRow.virtualPath, + allowScan: true, + telemetry + }); + assert.deepEqual(indexedHit, expectedRow, 'expected vfsidx hit lookup to return matching row'); + assert.deepEqual( + telemetry.map((event) => `${event.path}:${event.outcome}`), + ['bloom:positive', 'vfsidx:hit'], + 'expected bloom positive then vfsidx hit telemetry' + ); + + telemetry.length = 0; + const mismatchSource = rows[1]; + const mismatchEntry = index.get(mismatchSource.virtualPath); + assert.ok(mismatchEntry, 'expected index entry for mismatch source row'); + const mismatchIndex = new Map([[expectedRow.virtualPath, mismatchEntry]]); + const mismatchRecovered = await loadVfsManifestRowByPath({ + manifestPath, + index: mismatchIndex, + virtualPath: expectedRow.virtualPath, + allowScan: true, + telemetry + }); + assert.deepEqual(mismatchRecovered, expectedRow, 'expected scan fallback to recover from index row mismatch'); + assert.deepEqual( + telemetry.map((event) => `${event.path}:${event.outcome}`), + ['vfsidx:mismatch', 'scan:hit'], + 'expected vfsidx mismatch telemetry before scan recovery' + ); + + telemetry.length = 0; + const staleIndexHit = await loadVfsManifestRowByPath({ + manifestPath, + index: new Map(), + virtualPath: expectedRow.virtualPath, + allowScan: true, + telemetry + }); + assert.deepEqual(staleIndexHit, expectedRow, 'expected scan fallback to recover from stale index misses'); + assert.deepEqual( + telemetry.map((event) => `${event.path}:${event.outcome}`), + ['vfsidx:miss', 'scan:hit'], + 'expected stale index miss to continue with scan fallback' + ); + + telemetry.length = 0; + const scanHit = await loadVfsManifestRowByPath({ + manifestPath, + virtualPath: expectedRow.virtualPath, + allowScan: true, + telemetry + }); + assert.deepEqual(scanHit, expectedRow, 'expected scan fallback to return row'); + assert.deepEqual( + telemetry.map((event) => `${event.path}:${event.outcome}`), + ['scan:hit'], + 'expected scan fallback telemetry' + ); + + telemetry.length = 0; + const scanDisabled = await loadVfsManifestRowByPath({ + manifestPath, + virtualPath: '.poc-vfs/src/missing.ts#seg:segu:v1:none.ts', + allowScan: false, + telemetry + }); + assert.equal(scanDisabled, null, 'expected disabled scan to return null'); + assert.deepEqual( + telemetry.map((event) => `${event.path}:${event.outcome}`), + ['scan:disabled'], + 'expected scan disabled telemetry' + ); + + const reader = createVfsManifestOffsetReader({ manifestPath, maxBufferPoolEntries: 4 }); + try { + const requests = rows.map((row) => { + const entry = index.get(row.virtualPath); + assert.ok(entry, `expected index entry for ${row.virtualPath}`); + return { offset: entry.offset, bytes: entry.bytes }; + }); + const loadedA = await readVfsManifestRowsAtOffsets({ manifestPath, requests, reader }); + const loadedB = await readVfsManifestRowsAtOffsets({ manifestPath, requests, reader }); + assert.deepEqual( + loadedA.map((row) => row?.virtualPath || null), + rows.map((row) => row.virtualPath), + 'expected batched offset loads to preserve row ordering' + ); + assert.deepEqual( + loadedB.map((row) => row?.virtualPath || null), + rows.map((row) => row.virtualPath), + 'expected repeated batched reads to remain stable' + ); + const stats = reader.stats(); + assert.equal(stats.handleOpens, 1, 'expected batched reader to reuse a single file handle'); + assert.ok(stats.batchCalls >= 2, 'expected batched reader to report batch calls'); + assert.ok(stats.bufferReuses >= 1, 'expected batched reader to reuse pooled buffers'); + } finally { + await reader.close(); + } + + console.log('VFS fast-path telemetry contract test passed'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/vfs-idx-contract.test.js b/tests/tooling/vfs/idx-contract.test.js similarity index 100% rename from tests/tooling/vfs/vfs-idx-contract.test.js rename to tests/tooling/vfs/idx-contract.test.js diff --git a/tests/tooling/vfs/vfs-invalid-virtual-range-regression.test.js b/tests/tooling/vfs/invalid-virtual-range-regression.test.js similarity index 100% rename from tests/tooling/vfs/vfs-invalid-virtual-range-regression.test.js rename to tests/tooling/vfs/invalid-virtual-range-regression.test.js diff --git a/tests/tooling/vfs/io-batch-consistency.test.js b/tests/tooling/vfs/io-batch-consistency.test.js new file mode 100644 index 000000000..a911a8706 --- /dev/null +++ b/tests/tooling/vfs/io-batch-consistency.test.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { ensureVfsDiskDocument, resolveVfsDiskPath } from '../../../src/index/tooling/vfs.js'; +import { + createVfsQueuedWriteBatcher, + ensureVirtualFilesBatch, + resolveVfsIoBatching +} from '../../../src/integrations/tooling/providers/lsp.js'; + +const tempRoot = await makeTempDir('pairofcleats-vfs-io-batch-'); +const outDir = path.join(tempRoot, 'vfs'); +await fs.mkdir(outDir, { recursive: true }); + +try { + const docs = [ + { + virtualPath: '.poc-vfs/src/a.ts#seg:seg-a.ts', + text: 'const a = 1;\n', + docHash: 'xxh64:aaaaaaaaaaaaaaaa' + }, + { + virtualPath: '.poc-vfs/src/b.ts#seg:seg-b.ts', + text: 'const b = 2;\n', + docHash: 'xxh64:bbbbbbbbbbbbbbbb' + }, + { + virtualPath: '.poc-vfs/src/c.ts#seg:seg-c.ts', + text: 'const c = 3;\n', + docHash: 'xxh64:cccccccccccccccc' + } + ]; + + const sequential = new Map(); + for (const doc of docs) { + const result = await ensureVfsDiskDocument({ + baseDir: outDir, + virtualPath: doc.virtualPath, + text: doc.text, + docHash: doc.docHash + }); + sequential.set(doc.virtualPath, result.path); + } + + const batching = resolveVfsIoBatching({ + enabled: true, + maxInflight: 2, + maxQueueEntries: 2, + maxBatchBytes: 32, + flushIntervalMs: 5, + writeMode: 'atomic' + }); + assert.equal(batching.maxBatchBytes, 32, 'Expected maxBatchBytes to be normalized.'); + assert.equal(batching.flushIntervalMs, 5, 'Expected flushIntervalMs to be normalized.'); + assert.equal(batching.writeMode, 'atomic', 'Expected writeMode to be normalized.'); + const batched = await ensureVirtualFilesBatch({ + rootDir: outDir, + docs, + batching + }); + + assert.equal(batched.size, docs.length, 'Expected batched write to return all paths.'); + + for (const doc of docs) { + const expectedPath = resolveVfsDiskPath({ baseDir: outDir, virtualPath: doc.virtualPath }); + const seqPath = sequential.get(doc.virtualPath); + const batchedPath = batched.get(doc.virtualPath); + assert.equal(seqPath, expectedPath, 'Expected sequential path to be deterministic.'); + assert.equal(batchedPath, expectedPath, 'Expected batched path to be deterministic.'); + const contents = await fs.readFile(batchedPath, 'utf8'); + assert.equal(contents, doc.text, 'Expected batched write contents to match.'); + } + + const rerun = await ensureVirtualFilesBatch({ + rootDir: outDir, + docs, + batching + }); + for (const doc of docs) { + assert.equal(rerun.get(doc.virtualPath), sequential.get(doc.virtualPath), 'Expected stable path on rerun.'); + } + + const queuedDocs = [ + { + virtualPath: '.poc-vfs/src/queued.ts#seg:seg-queued.ts', + text: 'const queued = 1;\n', + docHash: 'xxh64:1111111111111111' + }, + { + virtualPath: '.poc-vfs/src/other.ts#seg:seg-other.ts', + text: 'const other = 1;\n', + docHash: 'xxh64:2222222222222222' + }, + { + virtualPath: '.poc-vfs/src/queued.ts#seg:seg-queued.ts', + text: 'const queued = 2;\n', + docHash: 'xxh64:3333333333333333' + } + ]; + const queued = await ensureVirtualFilesBatch({ + rootDir: outDir, + docs: queuedDocs, + batching: resolveVfsIoBatching({ enabled: true, maxInflight: 2, maxQueueEntries: 10 }) + }); + assert.equal(queued.size, 2, 'Expected duplicate queued writes to coalesce by final path.'); + const queuedPath = queued.get('.poc-vfs/src/queued.ts#seg:seg-queued.ts'); + assert.equal( + await fs.readFile(queuedPath, 'utf8'), + 'const queued = 2;\n', + 'Expected last queued write to win.' + ); + + const writer = createVfsQueuedWriteBatcher({ + rootDir: outDir, + batching: resolveVfsIoBatching({ enabled: true, maxInflight: 1, maxQueueEntries: 4 }) + }); + await writer.enqueue({ + virtualPath: '.poc-vfs/src/manual.ts#seg:seg-manual.ts', + text: 'manual one\n', + docHash: 'xxh64:4444444444444444' + }); + await writer.enqueue({ + virtualPath: '.poc-vfs/src/manual.ts#seg:seg-manual.ts', + text: 'manual two\n', + docHash: 'xxh64:5555555555555555' + }); + assert.equal(writer.getPendingSize(), 1, 'Expected manual writer to coalesce pending duplicate path.'); + const manual = await writer.drain(); + assert.equal( + await fs.readFile(manual.get('.poc-vfs/src/manual.ts#seg:seg-manual.ts'), 'utf8'), + 'manual two\n', + 'Expected manual queued writer to flush the last write.' + ); + + console.log('vfs io batch consistency ok'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/lookup-contract-matrix.test.js b/tests/tooling/vfs/lookup-contract-matrix.test.js new file mode 100644 index 000000000..754c3ed5f --- /dev/null +++ b/tests/tooling/vfs/lookup-contract-matrix.test.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + compareVfsManifestRows, + loadVfsManifestRowByPath +} from '../../../src/index/tooling/vfs.js'; +import { buildVfsIndexRows } from '../../../src/index/tooling/vfs-index.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { createSingleSegmentVfsManifestFixture } from '../../helpers/vfs-streaming-fixture.js'; + +{ + const rows = [ + { + schemaVersion: '1.0.0', + virtualPath: '.poc-vfs/b.ts#seg:seg-b.ts', + docHash: 'xxh64:bbbbbbbbbbbbbbbb', + containerPath: 'b.ts', + containerExt: '.ts', + containerLanguageId: 'typescript', + languageId: 'typescript', + effectiveExt: '.ts', + segmentUid: 'seg-b', + segmentId: 'seg-b', + segmentStart: 5, + segmentEnd: 10, + lineStart: 1, + lineEnd: 1 + }, + { + schemaVersion: '1.0.0', + virtualPath: '.poc-vfs/a.ts#seg:seg-a.ts', + docHash: 'xxh64:aaaaaaaaaaaaaaaa', + containerPath: 'a.ts', + containerExt: '.ts', + containerLanguageId: 'typescript', + languageId: 'typescript', + effectiveExt: '.ts', + segmentUid: 'seg-a', + segmentId: 'seg-a', + segmentStart: 0, + segmentEnd: 4, + lineStart: 1, + lineEnd: 1 + } + ]; + const indexRows = buildVfsIndexRows(rows); + assert.equal(indexRows.length, rows.length); + for (const row of indexRows) { + assert.ok(row.manifestSortKey); + } + + const sortedManifest = rows.slice().sort(compareVfsManifestRows).map((row) => row.virtualPath); + const sortedIndex = indexRows + .slice() + .sort((a, b) => String(a.manifestSortKey).localeCompare(String(b.manifestSortKey))) + .map((row) => row.virtualPath); + assert.deepStrictEqual(sortedIndex, sortedManifest); +} + +{ + const fixture = await createSingleSegmentVfsManifestFixture({ + tempPrefix: 'poc-vfs-lookup-contract-' + }); + + try { + await fixture.writeManifest(); + const missingPath = '.poc-vfs/missing.md#seg:missing'; + + const notFound = await loadVfsManifestRowByPath({ + manifestPath: fixture.manifestPath, + indexPath: fixture.indexPath, + bloomPath: fixture.bloomPath, + virtualPath: missingPath + }); + assert.equal(notFound, null); + assert.equal( + await loadVfsManifestRowByPath({ + manifestPath: fixture.manifestPath, + virtualPath: missingPath, + allowScan: false + }), + null + ); + assert.equal( + await loadVfsManifestRowByPath({ + manifestPath: fixture.manifestPath, + virtualPath: missingPath, + allowScan: true + }), + null + ); + } finally { + await fixture.cleanup(); + } +} + +{ + const tempRoot = await makeTempDir('poc-vfs-lookup-load-error-'); + const manifestPath = path.join(tempRoot, 'vfs_manifest.jsonl'); + const indexPath = path.join(tempRoot, 'vfs_manifest.vfsidx'); + + try { + const row = { + virtualPath: '.poc-vfs/src/app.js', + docHash: 'xxh64:abc', + segmentStart: 0, + segmentEnd: 10 + }; + await fs.writeFile(manifestPath, `${JSON.stringify(row)}\n`, 'utf8'); + await fs.writeFile(indexPath, '{not-json}\n', 'utf8'); + + const telemetry = []; + const resolved = await loadVfsManifestRowByPath({ + manifestPath, + indexPath, + virtualPath: row.virtualPath, + allowScan: true, + telemetry + }); + + assert.ok(resolved); + assert.equal(resolved.virtualPath, row.virtualPath); + assert.ok( + telemetry.some((event) => event?.path === 'vfsidx' && event?.outcome === 'load_error') + ); + } finally { + await rmDirRecursive(tempRoot); + } +} + +console.log('vfs lookup contract matrix test passed'); diff --git a/tests/tooling/vfs/vfs-manifest-missing-segmentuid.test.js b/tests/tooling/vfs/manifest-missing-segmentuid.test.js similarity index 100% rename from tests/tooling/vfs/vfs-manifest-missing-segmentuid.test.js rename to tests/tooling/vfs/manifest-missing-segmentuid.test.js diff --git a/tests/tooling/vfs/vfs-manifest-streaming.test.js b/tests/tooling/vfs/manifest-streaming.test.js similarity index 100% rename from tests/tooling/vfs/vfs-manifest-streaming.test.js rename to tests/tooling/vfs/manifest-streaming.test.js diff --git a/tests/tooling/vfs/vfs-maps-segment-offsets.test.js b/tests/tooling/vfs/maps-segment-offsets.test.js similarity index 100% rename from tests/tooling/vfs/vfs-maps-segment-offsets.test.js rename to tests/tooling/vfs/maps-segment-offsets.test.js diff --git a/tests/tooling/vfs/merge-heap-deterministic.test.js b/tests/tooling/vfs/merge-heap-deterministic.test.js new file mode 100644 index 000000000..01c737700 --- /dev/null +++ b/tests/tooling/vfs/merge-heap-deterministic.test.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createVfsManifestCollector } from '../../../src/index/build/vfs-manifest-collector.js'; +import { compareVfsManifestRows } from '../../../src/index/tooling/vfs.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; +import { runVfsManifestWriter } from '../../helpers/vfs-streaming-fixture.js'; + +const readJsonl = async (filePath) => { + const raw = await fs.readFile(filePath, 'utf8'); + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +}; + +const tempRoot = await makeTempDir('pairofcleats-vfs-merge-heap-'); +const outDir = path.join(tempRoot, 'out'); +await fs.mkdir(outDir, { recursive: true }); + +try { + const rows = [ + { + schemaVersion: '1.0.0', + virtualPath: '.poc-vfs/b.ts#seg:seg-b.ts', + docHash: 'xxh64:bbbbbbbbbbbbbbbb', + containerPath: 'b.ts', + containerExt: '.ts', + containerLanguageId: 'typescript', + languageId: 'typescript', + effectiveExt: '.ts', + segmentUid: 'seg-b', + segmentId: 'seg-b', + segmentStart: 10, + segmentEnd: 20, + lineStart: 1, + lineEnd: 2 + }, + { + schemaVersion: '1.0.0', + virtualPath: '.poc-vfs/a.ts#seg:seg-a.ts', + docHash: 'xxh64:aaaaaaaaaaaaaaaa', + containerPath: 'a.ts', + containerExt: '.ts', + containerLanguageId: 'typescript', + languageId: 'typescript', + effectiveExt: '.ts', + segmentUid: 'seg-a', + segmentId: 'seg-a', + segmentStart: 0, + segmentEnd: 5, + lineStart: 1, + lineEnd: 1 + }, + { + schemaVersion: '1.0.0', + virtualPath: '.poc-vfs/a.ts#seg:seg-c.ts', + docHash: 'xxh64:cccccccccccccccc', + containerPath: 'a.ts', + containerExt: '.ts', + containerLanguageId: 'typescript', + languageId: 'typescript', + effectiveExt: '.ts', + segmentUid: 'seg-c', + segmentId: 'seg-c', + segmentStart: 6, + segmentEnd: 9, + lineStart: 2, + lineEnd: 2 + } + ]; + + const collector = createVfsManifestCollector({ + buildRoot: tempRoot, + maxBufferRows: 1, + maxBufferBytes: 1 + }); + await collector.appendRows(rows); + + await runVfsManifestWriter({ outDir, mode: 'code', rows: collector }); + + const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); + const written = await readJsonl(manifestPath); + const expected = rows.slice().sort(compareVfsManifestRows); + + assert.deepStrictEqual( + written.map((row) => row.virtualPath), + expected.map((row) => row.virtualPath), + 'Expected heap-merged manifest rows to be deterministically sorted.' + ); + + console.log('vfs merge heap deterministic ok'); +} finally { + await rmDirRecursive(tempRoot); +} diff --git a/tests/tooling/vfs/offset-reader-close-timeout.test.js b/tests/tooling/vfs/offset-reader-close-timeout.test.js new file mode 100644 index 000000000..c77e42145 --- /dev/null +++ b/tests/tooling/vfs/offset-reader-close-timeout.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createVfsManifestOffsetReader } from '../../../src/index/tooling/vfs.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; + +applyTestEnv({ testing: '1' }); + +const tempRoot = await makeTempDir('pairofcleats-vfs-offset-reader-close-timeout-'); +const manifestPath = path.join(tempRoot, 'vfs_manifest.jsonl'); +await fs.writeFile( + manifestPath, + `${JSON.stringify({ virtualPath: '.poc-vfs/src/a.ts#seg:a', chunks: [] })}\n`, + 'utf8' +); + +const originalOpen = fs.open; +try { + fs.open = async (...args) => { + const handle = await originalOpen(...args); + return new Proxy(handle, { + get(target, prop, receiver) { + if (prop === 'close') { + return async () => new Promise(() => {}); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }; + + const logs = []; + const reader = createVfsManifestOffsetReader({ + manifestPath, + closeTimeoutMs: 25, + log: (line) => logs.push(String(line)) + }); + await reader.readAtOffset({ offset: 0, bytes: 256 }); + const closeStartedAtMs = Date.now(); + await reader.close(); + const closeElapsedMs = Date.now() - closeStartedAtMs; + assert.ok( + closeElapsedMs < 1500, + `expected reader close timeout to remain bounded (elapsed=${closeElapsedMs}ms)` + ); + assert.ok( + logs.some((line) => line.includes('vfs-offset-reader.close timed out')), + 'expected timeout close path to emit a warning log line' + ); + const readerStats = reader.stats(); + assert.equal( + readerStats.pendingCloseOperations, + 1, + 'expected timed-out close to remain tracked until the underlying handle settles' + ); +} finally { + fs.open = originalOpen; + await rmDirRecursive(tempRoot); +} + +console.log('VFS offset-reader close timeout test passed'); diff --git a/tests/tooling/vfs/vfs-parallel-manifest-deterministic.test.js b/tests/tooling/vfs/parallel-manifest-deterministic.test.js similarity index 100% rename from tests/tooling/vfs/vfs-parallel-manifest-deterministic.test.js rename to tests/tooling/vfs/parallel-manifest-deterministic.test.js diff --git a/tests/tooling/vfs/partial-lsp-open.test.js b/tests/tooling/vfs/partial-lsp-open.test.js new file mode 100644 index 000000000..f57eb9686 --- /dev/null +++ b/tests/tooling/vfs/partial-lsp-open.test.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; +import { parseJsonLinesFile } from '../../helpers/lsp-signature-fixtures.js'; +import { withTemporaryEnv } from '../../helpers/test-env.js'; + +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const tempRoot = resolveTestCachePath(root, 'vfs-partial-lsp-open'); +await fs.rm(tempRoot, { recursive: true, force: true }); +await fs.mkdir(tempRoot, { recursive: true }); + +const tracePath = path.join(tempRoot, 'trace.jsonl'); +const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); + +const docText = 'int add(int a, int b) { return a + b; }\n'; +const docText2 = 'int sub(int a, int b) { return a - b; }\n'; +const virtualPath1 = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; +const virtualPath2 = '.poc-vfs/src/unused.cpp#seg:stub.cpp'; +const documents = [ + { virtualPath: virtualPath1, text: docText, languageId: 'cpp', effectiveExt: '.cpp' }, + { virtualPath: virtualPath2, text: docText2, languageId: 'cpp', effectiveExt: '.cpp' } +]; + +const chunkUid = 'ck64:v1:test:src/sample.cpp:deadbeef'; +const targets = [{ + chunkRef: { + docId: 0, + chunkUid, + chunkId: 'chunk_deadbeef', + file: 'src/sample.cpp', + segmentUid: null, + segmentId: null, + range: { start: 0, end: docText.length } + }, + virtualPath: virtualPath1, + virtualRange: { start: 0, end: docText.length }, + symbolHint: { name: 'add', kind: 'function' } +}]; + +await withTemporaryEnv({ POC_LSP_TRACE: tracePath }, async () => { + await collectLspTypes({ + rootDir: tempRoot, + vfsRoot: tempRoot, + documents, + targets, + cmd: process.execPath, + args: [serverPath, '--mode', 'clangd'], + parseSignature: (detail) => ({ + signature: detail, + returnType: 'int', + paramTypes: { a: 'int', b: 'int' } + }) + }); +}); + +const events = await parseJsonLinesFile(tracePath); +const didOpenCount = events.filter((evt) => evt.kind === 'notification' && evt.method === 'textDocument/didOpen').length; +const documentSymbolCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/documentSymbol').length; + +assert.equal(didOpenCount, 1, 'expected only one didOpen (docs without targets should be skipped)'); +assert.equal(documentSymbolCount, 1, 'expected only one documentSymbol request'); + +console.log('VFS partial LSP open test passed'); diff --git a/tests/tooling/vfs/routing-and-token-contract-matrix.test.js b/tests/tooling/vfs/routing-and-token-contract-matrix.test.js new file mode 100644 index 000000000..108a7eedd --- /dev/null +++ b/tests/tooling/vfs/routing-and-token-contract-matrix.test.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { checksumString } from '../../../src/shared/hash.js'; +import { discoverSegments, assignSegmentUids, chunkSegments } from '../../../src/index/segments.js'; +import { assignChunkUids } from '../../../src/index/identity/chunk-uid.js'; +import * as hashRouting from '../../../src/index/tooling/vfs-hash-routing.js'; +import { + buildToolingVirtualDocuments, + buildVfsHashVirtualPath, + buildVfsVirtualPath, + resolveVfsVirtualPath +} from '../../../src/index/tooling/vfs.js'; +import { + buildVfsToken, + buildVfsTokenUri, + parseVfsTokenUri +} from '../../../src/integrations/tooling/lsp/uris.js'; + +const buildVfsRoutingToken = hashRouting.buildVfsRoutingToken || hashRouting.resolveVfsRoutingToken; + +assert.equal(typeof buildVfsRoutingToken, 'function', 'Expected buildVfsRoutingToken export.'); +assert.equal(hashRouting.VFS_HASH_ROUTING_SCHEMA_VERSION, '1.0.0'); + +{ + const virtualPath = '.poc-vfs/src/app.ts#seg:segu:v1:abc.ts'; + const docHash = 'xxh64:0123456789abcdef'; + const expectedHash = await checksumString(`${docHash}|${virtualPath}`); + const token = await buildVfsRoutingToken({ + virtualPath, + docHash, + mode: 'docHash+virtualPath' + }); + + assert.equal(token, expectedHash?.value || ''); + assert.ok(/^[0-9a-f]{16}$/.test(token)); + + const expectedDocOnly = (await checksumString(docHash)).value; + const tokenDocOnly = await buildVfsRoutingToken({ virtualPath, docHash, mode: 'docHash' }); + assert.equal(tokenDocOnly, expectedDocOnly); + + const missing = await buildVfsRoutingToken({ virtualPath, docHash: null }); + assert.equal(missing, null); +} + +{ + const containerPath = 'src/app.ts'; + const segmentUid = 'segu:v1:abc'; + const effectiveExt = '.ts'; + const legacy = buildVfsVirtualPath({ containerPath, segmentUid, effectiveExt }); + const fallback = resolveVfsVirtualPath({ + containerPath, + segmentUid, + effectiveExt, + docHash: null, + hashRouting: true + }); + assert.equal(fallback, legacy); + + const docHash = 'xxh64:0123456789abcdef'; + const hashPath = buildVfsHashVirtualPath({ docHash, effectiveExt }); + assert.ok(hashPath?.startsWith('.poc-vfs/by-hash/')); + + const resolved = resolveVfsVirtualPath({ + containerPath, + segmentUid, + effectiveExt, + docHash, + hashRouting: true + }); + assert.equal(resolved, hashPath); +} + +{ + const fileText = 'console.log(1);\n'; + const chunks = [ + { + file: 'src/App.vue', + lang: 'typescript', + ext: '.vue', + containerLanguageId: 'vue', + segment: { + segmentUid: 'segu:v1:hash', + segmentId: 'seg-hash', + start: 0, + end: fileText.length, + languageId: 'typescript', + ext: '.ts' + }, + chunkUid: 'chunk:1', + start: 0, + end: fileText.length, + fileHash: 'deadbeef' + } + ]; + const fileTextByPath = new Map([['src/App.vue', fileText]]); + const { documents } = await buildToolingVirtualDocuments({ + chunks, + fileTextByPath, + hashRouting: true, + strict: true + }); + + assert.equal(documents.length, 1); + const doc = documents[0]; + const expectedHashPath = buildVfsHashVirtualPath({ + docHash: doc.docHash, + effectiveExt: doc.effectiveExt + }); + assert.equal(doc.virtualPath, expectedHashPath); + assert.equal(doc.legacyVirtualPath, '.poc-vfs/src/App.vue#seg:segu:v1:hash.ts'); + + const plain = await buildToolingVirtualDocuments({ + chunks, + fileTextByPath, + hashRouting: false, + strict: true + }); + assert.equal(plain.documents[0].legacyVirtualPath, null); + assert.equal(plain.documents[0].virtualPath, '.poc-vfs/src/App.vue#seg:segu:v1:hash.ts'); +} + +{ + const relPath = 'src/App.vue'; + const text = [ + '', + '' + ].join('\n'); + + let segments = discoverSegments({ + text, + ext: '.vue', + relPath, + mode: 'code', + languageId: 'vue' + }); + segments = await assignSegmentUids({ text, segments, ext: '.vue', mode: 'code' }); + + const chunks = chunkSegments({ + text, + ext: '.vue', + relPath, + mode: 'code', + segments + }); + for (const chunk of chunks) { + chunk.file = relPath; + } + + await assignChunkUids({ chunks, fileText: text, fileRelPath: relPath, strict: true }); + + const { documents } = await buildToolingVirtualDocuments({ + chunks, + fileTextByPath: new Map([[relPath, text]]), + strict: true + }); + + const tsDocs = documents.filter((doc) => doc.effectiveExt === '.ts'); + assert.equal(tsDocs.length, 1, 'expected exactly one TypeScript virtual document'); + assert.equal(tsDocs[0].languageId, 'typescript'); + assert.ok(tsDocs[0].virtualPath.startsWith('.poc-vfs/')); +} + +{ + const relPath = 'src/app.js'; + const fileText = 'function ping() { return 1; }\n'; + const chunk = { + file: relPath, + ext: '.js', + lang: 'javascript', + start: 0, + end: fileText.length, + name: 'ping', + kind: 'FunctionDeclaration' + }; + + await assignChunkUids({ chunks: [chunk], fileText, fileRelPath: relPath, strict: true }); + + const buildOnce = async () => buildToolingVirtualDocuments({ + chunks: [chunk], + fileTextByPath: new Map([[relPath, fileText]]), + strict: true + }); + + const first = await buildOnce(); + const second = await buildOnce(); + + assert.equal(first.documents.length, 1, 'expected one document'); + assert.equal(second.documents.length, 1, 'expected one document'); + assert.equal(first.documents[0].virtualPath, second.documents[0].virtualPath, 'expected deterministic virtualPath'); +} + +{ + const virtualPath = '.poc-vfs/docs/hello%world#seg:segu:v1:abc.ts'; + const docHash = 'xxh64:0123456789abcdef'; + const token = await buildVfsToken({ virtualPath, docHash, mode: 'docHash+virtualPath' }); + const uri = buildVfsTokenUri({ virtualPath, token }); + + assert.ok(/^[0-9a-f]{16}$/.test(token)); + assert.ok(uri.startsWith('poc-vfs:///')); + assert.ok(uri.includes('token=')); + assert.ok(uri.includes('%23')); + assert.ok(!uri.includes('#seg:')); + + const parsed = parseVfsTokenUri(uri); + assert.equal(parsed?.virtualPath, virtualPath); + assert.equal(parsed?.token, token); +} + +console.log('vfs routing and token contract matrix test passed'); diff --git a/tests/tooling/vfs/row-size-trim.test.js b/tests/tooling/vfs/row-size-trim.test.js new file mode 100644 index 000000000..5f58ba291 --- /dev/null +++ b/tests/tooling/vfs/row-size-trim.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { VFS_MANIFEST_MAX_ROW_BYTES } from '../../../src/index/tooling/vfs.js'; +import { + createVfsRowTrimFixture, + runVfsManifestWriter +} from '../../helpers/vfs-streaming-fixture.js'; + +const fixture = await createVfsRowTrimFixture({ tempPrefix: 'pairofcleats-vfs-row-size-' }); + +try { + const { baseRows, tempRoot } = fixture; + assert.equal(baseRows.length, 1, 'expected a base vfs manifest row'); + + const loaded = await fixture.writeOversizedExtensionsAndLoad(); + + assert.equal(loaded.length, 1, 'trimmed row should still be emitted'); + assert.ok(!loaded[0].extensions, 'extensions should be trimmed when oversize'); + + const rowBytes = Buffer.byteLength(JSON.stringify(loaded[0]), 'utf8'); + assert.ok(rowBytes <= VFS_MANIFEST_MAX_ROW_BYTES, 'trimmed row should fit within max bytes'); + + const huge = { + ...baseRows[0], + containerPath: 'a'.repeat(VFS_MANIFEST_MAX_ROW_BYTES * 2), + virtualPath: `.poc-vfs/${'a'.repeat(VFS_MANIFEST_MAX_ROW_BYTES * 2)}` + }; + + const dropDir = path.join(tempRoot, 'drop'); + await fs.mkdir(dropDir, { recursive: true }); + await runVfsManifestWriter({ outDir: dropDir, mode: 'code', rows: [huge], maxJsonBytes: 1024 * 1024 }); + let hasManifest = true; + try { + await fs.stat(path.join(dropDir, 'vfs_manifest.jsonl')); + } catch { + hasManifest = false; + } + assert.equal(hasManifest, false, 'oversize row should result in no manifest file'); + + console.log('VFS row size trimming test passed'); +} finally { + await fixture.cleanup(); +} diff --git a/tests/tooling/vfs/vfs-row-trim-order.test.js b/tests/tooling/vfs/row-trim-order.test.js similarity index 100% rename from tests/tooling/vfs/vfs-row-trim-order.test.js rename to tests/tooling/vfs/row-trim-order.test.js diff --git a/tests/tooling/vfs/vfs-segment-coalesce.test.js b/tests/tooling/vfs/segment-coalesce.test.js similarity index 100% rename from tests/tooling/vfs/vfs-segment-coalesce.test.js rename to tests/tooling/vfs/segment-coalesce.test.js diff --git a/tests/tooling/vfs/vfs-segment-hash-cache.test.js b/tests/tooling/vfs/segment-hash-cache.test.js similarity index 100% rename from tests/tooling/vfs/vfs-segment-hash-cache.test.js rename to tests/tooling/vfs/segment-hash-cache.test.js diff --git a/tests/tooling/vfs/vfs-doc-hash-skip-rewrite.test.js b/tests/tooling/vfs/vfs-doc-hash-skip-rewrite.test.js deleted file mode 100644 index d3794e31f..000000000 --- a/tests/tooling/vfs/vfs-doc-hash-skip-rewrite.test.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { checksumString } from '../../../src/shared/hash.js'; -import { ensureVfsDiskDocument, resolveVfsDiskPath } from '../../../src/index/tooling/vfs.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const tempRoot = await makeTempDir('pairofcleats-vfs-doc-hash-'); - -try { - const virtualPath = '.poc-vfs/src/app.js#seg:segu:v1:abc.js'; - const text = 'console.log("hello");\n'; - const hash = await checksumString(text); - const docHash = `xxh64:${hash.value}`; - - await ensureVfsDiskDocument({ baseDir: tempRoot, virtualPath, text, docHash }); - const absPath = resolveVfsDiskPath({ baseDir: tempRoot, virtualPath }); - - await fs.writeFile(absPath, 'SENTINEL', 'utf8'); - await ensureVfsDiskDocument({ baseDir: tempRoot, virtualPath, text, docHash }); - - const content = await fs.readFile(absPath, 'utf8'); - assert.equal(content, 'SENTINEL', 'expected docHash cache to skip rewrite'); - - console.log('VFS doc hash skip rewrite test passed'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-fastpath-telemetry-contract.test.js b/tests/tooling/vfs/vfs-fastpath-telemetry-contract.test.js deleted file mode 100644 index 98b4e95dd..000000000 --- a/tests/tooling/vfs/vfs-fastpath-telemetry-contract.test.js +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { - buildVfsManifestRowsForFile, - createVfsManifestOffsetReader, - loadVfsManifestIndex, - loadVfsManifestRowByPath, - readVfsManifestRowsAtOffsets -} from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -applyTestEnv({ testing: '1' }); - -const runWriter = async ({ outDir, mode, rows }) => { - const writes = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = () => {}; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes: 1000000, - compression: null, - gzipOptions: null, - hashRouting: false, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-fastpath-telemetry-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const fileText = 'const a = 1;\nconst b = 2;\n'; - const rows = await buildVfsManifestRowsForFile({ - chunks: [ - { - file: 'src/a.ts', - lang: 'typescript', - segment: { - segmentUid: 'segu:v1:a', - segmentId: 'seg-a', - start: 0, - end: 12, - languageId: 'typescript', - ext: '.ts' - }, - start: 0, - end: 12 - }, - { - file: 'src/a.ts', - lang: 'typescript', - segment: { - segmentUid: 'segu:v1:b', - segmentId: 'seg-b', - start: 13, - end: fileText.length, - languageId: 'typescript', - ext: '.ts' - }, - start: 13, - end: fileText.length - } - ], - fileText, - containerPath: 'src/a.ts', - containerExt: '.ts', - containerLanguageId: 'typescript' - }); - - await runWriter({ outDir, mode: 'code', rows }); - - const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); - const indexPath = path.join(outDir, 'vfs_manifest.vfsidx'); - const bloomPath = path.join(outDir, 'vfs_manifest.vfsbloom.json'); - const index = await loadVfsManifestIndex({ indexPath }); - const telemetry = []; - - const bloomMissPath = '.poc-vfs/src/missing.ts#seg:segu:v1:missing.ts'; - const bloomMiss = await loadVfsManifestRowByPath({ - manifestPath, - indexPath, - bloomPath, - virtualPath: bloomMissPath, - allowScan: true, - telemetry - }); - assert.equal(bloomMiss, null, 'expected bloom negative lookup to return null'); - assert.deepEqual( - telemetry.filter((event) => event.virtualPath === bloomMissPath).map((event) => `${event.path}:${event.outcome}`), - ['bloom:negative'], - 'expected bloom-negative lookup to short-circuit without vfsidx/scan' - ); - - telemetry.length = 0; - const expectedRow = rows[0]; - const indexedHit = await loadVfsManifestRowByPath({ - manifestPath, - indexPath, - bloomPath, - virtualPath: expectedRow.virtualPath, - allowScan: true, - telemetry - }); - assert.deepEqual(indexedHit, expectedRow, 'expected vfsidx hit lookup to return matching row'); - assert.deepEqual( - telemetry.map((event) => `${event.path}:${event.outcome}`), - ['bloom:positive', 'vfsidx:hit'], - 'expected bloom positive then vfsidx hit telemetry' - ); - - telemetry.length = 0; - const staleIndexHit = await loadVfsManifestRowByPath({ - manifestPath, - index: new Map(), - virtualPath: expectedRow.virtualPath, - allowScan: true, - telemetry - }); - assert.deepEqual(staleIndexHit, expectedRow, 'expected scan fallback to recover from stale index misses'); - assert.deepEqual( - telemetry.map((event) => `${event.path}:${event.outcome}`), - ['vfsidx:miss', 'scan:hit'], - 'expected stale index miss to continue with scan fallback' - ); - - telemetry.length = 0; - const scanHit = await loadVfsManifestRowByPath({ - manifestPath, - virtualPath: expectedRow.virtualPath, - allowScan: true, - telemetry - }); - assert.deepEqual(scanHit, expectedRow, 'expected scan fallback to return row'); - assert.deepEqual( - telemetry.map((event) => `${event.path}:${event.outcome}`), - ['scan:hit'], - 'expected scan fallback telemetry' - ); - - telemetry.length = 0; - const scanDisabled = await loadVfsManifestRowByPath({ - manifestPath, - virtualPath: '.poc-vfs/src/missing.ts#seg:segu:v1:none.ts', - allowScan: false, - telemetry - }); - assert.equal(scanDisabled, null, 'expected disabled scan to return null'); - assert.deepEqual( - telemetry.map((event) => `${event.path}:${event.outcome}`), - ['scan:disabled'], - 'expected scan disabled telemetry' - ); - - const reader = createVfsManifestOffsetReader({ manifestPath, maxBufferPoolEntries: 4 }); - try { - const requests = rows.map((row) => { - const entry = index.get(row.virtualPath); - assert.ok(entry, `expected index entry for ${row.virtualPath}`); - return { offset: entry.offset, bytes: entry.bytes }; - }); - const loadedA = await readVfsManifestRowsAtOffsets({ manifestPath, requests, reader }); - const loadedB = await readVfsManifestRowsAtOffsets({ manifestPath, requests, reader }); - assert.deepEqual( - loadedA.map((row) => row?.virtualPath || null), - rows.map((row) => row.virtualPath), - 'expected batched offset loads to preserve row ordering' - ); - assert.deepEqual( - loadedB.map((row) => row?.virtualPath || null), - rows.map((row) => row.virtualPath), - 'expected repeated batched reads to remain stable' - ); - const stats = reader.stats(); - assert.equal(stats.handleOpens, 1, 'expected batched reader to reuse a single file handle'); - assert.ok(stats.batchCalls >= 2, 'expected batched reader to report batch calls'); - assert.ok(stats.bufferReuses >= 1, 'expected batched reader to reuse pooled buffers'); - } finally { - await reader.close(); - } - - console.log('VFS fast-path telemetry contract test passed'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-hash-routing-compat.test.js b/tests/tooling/vfs/vfs-hash-routing-compat.test.js deleted file mode 100644 index 11efd7d5b..000000000 --- a/tests/tooling/vfs/vfs-hash-routing-compat.test.js +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - buildVfsHashVirtualPath, - buildVfsVirtualPath, - resolveVfsVirtualPath -} from '../../../src/index/tooling/vfs.js'; - -const containerPath = 'src/app.ts'; -const segmentUid = 'segu:v1:abc'; -const effectiveExt = '.ts'; - -const legacy = buildVfsVirtualPath({ containerPath, segmentUid, effectiveExt }); -const fallback = resolveVfsVirtualPath({ - containerPath, - segmentUid, - effectiveExt, - docHash: null, - hashRouting: true -}); -assert.equal(fallback, legacy, 'Expected hash routing to fall back to legacy path when docHash is missing.'); - -const docHash = 'xxh64:0123456789abcdef'; -const hashPath = buildVfsHashVirtualPath({ docHash, effectiveExt }); -assert.ok(hashPath && hashPath.startsWith('.poc-vfs/by-hash/'), 'Expected hash virtual path prefix.'); - -const resolved = resolveVfsVirtualPath({ - containerPath, - segmentUid, - effectiveExt, - docHash, - hashRouting: true -}); -assert.equal(resolved, hashPath, 'Expected hash routing to return hash virtual path.'); - -console.log('vfs hash routing compat ok'); diff --git a/tests/tooling/vfs/vfs-hash-routing-contract.test.js b/tests/tooling/vfs/vfs-hash-routing-contract.test.js deleted file mode 100644 index 19f2b174c..000000000 --- a/tests/tooling/vfs/vfs-hash-routing-contract.test.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { checksumString } from '../../../src/shared/hash.js'; -import * as mod from '../../../src/index/tooling/vfs-hash-routing.js'; -const buildVfsRoutingToken = mod.buildVfsRoutingToken || mod.resolveVfsRoutingToken; -assert.equal(typeof buildVfsRoutingToken, 'function', 'Expected buildVfsRoutingToken export.'); - -assert.equal( - mod.VFS_HASH_ROUTING_SCHEMA_VERSION, - '1.0.0', - 'Expected VFS hash routing schema version 1.0.0.' -); - -const virtualPath = '.poc-vfs/src/app.ts#seg:segu:v1:abc.ts'; -const docHash = 'xxh64:0123456789abcdef'; -const routingKey = `${docHash}|${virtualPath}`; -const expectedHash = await checksumString(routingKey); -const expectedToken = expectedHash?.value || ''; - -const token = await buildVfsRoutingToken({ - virtualPath, - docHash, - mode: 'docHash+virtualPath' -}); - -assert.equal(token, expectedToken, 'Expected routing token to be xxh64(routingKey).'); -assert.ok(/^[0-9a-f]{16}$/.test(token), 'Routing token should be lowercase hex (xxh64).'); - -console.log('VFS hash routing contract ok.'); diff --git a/tests/tooling/vfs/vfs-hash-routing-roundtrip.test.js b/tests/tooling/vfs/vfs-hash-routing-roundtrip.test.js deleted file mode 100644 index 661e14688..000000000 --- a/tests/tooling/vfs/vfs-hash-routing-roundtrip.test.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - buildToolingVirtualDocuments, - buildVfsHashVirtualPath -} from '../../../src/index/tooling/vfs.js'; - -const fileText = 'console.log(1);\n'; -const chunks = [ - { - file: 'src/App.vue', - lang: 'typescript', - ext: '.vue', - containerLanguageId: 'vue', - segment: { - segmentUid: 'segu:v1:hash', - segmentId: 'seg-hash', - start: 0, - end: fileText.length, - languageId: 'typescript', - ext: '.ts' - }, - chunkUid: 'chunk:1', - start: 0, - end: fileText.length, - fileHash: 'deadbeef' - } -]; - -const fileTextByPath = new Map([['src/App.vue', fileText]]); - -const { documents } = await buildToolingVirtualDocuments({ - chunks, - fileTextByPath, - hashRouting: true, - strict: true -}); - -assert.equal(documents.length, 1); -const doc = documents[0]; -assert.ok(doc.virtualPath.startsWith('.poc-vfs/by-hash/')); -const expectedHashPath = buildVfsHashVirtualPath({ - docHash: doc.docHash, - effectiveExt: doc.effectiveExt -}); -assert.equal(doc.virtualPath, expectedHashPath); -assert.equal( - doc.legacyVirtualPath, - '.poc-vfs/src/App.vue#seg:segu:v1:hash.ts' -); - -const plain = await buildToolingVirtualDocuments({ - chunks, - fileTextByPath, - hashRouting: false, - strict: true -}); -assert.equal(plain.documents.length, 1); -assert.equal(plain.documents[0].legacyVirtualPath, null); -assert.equal( - plain.documents[0].virtualPath, - '.poc-vfs/src/App.vue#seg:segu:v1:hash.ts' -); - -console.log('vfs hash routing ok'); diff --git a/tests/tooling/vfs/vfs-hash-routing.test.js b/tests/tooling/vfs/vfs-hash-routing.test.js deleted file mode 100644 index b848be5d0..000000000 --- a/tests/tooling/vfs/vfs-hash-routing.test.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { checksumString } from '../../../src/shared/hash.js'; -import { buildVfsRoutingToken } from '../../../src/index/tooling/vfs-hash-routing.js'; - -const virtualPath = '.poc-vfs/src/app.ts#seg:segu:v1:abc.ts'; -const docHash = 'xxh64:0123456789abcdef'; - -const expectedCombined = (await checksumString(`${docHash}|${virtualPath}`)).value; -const tokenCombined = await buildVfsRoutingToken({ virtualPath, docHash }); -assert.equal(tokenCombined, expectedCombined, 'Expected routing token for docHash+virtualPath.'); -assert.ok(/^[0-9a-f]{16}$/.test(tokenCombined), 'Expected routing token to be lowercase hex.'); - -const expectedDocOnly = (await checksumString(docHash)).value; -const tokenDocOnly = await buildVfsRoutingToken({ virtualPath, docHash, mode: 'docHash' }); -assert.equal(tokenDocOnly, expectedDocOnly, 'Expected routing token for docHash-only mode.'); - -const missing = await buildVfsRoutingToken({ virtualPath, docHash: null }); -assert.equal(missing, null, 'Expected null token when docHash is missing.'); - -console.log('vfs hash routing token ok'); diff --git a/tests/tooling/vfs/vfs-index-lookup.test.js b/tests/tooling/vfs/vfs-index-lookup.test.js deleted file mode 100644 index 1e31f52ac..000000000 --- a/tests/tooling/vfs/vfs-index-lookup.test.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { buildVfsIndexRows } from '../../../src/index/tooling/vfs-index.js'; -import { compareVfsManifestRows } from '../../../src/index/tooling/vfs.js'; - -const rows = [ - { - schemaVersion: '1.0.0', - virtualPath: '.poc-vfs/b.ts#seg:seg-b.ts', - docHash: 'xxh64:bbbbbbbbbbbbbbbb', - containerPath: 'b.ts', - containerExt: '.ts', - containerLanguageId: 'typescript', - languageId: 'typescript', - effectiveExt: '.ts', - segmentUid: 'seg-b', - segmentId: 'seg-b', - segmentStart: 5, - segmentEnd: 10, - lineStart: 1, - lineEnd: 1 - }, - { - schemaVersion: '1.0.0', - virtualPath: '.poc-vfs/a.ts#seg:seg-a.ts', - docHash: 'xxh64:aaaaaaaaaaaaaaaa', - containerPath: 'a.ts', - containerExt: '.ts', - containerLanguageId: 'typescript', - languageId: 'typescript', - effectiveExt: '.ts', - segmentUid: 'seg-a', - segmentId: 'seg-a', - segmentStart: 0, - segmentEnd: 4, - lineStart: 1, - lineEnd: 1 - } -]; - -const indexRows = buildVfsIndexRows(rows); -assert.equal(indexRows.length, rows.length, 'Expected one index row per manifest row.'); -for (const row of indexRows) { - assert.ok(row.manifestSortKey, 'Expected manifestSortKey to be populated.'); -} - -const sortedManifest = rows.slice().sort(compareVfsManifestRows).map((row) => row.virtualPath); -const sortedIndex = indexRows - .slice() - .sort((a, b) => String(a.manifestSortKey).localeCompare(String(b.manifestSortKey))) - .map((row) => row.virtualPath); - -assert.deepStrictEqual(sortedIndex, sortedManifest, 'Expected index sort keys to match manifest ordering.'); - -console.log('vfs index lookup ok'); diff --git a/tests/tooling/vfs/vfs-index-negative.test.js b/tests/tooling/vfs/vfs-index-negative.test.js deleted file mode 100644 index 1584154f3..000000000 --- a/tests/tooling/vfs/vfs-index-negative.test.js +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { - buildVfsManifestRowsForFile, - loadVfsManifestRowByPath -} from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const runWriter = async ({ outDir, mode, rows }) => { - const writes = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = () => {}; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes: 1000000, - compression: null, - gzipOptions: null, - hashRouting: false, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-idx-negative-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const fileText = 'console.log(1);\n'; - const rows = await buildVfsManifestRowsForFile({ - chunks: [ - { - file: 'a.md', - lang: 'javascript', - segment: { - segmentUid: 'segu:v1:a', - segmentId: 'seg-a', - start: 0, - end: fileText.length, - languageId: 'javascript', - ext: null - }, - start: 0, - end: fileText.length - } - ], - fileText, - containerPath: 'a.md', - containerExt: '.md', - containerLanguageId: 'markdown' - }); - - await runWriter({ outDir, mode: 'code', rows }); - - const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); - const indexPath = path.join(outDir, 'vfs_manifest.vfsidx'); - const bloomPath = path.join(outDir, 'vfs_manifest.vfsbloom.json'); - - const missingPath = '.poc-vfs/missing.md#seg:missing'; - const notFound = await loadVfsManifestRowByPath({ - manifestPath, - indexPath, - bloomPath, - virtualPath: missingPath - }); - assert.equal(notFound, null, 'Expected missing virtualPath to return null with bloom/index.'); - - const noIndex = await loadVfsManifestRowByPath({ - manifestPath, - virtualPath: missingPath, - allowScan: false - }); - assert.equal(noIndex, null, 'Expected null without index when allowScan=false.'); - - const scanMiss = await loadVfsManifestRowByPath({ - manifestPath, - virtualPath: missingPath, - allowScan: true - }); - assert.equal(scanMiss, null, 'Expected null when scanning for missing virtualPath.'); - - console.log('vfs index negative lookup ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-io-batch-consistency.test.js b/tests/tooling/vfs/vfs-io-batch-consistency.test.js deleted file mode 100644 index b87fbb36b..000000000 --- a/tests/tooling/vfs/vfs-io-batch-consistency.test.js +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; -import { ensureVfsDiskDocument, resolveVfsDiskPath } from '../../../src/index/tooling/vfs.js'; -import { ensureVirtualFilesBatch, resolveVfsIoBatching } from '../../../src/integrations/tooling/providers/lsp.js'; - -const tempRoot = await makeTempDir('pairofcleats-vfs-io-batch-'); -const outDir = path.join(tempRoot, 'vfs'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const docs = [ - { - virtualPath: '.poc-vfs/src/a.ts#seg:seg-a.ts', - text: 'const a = 1;\n', - docHash: 'xxh64:aaaaaaaaaaaaaaaa' - }, - { - virtualPath: '.poc-vfs/src/b.ts#seg:seg-b.ts', - text: 'const b = 2;\n', - docHash: 'xxh64:bbbbbbbbbbbbbbbb' - }, - { - virtualPath: '.poc-vfs/src/c.ts#seg:seg-c.ts', - text: 'const c = 3;\n', - docHash: 'xxh64:cccccccccccccccc' - } - ]; - - const sequential = new Map(); - for (const doc of docs) { - const result = await ensureVfsDiskDocument({ - baseDir: outDir, - virtualPath: doc.virtualPath, - text: doc.text, - docHash: doc.docHash - }); - sequential.set(doc.virtualPath, result.path); - } - - const batching = resolveVfsIoBatching({ enabled: true, maxInflight: 2, maxQueueEntries: 2 }); - const batched = await ensureVirtualFilesBatch({ - rootDir: outDir, - docs, - batching - }); - - assert.equal(batched.size, docs.length, 'Expected batched write to return all paths.'); - - for (const doc of docs) { - const expectedPath = resolveVfsDiskPath({ baseDir: outDir, virtualPath: doc.virtualPath }); - const seqPath = sequential.get(doc.virtualPath); - const batchedPath = batched.get(doc.virtualPath); - assert.equal(seqPath, expectedPath, 'Expected sequential path to be deterministic.'); - assert.equal(batchedPath, expectedPath, 'Expected batched path to be deterministic.'); - const contents = await fs.readFile(batchedPath, 'utf8'); - assert.equal(contents, doc.text, 'Expected batched write contents to match.'); - } - - const rerun = await ensureVirtualFilesBatch({ - rootDir: outDir, - docs, - batching - }); - for (const doc of docs) { - assert.equal(rerun.get(doc.virtualPath), sequential.get(doc.virtualPath), 'Expected stable path on rerun.'); - } - - console.log('vfs io batch consistency ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-merge-heap-deterministic.test.js b/tests/tooling/vfs/vfs-merge-heap-deterministic.test.js deleted file mode 100644 index c070f504c..000000000 --- a/tests/tooling/vfs/vfs-merge-heap-deterministic.test.js +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { createVfsManifestCollector } from '../../../src/index/build/vfs-manifest-collector.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { compareVfsManifestRows } from '../../../src/index/tooling/vfs.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const readJsonl = async (filePath) => { - const raw = await fs.readFile(filePath, 'utf8'); - return raw - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line)); -}; - -const runWriter = async ({ outDir, rows }) => { - const writes = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = () => {}; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode: 'code', - rows, - maxJsonBytes: 1000000, - compression: null, - gzipOptions: null, - hashRouting: false, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-merge-heap-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const rows = [ - { - schemaVersion: '1.0.0', - virtualPath: '.poc-vfs/b.ts#seg:seg-b.ts', - docHash: 'xxh64:bbbbbbbbbbbbbbbb', - containerPath: 'b.ts', - containerExt: '.ts', - containerLanguageId: 'typescript', - languageId: 'typescript', - effectiveExt: '.ts', - segmentUid: 'seg-b', - segmentId: 'seg-b', - segmentStart: 10, - segmentEnd: 20, - lineStart: 1, - lineEnd: 2 - }, - { - schemaVersion: '1.0.0', - virtualPath: '.poc-vfs/a.ts#seg:seg-a.ts', - docHash: 'xxh64:aaaaaaaaaaaaaaaa', - containerPath: 'a.ts', - containerExt: '.ts', - containerLanguageId: 'typescript', - languageId: 'typescript', - effectiveExt: '.ts', - segmentUid: 'seg-a', - segmentId: 'seg-a', - segmentStart: 0, - segmentEnd: 5, - lineStart: 1, - lineEnd: 1 - }, - { - schemaVersion: '1.0.0', - virtualPath: '.poc-vfs/a.ts#seg:seg-c.ts', - docHash: 'xxh64:cccccccccccccccc', - containerPath: 'a.ts', - containerExt: '.ts', - containerLanguageId: 'typescript', - languageId: 'typescript', - effectiveExt: '.ts', - segmentUid: 'seg-c', - segmentId: 'seg-c', - segmentStart: 6, - segmentEnd: 9, - lineStart: 2, - lineEnd: 2 - } - ]; - - const collector = createVfsManifestCollector({ - buildRoot: tempRoot, - maxBufferRows: 1, - maxBufferBytes: 1 - }); - await collector.appendRows(rows); - - await runWriter({ outDir, rows: collector }); - - const manifestPath = path.join(outDir, 'vfs_manifest.jsonl'); - const written = await readJsonl(manifestPath); - const expected = rows.slice().sort(compareVfsManifestRows); - - assert.deepStrictEqual( - written.map((row) => row.virtualPath), - expected.map((row) => row.virtualPath), - 'Expected heap-merged manifest rows to be deterministically sorted.' - ); - - console.log('vfs merge heap deterministic ok'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-offset-reader-close-timeout.test.js b/tests/tooling/vfs/vfs-offset-reader-close-timeout.test.js deleted file mode 100644 index dd8e6b4e3..000000000 --- a/tests/tooling/vfs/vfs-offset-reader-close-timeout.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { createVfsManifestOffsetReader } from '../../../src/index/tooling/vfs.js'; -import { applyTestEnv } from '../../helpers/test-env.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -applyTestEnv({ testing: '1' }); - -const tempRoot = await makeTempDir('pairofcleats-vfs-offset-reader-close-timeout-'); -const manifestPath = path.join(tempRoot, 'vfs_manifest.jsonl'); -await fs.writeFile( - manifestPath, - `${JSON.stringify({ virtualPath: '.poc-vfs/src/a.ts#seg:a', chunks: [] })}\n`, - 'utf8' -); - -const originalOpen = fs.open; -try { - fs.open = async (...args) => { - const handle = await originalOpen(...args); - return new Proxy(handle, { - get(target, prop, receiver) { - if (prop === 'close') { - return async () => new Promise(() => {}); - } - const value = Reflect.get(target, prop, receiver); - return typeof value === 'function' ? value.bind(target) : value; - } - }); - }; - - const logs = []; - const reader = createVfsManifestOffsetReader({ - manifestPath, - closeTimeoutMs: 25, - log: (line) => logs.push(String(line)) - }); - await reader.readAtOffset({ offset: 0, bytes: 256 }); - const closeStartedAtMs = Date.now(); - await reader.close(); - const closeElapsedMs = Date.now() - closeStartedAtMs; - assert.ok( - closeElapsedMs < 1500, - `expected reader close timeout to remain bounded (elapsed=${closeElapsedMs}ms)` - ); - assert.ok( - logs.some((line) => line.includes('vfs-offset-reader.close timed out')), - 'expected timeout close path to emit a warning log line' - ); -} finally { - fs.open = originalOpen; - await rmDirRecursive(tempRoot); -} - -console.log('VFS offset-reader close timeout test passed'); diff --git a/tests/tooling/vfs/vfs-partial-lsp-open.test.js b/tests/tooling/vfs/vfs-partial-lsp-open.test.js deleted file mode 100644 index 4e1624f1f..000000000 --- a/tests/tooling/vfs/vfs-partial-lsp-open.test.js +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { collectLspTypes } from '../../../src/integrations/tooling/providers/lsp.js'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const tempRoot = resolveTestCachePath(root, 'vfs-partial-lsp-open'); -await fs.rm(tempRoot, { recursive: true, force: true }); -await fs.mkdir(tempRoot, { recursive: true }); - -const tracePath = path.join(tempRoot, 'trace.jsonl'); -const serverPath = path.join(root, 'tests', 'fixtures', 'lsp', 'stub-lsp-server.js'); - -const docText = 'int add(int a, int b) { return a + b; }\n'; -const docText2 = 'int sub(int a, int b) { return a - b; }\n'; -const virtualPath1 = '.poc-vfs/src/sample.cpp#seg:stub.cpp'; -const virtualPath2 = '.poc-vfs/src/unused.cpp#seg:stub.cpp'; -const documents = [ - { virtualPath: virtualPath1, text: docText, languageId: 'cpp', effectiveExt: '.cpp' }, - { virtualPath: virtualPath2, text: docText2, languageId: 'cpp', effectiveExt: '.cpp' } -]; - -const chunkUid = 'ck64:v1:test:src/sample.cpp:deadbeef'; -const targets = [{ - chunkRef: { - docId: 0, - chunkUid, - chunkId: 'chunk_deadbeef', - file: 'src/sample.cpp', - segmentUid: null, - segmentId: null, - range: { start: 0, end: docText.length } - }, - virtualPath: virtualPath1, - virtualRange: { start: 0, end: docText.length }, - symbolHint: { name: 'add', kind: 'function' } -}]; - -const originalTrace = process.env.POC_LSP_TRACE; -process.env.POC_LSP_TRACE = tracePath; -try { - await collectLspTypes({ - rootDir: tempRoot, - vfsRoot: tempRoot, - documents, - targets, - cmd: process.execPath, - args: [serverPath, '--mode', 'clangd'], - parseSignature: (detail) => ({ - signature: detail, - returnType: 'int', - paramTypes: { a: 'int', b: 'int' } - }) - }); -} finally { - process.env.POC_LSP_TRACE = originalTrace; -} - -const traceRaw = await fs.readFile(tracePath, 'utf8'); -const events = traceRaw.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); -const didOpenCount = events.filter((evt) => evt.kind === 'notification' && evt.method === 'textDocument/didOpen').length; -const documentSymbolCount = events.filter((evt) => evt.kind === 'request' && evt.method === 'textDocument/documentSymbol').length; - -assert.equal(didOpenCount, 1, 'expected only one didOpen (docs without targets should be skipped)'); -assert.equal(documentSymbolCount, 1, 'expected only one documentSymbol request'); - -console.log('VFS partial LSP open test passed'); diff --git a/tests/tooling/vfs/vfs-routing-by-effective-language.test.js b/tests/tooling/vfs/vfs-routing-by-effective-language.test.js deleted file mode 100644 index daaf0b14e..000000000 --- a/tests/tooling/vfs/vfs-routing-by-effective-language.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { discoverSegments, assignSegmentUids, chunkSegments } from '../../../src/index/segments.js'; -import { assignChunkUids } from '../../../src/index/identity/chunk-uid.js'; -import { buildToolingVirtualDocuments } from '../../../src/index/tooling/vfs.js'; - -const relPath = 'src/App.vue'; -const text = [ - '', - '' -].join('\n'); - -let segments = discoverSegments({ - text, - ext: '.vue', - relPath, - mode: 'code', - languageId: 'vue' -}); -segments = await assignSegmentUids({ text, segments, ext: '.vue', mode: 'code' }); - -const chunks = chunkSegments({ - text, - ext: '.vue', - relPath, - mode: 'code', - segments -}); -for (const chunk of chunks) { - chunk.file = relPath; -} - -await assignChunkUids({ chunks, fileText: text, fileRelPath: relPath, strict: true }); - -const { documents } = await buildToolingVirtualDocuments({ - chunks, - fileTextByPath: new Map([[relPath, text]]), - strict: true -}); - -const tsDocs = documents.filter((doc) => doc.effectiveExt === '.ts'); -assert.equal(tsDocs.length, 1, 'expected exactly one TypeScript virtual document'); -assert.equal(tsDocs[0].languageId, 'typescript'); -assert.ok(tsDocs[0].virtualPath.startsWith('.poc-vfs/'), 'expected .poc-vfs routing for segment docs'); - -console.log('VFS routing by effective language test passed'); diff --git a/tests/tooling/vfs/vfs-row-size-trim.test.js b/tests/tooling/vfs/vfs-row-size-trim.test.js deleted file mode 100644 index a7236755d..000000000 --- a/tests/tooling/vfs/vfs-row-size-trim.test.js +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { buildVfsManifestRowsForFile, VFS_MANIFEST_MAX_ROW_BYTES } from '../../../src/index/tooling/vfs.js'; -import { enqueueVfsManifestArtifacts } from '../../../src/index/build/artifacts/writers/vfs-manifest.js'; -import { writePiecesManifest } from '../../helpers/artifact-io-fixture.js'; -import { makeTempDir, rmDirRecursive } from '../../helpers/temp.js'; - -const runWriter = async ({ outDir, mode, rows, maxJsonBytes }) => { - const writes = []; - const pieceFiles = []; - const enqueueWrite = (label, fn) => { - writes.push({ label, fn }); - }; - const addPieceFile = (entry, absPath) => { - pieceFiles.push({ entry, absPath }); - }; - const formatArtifactLabel = (value) => value; - - await enqueueVfsManifestArtifacts({ - outDir, - mode, - rows, - maxJsonBytes, - compression: null, - gzipOptions: null, - enqueueWrite, - addPieceFile, - formatArtifactLabel - }); - - for (const write of writes) { - await write.fn(); - } - if (pieceFiles.length) { - const pieces = pieceFiles.map(({ entry, absPath }) => ({ - ...entry, - path: path.relative(outDir, absPath).replace(/\\/g, '/') - })); - await writePiecesManifest(outDir, pieces); - } -}; - -const tempRoot = await makeTempDir('pairofcleats-vfs-row-size-'); -const outDir = path.join(tempRoot, 'out'); -await fs.mkdir(outDir, { recursive: true }); - -try { - const containerPath = 'docs/trim.md'; - const containerExt = '.md'; - const containerLanguageId = 'markdown'; - const fileText = 'console.log(1);\n'; - const chunks = [ - { - file: containerPath, - lang: 'javascript', - segment: { - segmentUid: 'segu:v1:trim', - segmentId: 'seg-trim', - start: 0, - end: fileText.length, - languageId: 'javascript', - ext: null - }, - start: 0, - end: fileText.length - } - ]; - - const baseRows = await buildVfsManifestRowsForFile({ - chunks, - fileText, - containerPath, - containerExt, - containerLanguageId - }); - assert.equal(baseRows.length, 1, 'expected a base vfs manifest row'); - - const oversized = { - ...baseRows[0], - extensions: { blob: 'x'.repeat(40000) } - }; - - await runWriter({ outDir, mode: 'code', rows: [oversized], maxJsonBytes: 1024 * 1024 }); - const loaded = await loadJsonArrayArtifact(outDir, 'vfs_manifest', { strict: false }); - - assert.equal(loaded.length, 1, 'trimmed row should still be emitted'); - assert.ok(!loaded[0].extensions, 'extensions should be trimmed when oversize'); - - const rowBytes = Buffer.byteLength(JSON.stringify(loaded[0]), 'utf8'); - assert.ok(rowBytes <= VFS_MANIFEST_MAX_ROW_BYTES, 'trimmed row should fit within max bytes'); - - const huge = { - ...baseRows[0], - containerPath: 'a'.repeat(VFS_MANIFEST_MAX_ROW_BYTES * 2), - virtualPath: `.poc-vfs/${'a'.repeat(VFS_MANIFEST_MAX_ROW_BYTES * 2)}` - }; - - const dropDir = path.join(tempRoot, 'drop'); - await fs.mkdir(dropDir, { recursive: true }); - await runWriter({ outDir: dropDir, mode: 'code', rows: [huge], maxJsonBytes: 1024 * 1024 }); - let hasManifest = true; - try { - await fs.stat(path.join(dropDir, 'vfs_manifest.jsonl')); - } catch { - hasManifest = false; - } - assert.equal(hasManifest, false, 'oversize row should result in no manifest file'); - - console.log('VFS row size trimming test passed'); -} finally { - await rmDirRecursive(tempRoot); -} diff --git a/tests/tooling/vfs/vfs-token-uri-contract.test.js b/tests/tooling/vfs/vfs-token-uri-contract.test.js deleted file mode 100644 index d016788d8..000000000 --- a/tests/tooling/vfs/vfs-token-uri-contract.test.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import * as mod from '../../../src/integrations/tooling/lsp/uris.js'; -const buildVfsTokenUri = mod.buildVfsTokenUri || mod.buildVfsUri; -assert.equal(typeof buildVfsTokenUri, 'function', 'Expected buildVfsTokenUri export.'); - -const virtualPath = '.poc-vfs/docs/hello%world#v2.md#seg:segu:v1:abc.ts'; -const token = 'abcdef0123456789'; -const uri = buildVfsTokenUri({ virtualPath, token }); - -assert.ok(uri.startsWith('poc-vfs:///'), 'Expected poc-vfs scheme.'); -assert.ok(uri.includes('token=abcdef0123456789'), 'Expected token query parameter.'); -assert.ok(uri.includes('%23'), 'Expected # to be percent-encoded in the path.'); -assert.ok(!uri.includes('#seg:'), 'Expected raw segment marker to be encoded.'); - -console.log('VFS token URI contract ok.'); diff --git a/tests/tooling/vfs/vfs-token-uri-roundtrip.test.js b/tests/tooling/vfs/vfs-token-uri-roundtrip.test.js deleted file mode 100644 index b956a9537..000000000 --- a/tests/tooling/vfs/vfs-token-uri-roundtrip.test.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { - buildVfsToken, - buildVfsTokenUri, - parseVfsTokenUri -} from '../../../src/integrations/tooling/lsp/uris.js'; - -const virtualPath = '.poc-vfs/docs/hello%world#seg:segu:v1:abc.ts'; -const docHash = 'xxh64:0123456789abcdef'; - -const token = await buildVfsToken({ virtualPath, docHash, mode: 'docHash+virtualPath' }); -assert.ok(/^[0-9a-f]{16}$/.test(token), 'Expected xxh64 token hex.'); - -const uri = buildVfsTokenUri({ virtualPath, token }); -assert.ok(uri.startsWith('poc-vfs:///'), 'Expected poc-vfs scheme.'); -assert.ok(uri.includes('token='), 'Expected token query parameter.'); - -const parsed = parseVfsTokenUri(uri); -assert.equal(parsed?.virtualPath, virtualPath, 'Expected virtualPath roundtrip.'); -assert.equal(parsed?.token, token, 'Expected token roundtrip.'); - -console.log('VFS token URI roundtrip ok'); diff --git a/tests/tooling/vfs/vfs-virtualpath-deterministic.test.js b/tests/tooling/vfs/vfs-virtualpath-deterministic.test.js deleted file mode 100644 index 832f9c1ca..000000000 --- a/tests/tooling/vfs/vfs-virtualpath-deterministic.test.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { assignChunkUids } from '../../../src/index/identity/chunk-uid.js'; -import { buildToolingVirtualDocuments } from '../../../src/index/tooling/vfs.js'; - -const relPath = 'src/app.js'; -const fileText = 'function ping() { return 1; }\n'; -const chunk = { - file: relPath, - ext: '.js', - lang: 'javascript', - start: 0, - end: fileText.length, - name: 'ping', - kind: 'FunctionDeclaration' -}; - -await assignChunkUids({ chunks: [chunk], fileText, fileRelPath: relPath, strict: true }); - -const buildOnce = async () => buildToolingVirtualDocuments({ - chunks: [chunk], - fileTextByPath: new Map([[relPath, fileText]]), - strict: true -}); - -const first = await buildOnce(); -const second = await buildOnce(); - -assert.equal(first.documents.length, 1, 'expected one document'); -assert.equal(second.documents.length, 1, 'expected one document'); -assert.equal(first.documents[0].virtualPath, second.documents[0].virtualPath, 'expected deterministic virtualPath'); - -console.log('VFS virtualPath determinism test passed'); diff --git a/tests/tooling/vscode/api-failure-runtime.test.js b/tests/tooling/vscode/api-failure-runtime.test.js new file mode 100644 index 000000000..f1e0a86ba --- /dev/null +++ b/tests/tooling/vscode/api-failure-runtime.test.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { requestApiJson } = require('../../../extensions/vscode/runtime.js'); + +const originalFetch = globalThis.fetch; + +try { + globalThis.fetch = async () => ({ + ok: false, + status: 401, + async text() { + return JSON.stringify({ message: 'token expired' }); + } + }); + const unauthorized = await requestApiJson('http://127.0.0.1:4311', '/search', { + method: 'POST', + payload: { query: 'AuthToken' }, + label: 'PairOfCleats search' + }); + assert.equal(unauthorized.ok, false); + assert.equal(unauthorized.kind, 'api-unauthorized'); + assert.match(unauthorized.message, /unauthorized/i); + assert.match(unauthorized.detail || '', /token expired/i); + + globalThis.fetch = async () => ({ + ok: false, + status: 403, + async text() { + return JSON.stringify({ message: 'workspace denied' }); + } + }); + const forbidden = await requestApiJson('http://127.0.0.1:4311', '/search', { + method: 'POST', + payload: { query: 'AuthToken' }, + label: 'PairOfCleats search' + }); + assert.equal(forbidden.ok, false); + assert.equal(forbidden.kind, 'api-forbidden'); + assert.match(forbidden.message, /forbidden/i); + assert.match(forbidden.detail || '', /workspace denied/i); + + globalThis.fetch = async () => ({ + ok: false, + status: 500, + async text() { + return 'upstream blew up'; + } + }); + const httpError = await requestApiJson('http://127.0.0.1:4311', '/search', { + method: 'POST', + payload: { query: 'AuthToken' }, + label: 'PairOfCleats search' + }); + assert.equal(httpError.ok, false); + assert.equal(httpError.kind, 'api-http-error'); + assert.match(httpError.message, /HTTP 500/i); + assert.match(httpError.detail || '', /upstream blew up/i); + + globalThis.fetch = async () => ({ + ok: true, + status: 200, + async text() { + return '{"broken"'; + } + }); + const invalidJson = await requestApiJson('http://127.0.0.1:4311', '/search', { + method: 'POST', + payload: { query: 'AuthToken' }, + label: 'PairOfCleats search' + }); + assert.equal(invalidJson.ok, false); + assert.equal(invalidJson.kind, 'api-invalid-json'); + assert.match(invalidJson.message, /invalid json/i); + + globalThis.fetch = async () => { + const error = new Error('socket hang up'); + throw error; + }; + const requestError = await requestApiJson('http://127.0.0.1:4311', '/search', { + method: 'POST', + payload: { query: 'AuthToken' }, + label: 'PairOfCleats search' + }); + assert.equal(requestError.ok, false); + assert.equal(requestError.kind, 'api-request-error'); + assert.match(requestError.message, /socket hang up/i); +} finally { + globalThis.fetch = originalFetch; +} + +console.log('vscode api failure runtime test passed'); diff --git a/tests/tooling/vscode/api-runtime.test.js b/tests/tooling/vscode/api-runtime.test.js new file mode 100644 index 000000000..a3092afd4 --- /dev/null +++ b/tests/tooling/vscode/api-runtime.test.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { getRuntimeCapabilityManifest } from '../../../src/shared/runtime-capability-manifest.js'; + +import { + createVsCodeRuntimeHarness, + prepareVsCodeFixtureWorkspace +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-api-' +}); + +const remoteWorkspaceUri = { + scheme: 'vscode-remote', + fsPath: '/workspace/repo', + path: '/workspace/repo', + toString() { + return `${this.scheme}:${this.path}`; + } +}; +const remoteFileUri = { + scheme: 'vscode-remote', + fsPath: '/workspace/repo/src/app.ts', + path: '/workspace/repo/src/app.ts', + toString() { + return `${this.scheme}:${this.path}`; + } +}; + +const apiHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'remote', uri: remoteWorkspaceUri }], + activeEditor: { document: { uri: remoteFileUri } }, + configValues: { + apiServerUrl: 'http://127.0.0.1:4311', + apiExecutionMode: 'require', + apiTimeoutMs: 4321, + searchMode: 'code' + } +}); +const runtimeManifest = getRuntimeCapabilityManifest({ + runtimeCapabilities: { + watcher: { chokidar: false, parcel: false }, + regex: { re2: false, re2js: false }, + hash: { nodeRsXxhash: false, wasmXxhash: false }, + compression: { gzip: true, zstd: false }, + extractors: { pdf: false, docx: false }, + mcp: { sdk: false, legacy: true }, + externalBackends: { tantivy: false, lancedb: false }, + nativeAccel: { enabled: false, runtimeKind: 'js', abiVersion: 1, featureBits: 0 } + } +}); + +try { + apiHarness.activate(); + apiHarness.inputQueue.push('AuthToken'); + apiHarness.quickPickQueue.push(null); + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + runtimeManifest + } + }); + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + result: { + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + } + } + }); + await apiHarness.runCommand('pairofcleats.search'); + + assert.equal(apiHarness.spawnCalls.length, 0, 'API search should not spawn CLI'); + assert.equal(apiHarness.fetchCalls.length, 2, 'API search should probe capabilities then search'); + assert.ok(String(apiHarness.fetchCalls[0].url).endsWith('/capabilities')); + assert.ok(String(apiHarness.fetchCalls[1].url).endsWith('/search')); + const postedPayload = JSON.parse(String(apiHarness.fetchCalls[1].options.body || '{}')); + assert.equal(postedPayload.repo, '/workspace/repo'); + assert.equal(postedPayload.query, 'AuthToken'); + const history = apiHarness.workspaceStateStore.get('pairofcleats.searchHistory'); + assert.equal(history.length, 1); + assert.equal(history[0].invocation.kind, 'api-search'); + assert.equal(history[0].invocation.baseUrl, 'http://127.0.0.1:4311'); + assert.equal(history[0].invocation.timeoutMs, 4321); + assert.equal(history[0].invocation.payload.query, 'AuthToken'); + assert.equal(history[0].repoUri, 'vscode-remote:/workspace/repo'); + + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + result: { + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + } + } + }); + await apiHarness.runCommand('pairofcleats.rerunResultSet', history[0]); + assert.equal(apiHarness.fetchCalls.length, 3, 'rerun should reuse API invocation'); + assert.ok(String(apiHarness.fetchCalls[2].url).endsWith('/search')); + + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + status: { + repo: { + root: '/workspace/repo', + cacheRoot: '/workspace/repo/.cache', + totalBytes: 0, + sqlite: { code: true, prose: false, extractedProse: false, records: false } + }, + health: { issues: [], hints: [] } + } + } + }); + await apiHarness.runCommand('pairofcleats.indexHealth'); + assert.equal(apiHarness.spawnCalls.length, 0, 'API index health should not spawn CLI'); + assert.ok( + apiHarness.fetchCalls.some((call) => String(call.url).includes('/status?repo=')), + 'API index health should issue a status request' + ); + assert.ok(apiHarness.infoMessages.some((message) => /Index Health completed/i.test(message))); + + await apiHarness.extension._test.runExplainSearch(); + assert.ok(apiHarness.errorMessages.some((message) => /API mode is not supported for explain search/i.test(message))); +} finally { + apiHarness.restoreGlobals(); +} + +const preferHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + configValues: { + apiServerUrl: 'http://127.0.0.1:4311', + apiExecutionMode: 'prefer', + cliArgs: ['--trace'] + } +}); + +try { + preferHarness.activate(); + preferHarness.inputQueue.push('fallback query'); + preferHarness.quickPickQueue.push(null); + preferHarness.queuedFetchResults.push({ + status: 500, + json: { + message: 'capabilities offline' + } + }); + preferHarness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + }) + }); + await preferHarness.runCommand('pairofcleats.search'); + assert.equal(preferHarness.fetchCalls.length, 1, 'prefer mode should still probe API first'); + assert.equal(preferHarness.spawnCalls.length, 1, 'prefer mode should fall back to CLI'); + assert.ok(preferHarness.outputEvents.some((event) => event.kind === 'append' && /capabilities offline/i.test(event.line))); +} finally { + preferHarness.restoreGlobals(); +} + +console.log('vscode api runtime test passed'); diff --git a/tests/tooling/vscode/completion-provider.test.js b/tests/tooling/vscode/completion-provider.test.js new file mode 100644 index 000000000..33ff1edd6 --- /dev/null +++ b/tests/tooling/vscode/completion-provider.test.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createVsCodeRuntimeHarness, + prepareVsCodeFixtureWorkspace +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-completion-' +}); +const activeFile = workspace.resolvePath('src', 'app.ts'); +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + activeFile +}); + +harness.activate(); + +assert.equal(harness.completionProviders.length, 1, 'expected completion provider registration'); + +const fullSymbol = 'WidgetBuilder'; +const document = { + uri: { + scheme: 'file', + fsPath: activeFile, + path: activeFile.replace(/\\/g, '/'), + toString() { + return `file:${this.path}`; + } + }, + getWordRangeAtPosition() { + return { + start: new harness.fakeVscode.Position(0, 0), + end: new harness.fakeVscode.Position(0, fullSymbol.length) + }; + }, + getText() { + return fullSymbol; + } +}; + +const shortPrefixItems = await harness.completionProviders[0].provider.provideCompletionItems( + document, + new harness.fakeVscode.Position(0, 1), + {}, + {} +); +assert.deepEqual(shortPrefixItems, [], 'expected short prefix completion lookup to fail quiet'); +assert.equal(harness.spawnCalls.length, 0, 'short prefixes should not spawn CLI completion queries'); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + results: [ + { + name: 'WidgetBuilder', + qualifiedName: 'demo.WidgetBuilder', + kind: 'FunctionDeclaration', + file: 'src/defs.js', + virtualPath: 'src/defs.js', + score: 11 + }, + { + name: 'WidgetRegistry', + qualifiedName: 'demo.WidgetRegistry', + kind: 'ClassDeclaration', + file: 'src/registry.js', + virtualPath: 'src/registry.js', + score: 7 + } + ] + }) +}); + +const completionItems = await harness.completionProviders[0].provider.provideCompletionItems( + document, + new harness.fakeVscode.Position(0, 4), + {}, + {} +); +assert.equal(completionItems.length, 2); +assert.equal(completionItems[0].label, 'WidgetBuilder'); +assert.equal(completionItems[0].detail, 'demo.WidgetBuilder'); +assert.equal(completionItems[0].kind, harness.fakeVscode.CompletionItemKind.Function); +assert.equal(completionItems[1].kind, harness.fakeVscode.CompletionItemKind.Class); + +assert.deepEqual( + harness.spawnCalls[0].args, + [ + workspace.resolvePath('bin', 'pairofcleats.js'), + 'tooling', + 'navigate', + '--json', + '--repo', + workspace.root, + '--kind', + 'completions', + '--top', + '40', + '--file', + activeFile, + '--symbol', + 'Widg' + ] +); + +console.log('vscode completion provider test passed'); diff --git a/tests/tooling/vscode/context-risk-renderers.test.js b/tests/tooling/vscode/context-risk-renderers.test.js new file mode 100644 index 000000000..6420589fc --- /dev/null +++ b/tests/tooling/vscode/context-risk-renderers.test.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + buildRiskExplanationModelFromStandalone, + renderCompositeContextPack, + renderRiskExplanation +} = require('../../../extensions/vscode/analysis-renderers.js'); + +const rendered = renderCompositeContextPack({ + primary: { + ref: { type: 'chunk', chunkUid: 'chunk-risk' }, + file: 'src/app.ts', + excerpt: 'export function risky(input) { return query(input); }', + provenance: { + excerptSource: 'repo-range', + excerptHash: 'sha1:primary', + excerptBytes: 48 + } + }, + risk: { + status: 'ok', + summary: { + totals: { + sources: 1, + sinks: 1, + sanitizers: 0, + localFlows: 0 + } + }, + provenance: { + generatedAt: '2026-03-12T00:00:00.000Z', + ruleBundle: { + version: '1.0.0', + fingerprint: 'sha1:rulebundle-risk-assembly' + }, + effectiveConfigFingerprint: 'sha1:config-risk-assembly', + artifactRefs: { + stats: { + entrypoint: 'risk_interprocedural_stats.json' + }, + flows: { + entrypoint: 'risk_flows.jsonl' + } + } + }, + flows: [] + }, + truncation: [{ cap: 'maxBytes', limit: 128, observed: 256, omitted: 128 }], + warnings: [{ code: 'PACK_WARN', message: 'warning emitted' }] +}); + +assert.match(rendered, /Provenance: source=repo-range, hash=sha1:primary, bytes=48/, 'expected rendered primary provenance'); +assert.match(rendered, /rules 1\.0\.0 sha1:rulebundle-risk-assembly/, 'expected rendered rule bundle provenance'); +assert.match(rendered, /config sha1:config-risk-assembly/, 'expected rendered config fingerprint'); +assert.match(rendered, /artifact refs: stats=risk_interprocedural_stats\.json, flows=risk_flows\.jsonl/, 'expected rendered artifact refs'); +assert.match(rendered, /Truncation\n- maxBytes limit=128 observed=256 omitted=128/, 'expected rendered truncation section'); +assert.match(rendered, /Warnings\n- PACK_WARN: warning emitted/, 'expected rendered warnings section'); + +console.log('vscode context risk renderer test passed'); + + +const standaloneRendered = renderRiskExplanation( + buildRiskExplanationModelFromStandalone({ + chunk: { + chunkUid: 'chunk-risk', + file: 'src/app.ts', + name: 'risky', + kind: 'function' + }, + summary: { + totals: { + sources: 1, + sinks: 1, + sanitizers: 0, + localFlows: 0 + }, + topCategories: [ + { category: 'input', count: 1 } + ] + }, + stats: { + status: 'ok', + flowsEmitted: 1 + }, + provenance: { + generatedAt: '2026-03-12T00:00:00.000Z' + }, + flows: [] + }), + { + title: null, + includeSubject: false, + includeFilters: false + } +); + +assert.match(standaloneRendered, /summary: sources 1, sinks 1, sanitizers 0, localFlows 0/, 'expected shared summary rendering'); +assert.match(standaloneRendered, /interprocedural: status ok, flows 1/, 'expected shared interprocedural rendering'); diff --git a/tests/tooling/vscode/context-risk-runtime.test.js b/tests/tooling/vscode/context-risk-runtime.test.js new file mode 100644 index 000000000..4d2ad35eb --- /dev/null +++ b/tests/tooling/vscode/context-risk-runtime.test.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createVsCodeRuntimeHarness } from '../../helpers/vscode/runtime-harness.js'; + +const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-context-risk-')); +fs.mkdirSync(path.join(repoRoot, 'bin'), { recursive: true }); +fs.mkdirSync(path.join(repoRoot, 'src'), { recursive: true }); +fs.writeFileSync(path.join(repoRoot, 'bin', 'pairofcleats.js'), 'console.log("ok");\n'); +fs.writeFileSync(path.join(repoRoot, 'src', 'app.ts'), 'export function greet() { return 1; }\n'); + +const activeFile = path.join(repoRoot, 'src', 'app.ts'); +const activeEditor = { + document: { + uri: { scheme: 'file', fsPath: activeFile, path: activeFile.replace(/\\/g, '/') }, + getText(selection) { + if (selection?.kind === 'selection') return 'chunk:ck-selection'; + if (selection?.kind === 'word') return 'GreeterSymbol'; + return ''; + }, + getWordRangeAtPosition() { + return { kind: 'word' }; + } + }, + selection: { + kind: 'selection', + active: { line: 0, character: 0 }, + start: { line: 0, character: 0 }, + end: { line: 0, character: 12 } + }, + selections: [ + { + kind: 'selection', + active: { line: 0, character: 0 }, + start: { line: 0, character: 0 }, + end: { line: 0, character: 12 } + } + ] +}; + +const harness = createVsCodeRuntimeHarness({ + repoRoot, + activeFile, + activeEditor, + configValues: { + cliArgs: ['--trace'] + } +}); + +try { + harness.activate(); + + for (const commandId of ['pairofcleats.contextPack', 'pairofcleats.riskExplain']) { + assert.ok(harness.registeredCommands.has(commandId), `missing registered command ${commandId}`); + } + + harness.quickPickQueue.push((items) => items.find((item) => item.label === 'Active selection')); + harness.inputQueue.push('chunk:ck-selection'); + harness.inputQueue.push('2'); + harness.quickPickQueue.push({ label: 'Open Markdown + JSON', value: 'both' }); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + primary: { + ref: { type: 'chunk', chunkUid: 'ck-selection' }, + file: 'src/app.ts', + excerpt: 'export function greet() { return 1; }' + }, + graph: { summary: { counts: { nodes: 3, edges: 2 } } }, + types: { facts: [{ role: 'return', type: 'number' }] }, + risk: { flows: [{ flowId: 'flow-a', confidence: 0.9, path: { nodes: [] } }] }, + indexDir: 'index-code' + }) + }); + + await harness.runCommand('pairofcleats.contextPack'); + + const contextSpawn = harness.spawnCalls[0]; + assert.deepEqual(contextSpawn.args, [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'context-pack', + '--json', + '--repo', + repoRoot, + '--seed', + 'chunk:ck-selection', + '--hops', + '2', + '--includeRisk', + '--includeTypes' + ]); + + const contextExportRoot = path.join(repoRoot, '.pairofcleats', 'vscode', 'exports', 'contextPack'); + const contextExports = fs.readdirSync(contextExportRoot); + assert.ok(contextExports.some((entry) => entry.endsWith('.json')), 'expected context-pack json export'); + assert.ok(contextExports.some((entry) => entry.endsWith('.md')), 'expected context-pack markdown export'); + assert.ok(harness.executeCommandCalls.some((entry) => entry.id === 'markdown.showPreviewToSide'), 'expected markdown preview command'); + assert.ok(harness.openedDocuments.some((entry) => String(entry.uri?.fsPath || '').endsWith('.json')), 'expected json document open'); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Context Pack completed.'); + + const relativeSeed = harness.extension._test.resolveRepoRelativePathSeed(activeFile, { + repoRoot + }); + assert.equal(relativeSeed, 'src/app.ts'); + + harness.openedDocuments.length = 0; + harness.executeCommandCalls.length = 0; + harness.fakeVscode.workspace.openTextDocument = async () => { + throw new Error('json preview open failed'); + }; + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + primary: { + ref: { type: 'file', path: 'src/app.ts' }, + file: 'src/app.ts', + excerpt: 'export function greet() { return 1; }' + }, + graph: { summary: { counts: { nodes: 1, edges: 0 } } }, + indexDir: 'index-code' + }) + }); + const contextPackSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((entry) => entry.id === 'pairofcleats.contextPack'); + await harness.extension._test.executeOperatorWorkflow( + contextPackSpec, + { repoRoot, repoLabel: 'repo' }, + { + command: process.execPath, + args: [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'context-pack', + '--json', + '--repo', + repoRoot, + '--seed', + 'file:src/app.ts', + '--hops', + '1', + '--includeRisk', + '--includeTypes' + ], + inputContext: { seed: 'file:src/app.ts' } + } + ); + + assert.deepEqual(harness.spawnCalls.at(-1)?.args, [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'context-pack', + '--json', + '--repo', + repoRoot, + '--seed', + 'file:src/app.ts', + '--hops', + '1', + '--includeRisk', + '--includeTypes' + ]); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Context Pack completed.'); + assert.ok( + harness.outputEvents.some((entry) => entry.kind === 'append' && /failed to present operator payload/i.test(entry.line)), + 'expected fail-open payload presentation warning' + ); + + harness.quickPickQueue.push((items) => items.find((item) => item.label === 'Active file')); + harness.inputQueue.push('file:src/app.ts'); + harness.inputQueue.push('3'); + harness.quickPickQueue.push({ label: 'Open Markdown', value: 'markdown' }); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + primary: { ref: { type: 'file', path: 'src/app.ts' }, file: 'src/app.ts' }, + risk: { anchor: { chunkUid: 'ck-risk-anchor' } }, + indexDir: 'index-code' + }) + }); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + chunk: { + chunkUid: 'ck-risk-anchor', + file: 'src/app.ts', + name: 'greet', + kind: 'function' + }, + filters: { sourceRule: null, sinkRule: null }, + flows: [{ flowId: 'flow-risk', confidence: 0.8, path: { nodes: [] } }] + }) + }); + + await harness.runCommand('pairofcleats.riskExplain'); + + assert.deepEqual(harness.spawnCalls.at(-2)?.args, [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'context-pack', + '--json', + '--repo', + repoRoot, + '--seed', + 'file:src/app.ts', + '--hops', + '0', + '--includeRisk' + ]); + assert.deepEqual(harness.spawnCalls.at(-1)?.args, [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'risk', + 'explain', + '--json', + '--index', + path.join(repoRoot, 'index-code'), + '--chunk', + 'ck-risk-anchor', + '--max', + '3' + ]); + + const riskExportRoot = path.join(repoRoot, '.pairofcleats', 'vscode', 'exports', 'riskExplain'); + const riskExports = fs.readdirSync(riskExportRoot); + assert.ok(riskExports.some((entry) => entry.endsWith('.json')), 'expected risk-explain json export'); + assert.ok(riskExports.some((entry) => entry.endsWith('.md')), 'expected risk-explain markdown export'); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Risk Explain completed.'); + assert.equal(harness.errorMessages.length, 0, `unexpected errors: ${harness.errorMessages.join('; ')}`); + assert.ok(harness.outputEvents.some((entry) => entry.kind === 'append' && /exported markdown:/i.test(entry.line))); + assert.ok(harness.outputEvents.some((entry) => entry.kind === 'append' && /exported json:/i.test(entry.line))); + + console.log('vscode context/risk runtime test passed'); +} finally { + harness.restoreGlobals(); +} diff --git a/tests/tooling/vscode/extension-module-boundary.test.js b/tests/tooling/vscode/extension-module-boundary.test.js new file mode 100644 index 000000000..b12d9b314 --- /dev/null +++ b/tests/tooling/vscode/extension-module-boundary.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const extensionRoot = path.join(root, 'extensions', 'vscode'); +const sourceFiles = fs.readdirSync(extensionRoot) + .filter((name) => /\.(?:js|cjs)$/u.test(name)) + .map((name) => path.join(extensionRoot, name)); + +const importPattern = /require\(\s*['"]([^'"]+)['"]\s*\)|from\s+['"]([^'"]+)['"]/gu; + +for (const filePath of sourceFiles) { + const source = fs.readFileSync(filePath, 'utf8'); + let match = null; + while ((match = importPattern.exec(source)) !== null) { + const specifier = match[1] || match[2]; + if (!specifier || !specifier.startsWith('.')) continue; + const resolved = path.resolve(path.dirname(filePath), specifier); + const relative = path.relative(extensionRoot, resolved); + const escapesRoot = relative === '' ? false : relative.startsWith('..'); + assert.equal( + escapesRoot, + false, + `expected extension module import to stay within package root (${path.basename(filePath)} -> ${specifier})` + ); + } +} + +console.log('vscode extension module boundary test passed'); diff --git a/tests/tooling/vscode/extension-packaging.test.js b/tests/tooling/vscode/extension-packaging.test.js deleted file mode 100644 index 2f5671aaa..000000000 --- a/tests/tooling/vscode/extension-packaging.test.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { resolveTestCachePath } from '../../helpers/test-cache.js'; - -const root = process.cwd(); -const outDir = resolveTestCachePath(root, 'package-vscode-structure'); - -const run = spawnSync( - process.execPath, - [path.join(root, 'tools', 'package-vscode.js'), '--out-dir', outDir, '--smoke'], - { cwd: root, encoding: 'utf8' } -); - -if (run.status !== 0) { - console.error('extension-packaging test failed: package-vscode command failed'); - if (run.stderr) console.error(run.stderr.trim()); - process.exit(run.status ?? 1); -} - -const archivePath = path.join(outDir, 'pairofcleats.vsix'); -const manifestPath = `${archivePath}.manifest.json`; -if (!fs.existsSync(archivePath) || !fs.existsSync(manifestPath)) { - console.error('extension-packaging test failed: archive outputs missing'); - process.exit(1); -} - -const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); -const paths = Array.isArray(manifest.entries) ? manifest.entries.map((entry) => entry.path) : []; -if (!paths.includes('extension/package.json') || !paths.includes('extension/extension.js')) { - console.error('extension-packaging test failed: required extension entries missing'); - process.exit(1); -} - -console.log('vscode extension packaging test passed'); diff --git a/tests/tooling/vscode/extension-runtime.test.js b/tests/tooling/vscode/extension-runtime.test.js new file mode 100644 index 000000000..279945c5e --- /dev/null +++ b/tests/tooling/vscode/extension-runtime.test.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + prepareVsCodeFixtureWorkspace, + createVsCodeRuntimeHarness +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-ext-' +}); +const otherWorkspace = await prepareVsCodeFixtureWorkspace('vscode/secondary-repo', { + prefix: 'poc-vscode-other-' +}); + +const nestedRepoRoot = workspace.resolvePath('packages', 'nested'); +const nestedSourceFile = workspace.resolvePath('packages', 'nested', 'src', 'service.ts'); +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + configValues: { + cliArgs: ['--trace'] + } +}); + +const { extension } = harness; +harness.activate(); + +assert.ok(harness.registeredCommands.has('pairofcleats.selectRepo')); +assert.ok(harness.registeredCommands.has('pairofcleats.clearSelectedRepo')); + +const cliResolution = extension._test.resolveCli( + workspace.root, + harness.fakeVscode.workspace.getConfiguration() +); +assert.equal(cliResolution.ok, true); +assert.equal(cliResolution.command, process.execPath); +assert.deepEqual( + cliResolution.argsPrefix, + [workspace.resolvePath('bin', 'pairofcleats.js'), '--trace'] +); + +const repoContextSingle = await extension._test.resolveRepoContext(); +assert.equal(repoContextSingle.ok, true); +assert.equal(repoContextSingle.repoRoot, workspace.root); +assert.equal(repoContextSingle.source, 'single-workspace'); + +harness.setActiveFile(nestedSourceFile); +const repoContextNested = await extension._test.resolveRepoContext(); +assert.equal(repoContextNested.ok, true); +assert.equal(repoContextNested.repoRoot, nestedRepoRoot); +assert.equal(repoContextNested.source, 'active-editor'); + +harness.setWorkspaceFolders([ + { name: 'alpha', path: workspace.root }, + { name: 'beta', path: otherWorkspace.root } +]); +harness.setActiveFile(otherWorkspace.resolvePath('src', 'worker.ts')); +const repoContextActive = await extension._test.resolveRepoContext(); +assert.equal(repoContextActive.ok, true); +assert.equal(repoContextActive.repoRoot, otherWorkspace.root); +assert.equal(repoContextActive.source, 'active-editor'); + +harness.setActiveEditor(null); +harness.quickPickQueue.push((items) => items[1]); +const repoContextPicked = await extension._test.resolveRepoContext(); +assert.equal(repoContextPicked.ok, true); +assert.equal(repoContextPicked.repoRoot, otherWorkspace.root); +assert.equal(repoContextPicked.source, 'repo-picker'); + +harness.quickPickQueue.push((items) => items[1]); +await harness.runCommand('pairofcleats.selectRepo'); +assert.match(harness.infoMessages.pop(), /will use secondary-repo/i); +harness.setActiveFile(workspace.resolvePath('src', 'app.ts')); +const selectedRepoContext = await extension._test.resolveRepoContext(); +assert.equal(selectedRepoContext.ok, true); +assert.equal(selectedRepoContext.repoRoot, otherWorkspace.root); +assert.equal(selectedRepoContext.source, 'selected-repo'); +assert.ok(String(selectedRepoContext.workspaceUriString || '').includes(otherWorkspace.root.replace(/\\/g, '/'))); +assert.ok(harness.statusBarItems[0].text.includes('secondary-repo')); +assert.ok(harness.statusBarItems[0].text.includes('selected')); + +await harness.runCommand('pairofcleats.clearSelectedRepo'); +assert.match(harness.infoMessages.pop(), /cleared the explicit repository selection/i); +const clearedRepoContext = await extension._test.resolveRepoContext(); +assert.equal(clearedRepoContext.ok, true); +assert.equal(clearedRepoContext.repoRoot, workspace.root); +assert.equal(clearedRepoContext.source, 'active-editor'); + +const persistedHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [ + { name: 'alpha', path: workspace.root }, + { name: 'beta', path: otherWorkspace.root } + ], + activeEditor: null, + workspaceState: { + 'pairofcleats.selectedRepo': { + repoRoot: otherWorkspace.root, + workspacePath: otherWorkspace.root + } + } +}); +persistedHarness.activate(); +const persistedSelectedContext = await persistedHarness.extension._test.resolveRepoContext(); +assert.equal(persistedSelectedContext.ok, true); +assert.equal(persistedSelectedContext.repoRoot, otherWorkspace.root); +assert.equal(persistedSelectedContext.source, 'selected-repo'); + +persistedHarness.setWorkspaceFolders([ + { name: 'local', path: workspace.root }, + { + name: 'remote', + uri: { scheme: 'vscode-remote', fsPath: '/workspace/remote', path: '/workspace/remote' } + } +]); +persistedHarness.setActiveEditor({ + document: { + uri: { scheme: 'vscode-remote', fsPath: '/workspace/remote/src/app.ts', path: '/workspace/remote/src/app.ts' } + } +}); +const remotePreferredContext = await persistedHarness.extension._test.resolveRepoContext({ allowRemote: true }); +assert.equal(remotePreferredContext.ok, true); +assert.equal(remotePreferredContext.source, 'active-editor'); +assert.equal(remotePreferredContext.workspaceUri.scheme, 'vscode-remote'); + +const remoteFolder = { + name: 'remote', + uri: { scheme: 'vscode-remote', fsPath: '/workspace/repo', path: '/workspace/repo' } +}; +harness.setWorkspaceFolders([remoteFolder]); +const remoteContext = await extension._test.resolveRepoContext(); +assert.equal(remoteContext.ok, false); +assert.equal(remoteContext.kind, 'unsupported-workspace'); +assert.match(remoteContext.message, /local file workspaces/i); +assert.match(remoteContext.detail, /vscode-remote/i); + +harness.setWorkspaceFolders([ + { name: 'remote', uri: { scheme: 'vscode-remote', fsPath: '/workspace/remote', path: '/workspace/remote' } }, + { name: 'local', path: workspace.root } +]); +harness.setActiveEditor({ document: { uri: { scheme: 'vscode-remote', fsPath: '/workspace/remote/src/app.ts', path: '/workspace/remote/src/app.ts' } } }); +const mixedPassive = extension._test.resolvePassiveRepoContext(); +assert.equal(mixedPassive.ok, true); +assert.equal(mixedPassive.repoRoot, workspace.root); +assert.equal(mixedPassive.source, 'single-workspace'); + +harness.setActiveFile(workspace.resolvePath('src', 'app.ts')); +const mixedActive = await extension._test.resolveRepoContext(); +assert.equal(mixedActive.ok, true); +assert.equal(mixedActive.repoRoot, workspace.root); +assert.equal(mixedActive.source, 'active-editor'); + +const mixedPathHint = await extension._test.resolveRepoContext({ + pathHint: { scheme: 'file', fsPath: workspace.resolvePath('src', 'app.ts'), path: workspace.resolvePath('src', 'app.ts').replace(/\\/g, '/') } +}); +assert.equal(mixedPathHint.ok, true); +assert.equal(mixedPathHint.repoRoot, workspace.root); +assert.equal(mixedPathHint.source, 'path-hint'); + +const invalidCliDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-cli-dir-')); +harness.fakeVscode.workspace.workspaceFolders = [ + { name: 'local', uri: { scheme: 'file', fsPath: workspace.root, path: workspace.root.replace(/\\/g, '/') } } +]; +harness.setActiveEditor(null); +harness.inputQueue.push('symbol'); +harness.fakeVscode.workspace.getConfiguration = () => ({ + get(key) { + const values = { + cliPath: invalidCliDir, + cliArgs: ['--trace'], + searchMode: 'code', + searchBackend: '', + searchAnn: true, + maxResults: 25, + searchContextLines: 0, + searchFile: '', + searchPath: '', + searchLang: '', + searchExt: '', + searchType: '', + searchCaseSensitive: false, + extraSearchArgs: [], + env: {} + }; + return values[key]; + } +}); +await extension._test.runSearch(); +assert.equal(harness.errorMessages.length, 1); +assert.match(harness.errorMessages[0], /not a file/i); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /resolved to/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'show')); +assert.equal(harness.infoMessages.length, 0); + +console.log('vscode extension runtime test passed'); diff --git a/tests/tooling/vscode/inline-signals.test.js b/tests/tooling/vscode/inline-signals.test.js new file mode 100644 index 000000000..800b13184 --- /dev/null +++ b/tests/tooling/vscode/inline-signals.test.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createVsCodeRuntimeHarness, + prepareVsCodeFixtureWorkspace +} from '../../helpers/vscode/runtime-harness.js'; + +const flush = async () => { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +}; + +const createPackWarning = (code, message) => ({ code, message }); + +const queueInlineSignalsContextPackResult = (harness, { + warnings = [createPackWarning('PACK_WARN', 'warning emitted')] +} = {}) => { + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + risk: { + status: 'ok', + analysisStatus: { code: 'ok' }, + flows: [ + { flowId: 'flow-a', confidence: 0.91 }, + { flowId: 'flow-b', confidence: 0.73 }, + { flowId: 'flow-c', confidence: 0.61 } + ] + }, + types: { + facts: [ + { role: 'return', type: 'number' }, + { role: 'param', type: 'string' }, + { role: 'param', type: 'boolean' } + ] + }, + warnings, + truncation: [ + { cap: 'maxFlows', limit: 2, observed: 3 } + ] + }) + }); +}; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-inline-signals-' +}); +const activeFile = workspace.resolvePath('src', 'app.ts'); +const symbol = 'WidgetBuilder'; +const document = { + uri: { + scheme: 'file', + fsPath: activeFile, + path: activeFile.replace(/\\/g, '/'), + toString() { + return `file:${this.path}`; + } + }, + getWordRangeAtPosition() { + return { + start: { line: 0, character: 0 }, + end: { line: 0, character: symbol.length } + }; + }, + getText() { + return symbol; + } +}; +const activeEditor = { + document +}; + +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + activeFile, + activeEditor, + configValues: { + cliArgs: ['--trace'], + inlineHoverEnabled: true, + inlineDiagnosticsEnabled: true, + inlineDecorationsEnabled: true, + inlineMaxItems: 2 + } +}); + +try { + queueInlineSignalsContextPackResult(harness, { + warnings: [ + createPackWarning('PACK_WARN', 'warning emitted'), + createPackWarning('PACK_WARN_2', 'second warning emitted') + ] + }); + + harness.activate(); + await flush(); + + assert.equal(harness.hoverProviders.length, 1, 'expected hover provider registration'); + assert.equal(harness.diagnosticCollections.length, 1, 'expected inline diagnostic collection'); + const diagnostics = harness.diagnosticCollections[0].entries.get(document.uri.toString()); + assert.equal(Array.isArray(diagnostics), true, 'expected diagnostics for active file'); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].severity, harness.fakeVscode.DiagnosticSeverity.Warning); + assert.match(diagnostics[0].message, /3 risk flows/i); + assert.equal(harness.decorationTypes.length, 1, 'expected inline decoration type'); + assert.equal(harness.decorationApplications.length > 0, true, 'expected decoration application'); + assert.match(harness.decorationTypes[0].options.after.contentText, /PairOfCleats: 3 risk flows/i); + + queueInlineSignalsContextPackResult(harness); + + const hover = await harness.hoverProviders[0].provider.provideHover( + document, + new harness.fakeVscode.Position(0, 4), + {} + ); + assert.ok(hover, 'expected bounded inline hover'); + assert.match(hover.contents.value, /seed: `symbol:WidgetBuilder`/i); + assert.match(hover.contents.value, /\*\*Risk flows\*\*/); + assert.match(hover.contents.value, /flow-a/); + assert.match(hover.contents.value, /flow-b/); + assert.doesNotMatch(hover.contents.value, /flow-c/, 'expected hover maxItems bound to omit extra risk flows'); + assert.match(hover.contents.value, /\*\*Type facts\*\*/); + assert.match(hover.contents.value, /return: `number`/); + assert.match(hover.contents.value, /param: `string`/); + assert.doesNotMatch(hover.contents.value, /boolean/, 'expected hover maxItems bound to omit extra type facts'); + + const hoverArgs = harness.spawnCalls.at(-1)?.args || []; + assert.deepEqual(hoverArgs, [ + workspace.resolvePath('bin', 'pairofcleats.js'), + '--trace', + 'context-pack', + '--json', + '--repo', + workspace.root, + '--seed', + 'symbol:WidgetBuilder', + '--hops', + '0', + '--includeRisk', + '--includeTypes' + ]); + + const disabledHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + activeFile, + activeEditor, + configValues: { + inlineHoverEnabled: false, + inlineDiagnosticsEnabled: false, + inlineDecorationsEnabled: false + } + }); + try { + disabledHarness.activate(); + await flush(); + const disabledHover = await disabledHarness.extension._test.provideInlineHoverAtPosition( + document, + new disabledHarness.fakeVscode.Position(0, 4) + ); + assert.equal(disabledHover, null, 'expected disabled inline hover to fail quiet'); + assert.equal(disabledHarness.spawnCalls.length, 0, 'disabled inline settings must not spawn CLI work'); + } finally { + disabledHarness.restoreGlobals(); + } + + console.log('vscode inline signals test passed'); +} finally { + harness.restoreGlobals(); +} diff --git a/tests/tooling/vscode/integration-harness.test.js b/tests/tooling/vscode/integration-harness.test.js new file mode 100644 index 000000000..69b1fde24 --- /dev/null +++ b/tests/tooling/vscode/integration-harness.test.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +import { + prepareVsCodeFixtureWorkspace, + createVsCodeRuntimeHarness +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-integration-' +}); +const nestedSourceFile = workspace.resolvePath('packages', 'nested', 'src', 'service.ts'); +fs.mkdirSync(workspace.resolvePath('packages', 'nested', 'tools', 'config'), { recursive: true }); +fs.mkdirSync(workspace.resolvePath('packages', 'nested', 'tools', 'index'), { recursive: true }); +fs.writeFileSync(workspace.resolvePath('packages', 'nested', 'tools', 'config', 'dump.js'), 'console.log("ok");\n'); +fs.writeFileSync(workspace.resolvePath('packages', 'nested', 'tools', 'index', 'report-artifacts.js'), 'console.log("ok");\n'); +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'root', path: workspace.root }], + activeFile: nestedSourceFile, + configValues: { + cliArgs: ['--trace'], + searchMode: 'code', + searchBackend: 'sqlite' + } +}); + +harness.activate(); + +for (const commandId of [ + 'pairofcleats.search', + 'pairofcleats.setup', + 'pairofcleats.bootstrap', + 'pairofcleats.doctor', + 'pairofcleats.configDump', + 'pairofcleats.indexHealth', + 'pairofcleats.codeMap', + 'pairofcleats.showSearchHistory' +]) { + assert.ok(harness.registeredCommands.has(commandId), `missing registered command ${commandId}`); +} + +harness.inputQueue.push('nested symbol'); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/service.ts', score: 1, startLine: 1 }] + }) +}); +await harness.runCommand('pairofcleats.search'); + +assert.equal(harness.errorMessages.length, 0, `unexpected search errors: ${harness.errorMessages.join('; ')}`); +assert.equal(harness.spawnCalls.length, 1); +assert.equal(harness.spawnCalls[0].command, process.execPath); +const searchArgs = harness.spawnCalls[0].args; +assert.deepEqual(searchArgs, [ + workspace.resolvePath('packages', 'nested', 'bin', 'pairofcleats.js'), + '--trace', + 'search', + '--json', + '--top', + '25', + '--mode', + 'code', + '--backend', + 'sqlite', + '--repo', + workspace.resolvePath('packages', 'nested'), + '--', + 'nested symbol' +]); + +const searchHistory = harness.workspaceStateStore.get('pairofcleats.searchHistory'); +assert.equal(searchHistory.length, 1); +assert.equal(searchHistory[0].repoRoot, workspace.resolvePath('packages', 'nested')); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + format: 'html-iso', + outPath: workspace.resolvePath('.pairofcleats', 'maps', 'vscode-map.iso.html'), + summary: { counts: { files: 1, members: 2, edges: 1 } }, + warnings: [] + }) +}); +await harness.runCommand('pairofcleats.codeMap'); + +assert.equal(harness.errorMessages.length, 0, `unexpected workflow errors: ${harness.errorMessages.join('; ')}`); +assert.equal(harness.spawnCalls.length, 2); +assert.deepEqual( + harness.spawnCalls[1].args, + [ + workspace.resolvePath('packages', 'nested', 'bin', 'pairofcleats.js'), + '--trace', + 'report', + 'map', + '--json', + '--repo', + workspace.resolvePath('packages', 'nested'), + '--format', + 'html-iso', + '--out', + workspace.resolvePath('packages', 'nested', '.pairofcleats', 'maps', 'vscode-map.iso.html') + ] +); +assert.equal(harness.openExternalCalls.length, 1); +assert.ok(harness.infoMessages.some((message) => /Code Map completed/i.test(message))); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + root: workspace.resolvePath('packages', 'nested'), + incremental: true, + restoredArtifacts: false, + steps: { + tooling: { ok: true, installed: true }, + cache: { ok: true, restored: false } + }, + errors: [] + }) +}); +await harness.runCommand('pairofcleats.setup'); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + root: workspace.resolvePath('packages', 'nested'), + incremental: true, + restoredArtifacts: true, + steps: { + tooling: { ok: true, installed: true }, + artifacts: { ok: true, restored: true } + }, + errors: [] + }) +}); +await harness.runCommand('pairofcleats.bootstrap'); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot: workspace.resolvePath('packages', 'nested'), + summary: { status: 'ok' }, + identity: { chunkUid: { available: true } }, + xxhash: { backend: 'native' }, + providers: [{ id: 'gopls', status: 'ok', enabled: true }], + scm: { provider: 'git', annotateEnabled: true } + }) +}); +await harness.runCommand('pairofcleats.doctor'); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot: workspace.resolvePath('packages', 'nested'), + policy: { + quality: { value: 'max', source: 'config' } + }, + derived: { + mcp: { + mode: 'auto', + modeSource: 'default', + sdkAvailable: true + } + } + }) +}); +await harness.runCommand('pairofcleats.configDump'); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repo: { + root: workspace.resolvePath('packages', 'nested'), + cacheRoot: workspace.resolvePath('packages', 'nested', '.cache'), + totalBytes: 2048, + sqlite: { + code: true, + prose: false, + extractedProse: false, + records: false + } + }, + corruption: { ok: true }, + health: { issues: [], hints: ['run bootstrap if sqlite pieces are missing'] } + }) +}); +await harness.runCommand('pairofcleats.indexHealth'); + +assert.ok(harness.infoMessages.some((message) => /Setup completed/i.test(message))); +assert.ok(harness.infoMessages.some((message) => /Bootstrap completed/i.test(message))); +assert.ok(harness.infoMessages.some((message) => /Tooling Doctor completed/i.test(message))); +assert.ok(harness.infoMessages.some((message) => /Config Dump completed/i.test(message))); +assert.ok(harness.infoMessages.some((message) => /Index Health completed/i.test(message))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /next: PairOfCleats: Tooling Doctor/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /next: PairOfCleats: Index Health/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /mcp mode: auto/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /sqlite code: present/i.test(event.line))); + +console.log('vscode integration harness test passed'); diff --git a/tests/tooling/vscode/navigation-providers.test.js b/tests/tooling/vscode/navigation-providers.test.js new file mode 100644 index 000000000..b9c7913de --- /dev/null +++ b/tests/tooling/vscode/navigation-providers.test.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createVsCodeRuntimeHarness, + prepareVsCodeFixtureWorkspace +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-navigation-' +}); +const activeFile = workspace.resolvePath('src', 'app.ts'); +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + activeFile, + configValues: { + cliArgs: ['--trace'] + } +}); + +harness.activate(); + +assert.equal(harness.definitionProviders.length, 1, 'expected definition provider registration'); +assert.equal(harness.referenceProviders.length, 1, 'expected reference provider registration'); +assert.equal(harness.documentSymbolProviders.length, 1, 'expected document symbol provider registration'); + +const symbol = 'WidgetBuilder'; +const start = new harness.fakeVscode.Position(0, 0); +const end = new harness.fakeVscode.Position(0, symbol.length); +const document = { + uri: { + scheme: 'file', + fsPath: activeFile, + path: activeFile.replace(/\\/g, '/'), + toString() { + return `file:${this.path}`; + } + }, + getWordRangeAtPosition() { + return { start, end }; + }, + getText() { + return symbol; + } +}; + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + results: [ + { + file: 'src/defs.js', + virtualPath: 'src/defs.js', + startLine: 12, + endLine: 12, + startCol: 3, + endCol: 16, + kind: 'FunctionDeclaration', + name: 'WidgetBuilder' + } + ] + }) +}); + +const definitions = await harness.definitionProviders[0].provider.provideDefinition(document, start, {}); +assert.equal(definitions.length, 1); +assert.equal(definitions[0].uri.fsPath, harness.resolvePath('src', 'defs.js')); +assert.equal(definitions[0].range.start.line, 11); +assert.equal(definitions[0].range.start.character, 2); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + results: [ + { + file: 'src/app.ts', + virtualPath: 'src/app.ts', + startLine: 4, + endLine: 4, + startCol: 1, + endCol: 14, + kind: 'FunctionDeclaration', + name: 'WidgetBuilder' + }, + { + file: 'src/consumer.ts', + virtualPath: 'src/consumer.ts', + startLine: 9, + endLine: 9, + startCol: 5, + endCol: 18, + kind: 'FunctionDeclaration', + name: 'WidgetBuilder' + } + ] + }) +}); + +const references = await harness.referenceProviders[0].provider.provideReferences(document, start, {}, {}); +assert.equal(references.length, 2); +assert.equal(references[1].uri.fsPath, harness.resolvePath('src', 'consumer.ts')); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + results: [ + { + file: 'src/app.ts', + virtualPath: 'src/app.ts', + startLine: 2, + endLine: 6, + startCol: 1, + endCol: 1, + kind: 'FunctionDeclaration', + name: 'WidgetBuilder', + qualifiedName: 'demo.WidgetBuilder' + } + ] + }) +}); + +const documentSymbols = await harness.documentSymbolProviders[0].provider.provideDocumentSymbols(document, {}); +assert.equal(documentSymbols.length, 1); +assert.equal(documentSymbols[0].name, 'WidgetBuilder'); +assert.equal(documentSymbols[0].range.start.line, 1); + +const definitionArgs = harness.spawnCalls[0].args; +assert.deepEqual( + definitionArgs, + [ + workspace.resolvePath('bin', 'pairofcleats.js'), + '--trace', + 'tooling', + 'navigate', + '--json', + '--repo', + workspace.root, + '--kind', + 'definitions', + '--top', + '25', + '--file', + activeFile, + '--symbol', + 'WidgetBuilder' + ] +); + +assert.equal(harness.spawnCalls[1].args[4], '--json'); +assert.equal(harness.spawnCalls[1].args[8], 'references'); +assert.equal(harness.spawnCalls[2].args[8], 'document-symbols'); + +console.log('vscode navigation providers test passed'); diff --git a/tests/tooling/vscode/operations-runtime.test.js b/tests/tooling/vscode/operations-runtime.test.js new file mode 100644 index 000000000..a629a65c6 --- /dev/null +++ b/tests/tooling/vscode/operations-runtime.test.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { + createVsCodeRuntimeTempRepo, + createVsCodeRuntimeHarness +} from '../../helpers/vscode/runtime-harness.js'; +import { + assertNoRuntimeErrors, + assertRegisteredCommands, + assertSpawnArgs +} from '../../helpers/vscode/runtime-assertions.js'; + +const repoRoot = createVsCodeRuntimeTempRepo({ + prefix: 'poc-vscode-ops-', + toolScripts: ['tools/index/validate.js'] +}); +const harness = createVsCodeRuntimeHarness({ + repoRoot, + activeFile: path.join(repoRoot, 'src', 'app.ts'), + configValues: { + cliArgs: ['--trace'] + } +}); + +harness.activate(); + +try { + assertRegisteredCommands(harness.registeredCommands, [ + 'pairofcleats.indexBuild', + 'pairofcleats.indexWatchStart', + 'pairofcleats.indexWatchStop', + 'pairofcleats.indexValidate', + 'pairofcleats.serviceApiStart', + 'pairofcleats.serviceApiStop', + 'pairofcleats.serviceIndexerStart', + 'pairofcleats.serviceIndexerStop' + ]); + + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + root: repoRoot, + strict: true, + modes: { + code: { ok: true, path: path.join(repoRoot, 'index-code'), missing: [], warnings: [] } + }, + sqlite: { enabled: false, ok: true, mode: 'code', issues: [] }, + lmdb: { enabled: false, ok: true, issues: [], warnings: [] }, + warnings: [], + issues: [], + hints: [] + }) + }); + await harness.runCommand('pairofcleats.indexValidate'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Index Validate completed.'); + assertSpawnArgs( + harness.spawnCalls, + 0, + [ + path.join(repoRoot, 'tools', 'index', 'validate.js'), + '--json', + '--repo', + repoRoot + ] + ); + + harness.queuedResults.push({ + code: 0, + stdout: '[build] done\n' + }); + await harness.runCommand('pairofcleats.indexBuild'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Index Build completed.'); + assertSpawnArgs( + harness.spawnCalls, + 1, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'index', + 'build', + '--repo', + repoRoot, + '--progress', + 'log' + ] + ); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /\[stdout\] \[build\] done/i.test(event.line))); + + harness.queuedResults.push({ persistent: true, stdout: '[watch] started\n', killCode: 0 }); + await harness.runCommand('pairofcleats.indexWatchStart'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Index Watch started. Use PairOfCleats: Stop Index Watch to stop it.'); + assertSpawnArgs( + harness.spawnCalls, + 2, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'index', + 'watch', + '--repo', + repoRoot, + '--progress', + 'log' + ] + ); + await harness.runCommand('pairofcleats.indexWatchStop'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Index Watch stopped.'); + + harness.queuedResults.push({ persistent: true, stdout: '[api] listening\n', killCode: 0 }); + await harness.runCommand('pairofcleats.serviceApiStart'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Service API started. Use PairOfCleats: Stop Service API to stop it.'); + await harness.runCommand('pairofcleats.serviceApiStop'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Service API stopped.'); + + harness.queuedResults.push({ persistent: true, stdout: '[indexer] watching\n', killCode: 0 }); + await harness.runCommand('pairofcleats.serviceIndexerStart'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Service Indexer started. Use PairOfCleats: Stop Service Indexer to stop it.'); + await harness.runCommand('pairofcleats.serviceIndexerStop'); + assert.equal(harness.infoMessages.shift(), 'PairOfCleats: Service Indexer stopped.'); + + harness.queuedResults.push({ throw: new Error('managed spawn exploded') }); + await harness.runCommand('pairofcleats.indexWatchStart'); + assert.match(harness.errorMessages.shift(), /Index Watch failed to start/i); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /managed spawn exploded/i.test(event.line))); + + assertNoRuntimeErrors(harness.errorMessages); + assert.equal(harness.killCalls.length, 3, 'expected stop commands to terminate all three persistent children'); +} finally { + harness.restoreGlobals(); +} + +console.log('vscode operations runtime test passed'); diff --git a/tests/tooling/vscode/operator-runtime.test.js b/tests/tooling/vscode/operator-runtime.test.js new file mode 100644 index 000000000..70a6ebf88 --- /dev/null +++ b/tests/tooling/vscode/operator-runtime.test.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createVsCodeRuntimeHarness } from '../../helpers/vscode/runtime-harness.js'; +import { assertRegisteredCommands } from './runtime-test-helpers.js'; + +const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-operator-')); +fs.mkdirSync(path.join(repoRoot, 'bin'), { recursive: true }); +fs.mkdirSync(path.join(repoRoot, 'tools', 'config'), { recursive: true }); +fs.mkdirSync(path.join(repoRoot, 'tools', 'index'), { recursive: true }); +fs.writeFileSync(path.join(repoRoot, 'bin', 'pairofcleats.js'), 'console.log("ok");'); +fs.writeFileSync(path.join(repoRoot, 'tools', 'config', 'dump.js'), 'console.log("ok");'); +fs.writeFileSync(path.join(repoRoot, 'tools', 'index', 'report-artifacts.js'), 'console.log("ok");'); + +const harness = createVsCodeRuntimeHarness({ + repoRoot, + configValues: { + cliArgs: ['--trace'], + searchMode: 'code' + } +}); + +harness.activate(); +assertRegisteredCommands(harness.registeredCommands, [ + 'pairofcleats.search', + 'pairofcleats.setup', + 'pairofcleats.bootstrap', + 'pairofcleats.doctor', + 'pairofcleats.configDump', + 'pairofcleats.indexHealth' +]); + +const configDumpSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.configDump'); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot, + policy: { + quality: { value: 'max', source: 'config' } + }, + derived: { + cacheRoot: path.join(repoRoot, '.cache'), + repoCacheRoot: path.join(repoRoot, '.cache', 'repo'), + mcp: { + mode: 'auto', + modeSource: 'default', + sdkAvailable: true + } + } + }) +}); +await harness.extension._test.runOperatorCommand(configDumpSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Config Dump completed.'); +assert.deepEqual( + harness.spawnCalls[0].args, + [ + path.join(repoRoot, 'tools', 'config', 'dump.js'), + '--json', + '--repo', + repoRoot + ] +); +assert.equal(harness.spawnCalls[0].command, process.execPath); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /quality: max \(config\)/i.test(event.line))); + +const doctorSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.doctor'); +harness.queuedResults.push({ + code: 1, + stdout: JSON.stringify({ + repoRoot, + summary: { status: 'error' }, + identity: { chunkUid: { available: false } }, + xxhash: { backend: 'js' }, + providers: [{ id: 'gopls', status: 'error', enabled: true }], + scm: { provider: 'git', annotateEnabled: false } + }) +}); +await harness.extension._test.runOperatorCommand(doctorSpec); +assert.equal( + harness.errorMessages.pop(), + 'PairOfCleats: Tooling Doctor reported issues. See PairOfCleats output for details.' +); +assert.deepEqual( + harness.spawnCalls[1].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'tooling', + 'doctor', + '--json', + '--repo', + repoRoot + ] +); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /providers: 1 total, 0 warn, 1 error/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'show')); + +harness.queuedResults.push({ + throw: new Error('sync spawn failure') +}); +await harness.extension._test.runOperatorCommand(configDumpSpec); +assert.match(harness.errorMessages.pop(), /Config Dump failed to start/i); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /sync spawn failure/i.test(event.line))); + +harness.restoreGlobals(); + +console.log('vscode operator runtime test passed'); diff --git a/tests/tooling/vscode/package-archive-metadata.test.js b/tests/tooling/vscode/package-archive-metadata.test.js index cfe0bd5db..5f8347b6d 100644 --- a/tests/tooling/vscode/package-archive-metadata.test.js +++ b/tests/tooling/vscode/package-archive-metadata.test.js @@ -1,17 +1,19 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; const root = process.cwd(); const outDir = resolveTestCachePath(root, 'package-vscode-metadata'); -const run = spawnSync( - process.execPath, +const run = runNode( [path.join(root, 'tools', 'package-vscode.js'), '--out-dir', outDir], - { cwd: root, encoding: 'utf8' } + 'package-vscode archive metadata', + root, + process.env, + { stdio: 'pipe', allowFailure: true } ); if (run.status !== 0) { console.error('package-archive-metadata test failed: package-vscode command failed'); diff --git a/tests/tooling/vscode/package-contract-matrix.test.js b/tests/tooling/vscode/package-contract-matrix.test.js new file mode 100644 index 000000000..848952e16 --- /dev/null +++ b/tests/tooling/vscode/package-contract-matrix.test.js @@ -0,0 +1,299 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +import { getEditorCommandSpecs } from '../../../src/shared/runtime-capability-manifest.js'; +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const extensionDir = path.join(root, 'extensions', 'vscode'); +const manifestPath = path.join(extensionDir, 'package.json'); +const entryPath = path.join(extensionDir, 'extension.js'); +const packagingScriptPath = path.join(root, 'tools', 'package-vscode.js'); +const determinismSpecPath = path.join(root, 'docs', 'specs', 'editor-packaging-determinism.md'); +const contractPath = path.join(root, 'src', 'contracts', 'editor-config-contract.json'); +const packagedContractPath = path.join(extensionDir, 'editor-config-contract.json'); +const guidePath = path.join(root, 'docs', 'guides', 'editor-integration.md'); +const sublimeConfigPath = path.join(root, 'sublime', 'PairOfCleats', 'lib', 'config.py'); + +for (const target of [manifestPath, entryPath, packagingScriptPath, determinismSpecPath, contractPath, packagedContractPath, guidePath, sublimeConfigPath]) { + if (!fs.existsSync(target)) { + console.error(`VS Code extension test missing required file: ${target}`); + process.exit(1); + } +} + +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); +const packagedContract = JSON.parse(fs.readFileSync(packagedContractPath, 'utf8')); +const guide = fs.readFileSync(guidePath, 'utf8'); +const extensionSource = fs.readFileSync(entryPath, 'utf8'); +const sublimeConfigSource = fs.readFileSync(sublimeConfigPath, 'utf8'); + +if (extensionSource.includes('docs/tooling/editor-config-contract.json')) { + console.error('VS Code extension must not load editor config contract from docs/tooling.'); + process.exit(1); +} +if (sublimeConfigSource.includes('docs/tooling/editor-config-contract.json')) { + console.error('Sublime config must not load editor config contract from docs/tooling.'); + process.exit(1); +} +if (JSON.stringify(packagedContract) !== JSON.stringify(contract)) { + console.error('VS Code packaged editor config contract drifted from src/contracts/editor-config-contract.json.'); + process.exit(1); +} + +const requiredMetadata = [ + ['homepage', manifest.homepage], + ['repository.url', manifest.repository?.url], + ['bugs.url', manifest.bugs?.url], + ['publisher', manifest.publisher], + ['markdown', manifest.markdown] +]; +for (const [label, value] of requiredMetadata) { + if (typeof value !== 'string' || value.trim().length === 0) { + console.error(`VS Code extension metadata missing ${label}.`); + process.exit(1); + } +} +if (manifest.capabilities?.virtualWorkspaces !== false) { + console.error('VS Code extension must declare virtualWorkspaces=false.'); + process.exit(1); +} + +const activationEvents = new Set(manifest.activationEvents || []); +const expectedCommands = new Map(getEditorCommandSpecs().map((entry) => [entry.id, entry.title])); +for (const commandId of expectedCommands.keys()) { + if (!activationEvents.has(`onCommand:${commandId}`)) { + console.error(`VS Code extension activation event missing for ${commandId}.`); + process.exit(1); + } +} + +const commands = manifest.contributes?.commands || []; +for (const [commandId, title] of expectedCommands.entries()) { + const command = commands.find((cmd) => cmd.command === commandId); + if (!command || command.title !== title) { + console.error(`VS Code extension command drifted for ${commandId}.`); + process.exit(1); + } +} + +const explorerViews = manifest.contributes?.views?.explorer || []; +if (!explorerViews.some((view) => view.id === 'pairofcleats.resultsExplorer')) { + console.error('VS Code extension explorer view missing pairofcleats.resultsExplorer.'); + process.exit(1); +} + +const commandPaletteMenus = manifest.contributes?.menus?.commandPalette || []; +const editorContextMenus = manifest.contributes?.menus?.['editor/context'] || []; +const viewTitleMenus = manifest.contributes?.menus?.['view/title'] || []; +const viewItemContextMenus = manifest.contributes?.menus?.['view/item/context'] || []; + +const requireMenuEntries = (entries, required, label) => { + for (const [commandId, expectedWhen] of required.entries()) { + const menu = entries.find((entry) => entry.command === commandId); + if (!menu || menu.when !== expectedWhen) { + console.error(`VS Code ${label} drifted for ${commandId}.`); + process.exit(1); + } + } +}; + +requireMenuEntries(commandPaletteMenus, new Map([ + ['pairofcleats.searchSelection', 'editorTextFocus && editorHasSelection'], + ['pairofcleats.searchSymbolUnderCursor', 'editorTextFocus'], + ['pairofcleats.selectRepo', 'workbenchState != empty'], + ['pairofcleats.clearSelectedRepo', 'workbenchState != empty'], + ['pairofcleats.search', 'workbenchState != empty'], + ['pairofcleats.explainSearch', 'workbenchState != empty'], + ['pairofcleats.showSearchHistory', 'workbenchState != empty'], + ['pairofcleats.repeatLastSearch', 'workbenchState != empty'], + ['pairofcleats.reopenLastResults', 'workbenchState != empty'], + ['pairofcleats.openIndexDirectory', 'workbenchState != empty'], + ['pairofcleats.contextPack', 'workbenchState != empty'], + ['pairofcleats.riskExplain', 'workbenchState != empty'], + ['pairofcleats.indexBuild', 'workbenchState != empty'], + ['pairofcleats.indexWatchStart', 'workbenchState != empty'], + ['pairofcleats.indexWatchStop', 'workbenchState != empty'], + ['pairofcleats.indexValidate', 'workbenchState != empty'], + ['pairofcleats.serviceApiStart', 'workbenchState != empty'], + ['pairofcleats.serviceApiStop', 'workbenchState != empty'], + ['pairofcleats.serviceIndexerStart', 'workbenchState != empty'], + ['pairofcleats.serviceIndexerStop', 'workbenchState != empty'], + ['pairofcleats.openResultHit', 'false'], + ['pairofcleats.revealResultHit', 'false'], + ['pairofcleats.copyResultPath', 'false'], + ['pairofcleats.rerunResultSet', 'false'] +]), 'commandPalette menu'); + +requireMenuEntries(editorContextMenus, new Map([ + ['pairofcleats.searchSelection', 'editorTextFocus && editorHasSelection'], + ['pairofcleats.searchSymbolUnderCursor', 'editorTextFocus'], + ['pairofcleats.explainSearch', 'editorTextFocus'], + ['pairofcleats.contextPack', 'editorTextFocus'], + ['pairofcleats.riskExplain', 'editorTextFocus'] +]), 'editor/context menu'); + +requireMenuEntries(viewTitleMenus, new Map([ + ['pairofcleats.search', 'view == pairofcleats.resultsExplorer'], + ['pairofcleats.showSearchHistory', 'view == pairofcleats.resultsExplorer'], + ['pairofcleats.reopenLastResults', 'view == pairofcleats.resultsExplorer'], + ['pairofcleats.groupResultsBySection', 'view == pairofcleats.resultsExplorer'], + ['pairofcleats.groupResultsByFile', 'view == pairofcleats.resultsExplorer'], + ['pairofcleats.groupResultsByQuery', 'view == pairofcleats.resultsExplorer'] +]), 'view/title menu'); + +requireMenuEntries(viewItemContextMenus, new Map([ + ['pairofcleats.openResultHit', 'view == pairofcleats.resultsExplorer && viewItem == pairofcleats.resultHit'], + ['pairofcleats.revealResultHit', 'view == pairofcleats.resultsExplorer && viewItem == pairofcleats.resultHit'], + ['pairofcleats.copyResultPath', 'view == pairofcleats.resultsExplorer && viewItem == pairofcleats.resultHit'], + ['pairofcleats.rerunResultSet', 'view == pairofcleats.resultsExplorer && viewItem == pairofcleats.resultSet'] +]), 'view/item/context menu'); + +const keybindings = manifest.contributes?.keybindings || []; +for (const [commandId, expectedWhen] of new Map([ + ['pairofcleats.searchSelection', 'editorTextFocus && editorHasSelection'], + ['pairofcleats.searchSymbolUnderCursor', 'editorTextFocus'], + ['pairofcleats.repeatLastSearch', 'workbenchState != empty'] +]).entries()) { + const binding = keybindings.find((entry) => entry.command === commandId); + if (!binding || binding.when !== expectedWhen) { + console.error(`VS Code keybinding drifted for ${commandId}.`); + process.exit(1); + } +} + +const walkthroughs = manifest.contributes?.walkthroughs || []; +if (!walkthroughs.some((entry) => entry.id === 'pairofcleats.gettingStarted')) { + console.error('VS Code walkthrough missing pairofcleats.gettingStarted.'); + process.exit(1); +} +for (const markdownPath of ['walkthroughs/first-search.md', 'walkthroughs/operations.md']) { + if (!fs.existsSync(path.join(root, 'extensions', 'vscode', markdownPath))) { + console.error(`VS Code walkthrough markdown missing ${markdownPath}.`); + process.exit(1); + } +} + +const settings = contract?.settings?.vscode || {}; +const keyMap = { + cliPathKey: 'pairofcleats', + cliArgsKey: 'pairofcleats', + apiServerUrlKey: 'pairofcleats', + apiTimeoutKey: 'pairofcleats', + apiExecutionModeKey: 'pairofcleats', + modeKey: 'pairofcleats', + backendKey: 'pairofcleats', + annKey: 'pairofcleats', + maxResultsKey: 'pairofcleats', + contextLinesKey: 'pairofcleats', + fileKey: 'pairofcleats', + pathKey: 'pairofcleats', + langKey: 'pairofcleats', + extKey: 'pairofcleats', + typeKey: 'pairofcleats', + asOfKey: 'pairofcleats', + snapshotKey: 'pairofcleats', + filterKey: 'pairofcleats', + authorKey: 'pairofcleats', + modifiedAfterKey: 'pairofcleats', + modifiedSinceKey: 'pairofcleats', + churnKey: 'pairofcleats', + caseSensitiveKey: 'pairofcleats', + envKey: 'pairofcleats', + extraSearchArgsKey: 'pairofcleats', + inlineHoverEnabledKey: 'pairofcleats', + inlineDiagnosticsEnabledKey: 'pairofcleats', + inlineDecorationsEnabledKey: 'pairofcleats', + inlineMaxItemsKey: 'pairofcleats' +}; +const expectedSettings = Object.entries(keyMap).map(([contractKey, prefix]) => `${prefix}.${settings[contractKey]}`).filter(Boolean); +const configProps = manifest.contributes?.configuration?.properties || {}; +for (const prop of expectedSettings) { + if (!configProps[prop]) { + console.error(`VS Code extension config missing ${prop}.`); + process.exit(1); + } +} + +const guideSettings = new Set(Array.from(guide.matchAll(/- `([^`]+)`/g), (match) => match[1]).filter((entry) => entry.startsWith('pairofcleats.'))); +for (const prop of expectedSettings) { + if (!guideSettings.has(prop)) { + console.error(`VS Code editor guide missing ${prop}.`); + process.exit(1); + } +} + +const modeEnum = configProps['pairofcleats.searchMode']?.enum || []; +if (!modeEnum.includes('extracted-prose')) { + console.error('VS Code searchMode enum missing extracted-prose.'); + process.exit(1); +} +const backendEnum = configProps['pairofcleats.searchBackend']?.enum || []; +for (const value of ['', 'auto', 'memory', 'sqlite', 'sqlite-fts', 'lmdb', 'tantivy']) { + if (!backendEnum.includes(value)) { + console.error(`VS Code searchBackend enum missing ${value || ''}.`); + process.exit(1); + } +} +if (configProps['pairofcleats.apiServerUrl']?.type !== 'string') { + console.error('VS Code apiServerUrl setting must be a string.'); + process.exit(1); +} +if (configProps['pairofcleats.apiTimeoutMs']?.type !== 'number' || configProps['pairofcleats.apiTimeoutMs']?.minimum !== 1) { + console.error('VS Code apiTimeoutMs setting contract drifted.'); + process.exit(1); +} +const apiExecutionModeEnum = configProps['pairofcleats.apiExecutionMode']?.enum || []; +for (const value of ['cli', 'prefer', 'require']) { + if (!apiExecutionModeEnum.includes(value)) { + console.error(`VS Code apiExecutionMode enum missing ${value}.`); + process.exit(1); + } +} +if (configProps['pairofcleats.env']?.type !== 'object') { + console.error('VS Code env setting must be an object.'); + process.exit(1); +} + +const outDir = resolveTestCachePath(root, 'package-vscode-structure'); +const run = runNode( + [path.join(root, 'tools', 'package-vscode.js'), '--out-dir', outDir, '--smoke'], + 'package-vscode contract matrix', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); +if (run.status !== 0) { + console.error('package-contract-matrix failed: package-vscode command failed'); + if (run.stderr) console.error(run.stderr.trim()); + process.exit(run.status ?? 1); +} + +const archivePath = path.join(outDir, 'pairofcleats.vsix'); +const packagedManifestPath = `${archivePath}.manifest.json`; +if (!fs.existsSync(archivePath) || !fs.existsSync(packagedManifestPath)) { + console.error('package-contract-matrix failed: archive outputs missing'); + process.exit(1); +} +const packagedManifest = JSON.parse(fs.readFileSync(packagedManifestPath, 'utf8')); +const paths = Array.isArray(packagedManifest.entries) ? packagedManifest.entries.map((entry) => entry.path) : []; +for (const entry of [ + 'extension/package.json', + 'extension/extension.js', + 'extension/analysis-renderers.js', + 'extension/windows-cmd.js', + 'extension/windows-cmd-core.cjs', + 'extension/README.md', + 'extension/walkthroughs/first-search.md', + 'extension/walkthroughs/operations.md' +]) { + if (!paths.includes(entry)) { + console.error(`package-contract-matrix failed: missing shipped entry ${entry}`); + process.exit(1); + } +} + +console.log('vscode package contract matrix test passed'); diff --git a/tests/tooling/vscode/package-determinism.test.js b/tests/tooling/vscode/package-determinism.test.js new file mode 100644 index 000000000..1ea699b05 --- /dev/null +++ b/tests/tooling/vscode/package-determinism.test.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +import { runNode } from '../../helpers/run-node.js'; +import { resolveTestCachePath } from '../../helpers/test-cache.js'; + +const root = process.cwd(); +const outA = resolveTestCachePath(root, 'package-vscode-determinism-a'); +const outB = resolveTestCachePath(root, 'package-vscode-determinism-b'); + +const runPack = (outDir) => runNode( + [path.join(root, 'tools', 'package-vscode.js'), '--out-dir', outDir], + 'package-vscode determinism', + root, + process.env, + { stdio: 'pipe', allowFailure: true } +); + +const first = runPack(outA); +if (first.status !== 0) { + console.error('package-determinism test failed: first package run failed'); + if (first.stderr) console.error(first.stderr.trim()); + process.exit(first.status ?? 1); +} +const second = runPack(outB); +if (second.status !== 0) { + console.error('package-determinism test failed: second package run failed'); + if (second.stderr) console.error(second.stderr.trim()); + process.exit(second.status ?? 1); +} + +const checksumA = fs.readFileSync(path.join(outA, 'pairofcleats.vsix.sha256'), 'utf8').trim(); +const checksumB = fs.readFileSync(path.join(outB, 'pairofcleats.vsix.sha256'), 'utf8').trim(); +if (checksumA !== checksumB) { + console.error('package-determinism test failed: checksum mismatch across runs'); + process.exit(1); +} + +console.log('vscode package determinism test passed'); diff --git a/tests/tooling/vscode/results-explorer-runtime.test.js b/tests/tooling/vscode/results-explorer-runtime.test.js new file mode 100644 index 000000000..e892746a9 --- /dev/null +++ b/tests/tooling/vscode/results-explorer-runtime.test.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createResultsExplorerRuntimeHarness } from './runtime-test-helpers.js'; + +const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-results-')); +fs.mkdirSync(path.join(repoRoot, 'bin'), { recursive: true }); +fs.mkdirSync(path.join(repoRoot, 'src'), { recursive: true }); +fs.writeFileSync(path.join(repoRoot, 'bin', 'pairofcleats.js'), 'console.log("ok");'); +fs.writeFileSync(path.join(repoRoot, 'src', 'app.ts'), 'export const value = 1;\n'); +fs.writeFileSync(path.join(repoRoot, 'README.md'), '# readme\n'); +fs.writeFileSync(path.join(repoRoot, 'records.json'), '{}\n'); + +const harness = createResultsExplorerRuntimeHarness({ + repoRoot, + activeFile: path.join(repoRoot, 'src', 'app.ts'), + configValues: { + searchMode: 'both', + searchBackend: 'sqlite' + } +}); + +harness.activate(); +assert.equal(harness.treeViews[0].id, 'pairofcleats.resultsExplorer'); +const provider = harness.treeProviders[0]; + +harness.inputQueue.push('auth token'); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }], + prose: [{ file: 'README.md', score: 2, startLine: 1 }], + records: [{ file: 'records.json', score: 3, startLine: 1 }] + }) +}); +harness.quickPickQueue.push((items) => items[0]); +await harness.runCommand('pairofcleats.search'); + +const history = harness.workspaceStateStore.get('pairofcleats.searchHistory'); +assert.equal(history.length, 1); +assert.equal(history[0].query, 'auth token'); +assert.equal(history[0].totalHits, 3); +assert.equal(history[0].mode, 'both'); +assert.equal(history[0].backend, 'sqlite'); +assert.deepEqual(history[0].invocation.args, harness.spawnCalls[0].args); + +let roots = provider.getChildren(); +assert.deepEqual(roots.map((node) => node.treeItem.label).sort(), ['code', 'prose', 'records']); + +await harness.runCommand('pairofcleats.groupResultsByFile'); +roots = provider.getChildren(); +assert.deepEqual(roots.map((node) => node.treeItem.label).sort(), ['README.md', 'records.json', 'src/app.ts']); + +await harness.runCommand('pairofcleats.groupResultsByQuery'); +roots = provider.getChildren(); +assert.equal(roots[0].treeItem.label, 'auth token'); +const resultNode = roots[0].children[0]; +await harness.runCommand('pairofcleats.copyResultPath', resultNode); +assert.ok(harness.clipboardWrites[0].endsWith(path.join('src', 'app.ts'))); +await harness.runCommand('pairofcleats.revealResultHit', resultNode); +assert.equal(harness.executeCommandCalls[0].id, 'revealInExplorer'); +await harness.runCommand('pairofcleats.openResultHit', resultNode); +assert.ok(harness.openedPaths[0].endsWith(path.join('src', 'app.ts'))); +const openedEditor = harness.shownEditors.find((entry) => entry?.selection); +assert.equal(openedEditor.selection.start.line, 0); + +const traversalNode = { + ...resultNode, + hit: { ...resultNode.hit, file: '../outside.ts' } +}; +const errorCountBeforeTraversal = harness.errorMessages.length; +await harness.runCommand('pairofcleats.copyResultPath', traversalNode); +await harness.runCommand('pairofcleats.revealResultHit', traversalNode); +assert.ok(harness.errorMessages.slice(errorCountBeforeTraversal).some((message) => /outside the repo/i.test(message))); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + }) +}); +harness.quickPickQueue.push((items) => items[0]); +await harness.runCommand('pairofcleats.showSearchHistory'); +assert.deepEqual(harness.spawnCalls[1].args, harness.spawnCalls[0].args); + +await harness.runCommand('pairofcleats.reopenLastResults'); +assert.ok(harness.infoMessages.some((message) => /reopened results/i.test(message))); + +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + }) +}); +await harness.runCommand('pairofcleats.rerunResultSet', history[0]); +assert.deepEqual(harness.spawnCalls[2].args, harness.spawnCalls[0].args); +assert.equal(harness.errorMessages.length, errorCountBeforeTraversal + 2, `unexpected errors: ${harness.errorMessages.join('; ')}`); + +harness.restoreGlobals(); + +console.log('vscode results explorer runtime test passed'); diff --git a/tests/tooling/vscode/runtime-test-helpers.js b/tests/tooling/vscode/runtime-test-helpers.js new file mode 100644 index 000000000..e6d105528 --- /dev/null +++ b/tests/tooling/vscode/runtime-test-helpers.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; + +import { createVsCodeRuntimeHarness } from '../../helpers/vscode/runtime-harness.js'; + +export function assertRegisteredCommands(registeredCommands, commandIds) { + for (const commandId of commandIds) { + assert.ok(registeredCommands.has(commandId), `missing registered command ${commandId}`); + } +} + +export function createResultsExplorerRuntimeHarness(options) { + const harness = createVsCodeRuntimeHarness(options); + const openedPaths = []; + const shownEditors = []; + + harness.fakeVscode.workspace.openTextDocument = async function openTextDocument(uri) { + openedPaths.push(uri.fsPath); + const document = { uri }; + harness.openedDocuments.push(document); + return document; + }; + harness.fakeVscode.window.showTextDocument = async function showTextDocument(document) { + const editor = { + document, + selection: null, + revealRange(range) { + shownEditors.push({ document, range }); + } + }; + shownEditors.push(editor); + return editor; + }; + + return { + ...harness, + openedPaths, + shownEditors + }; +} diff --git a/tests/tooling/vscode/runtime.test.js b/tests/tooling/vscode/runtime.test.js new file mode 100644 index 000000000..43849b807 --- /dev/null +++ b/tests/tooling/vscode/runtime.test.js @@ -0,0 +1,324 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { + resolveConfiguredCli, + parseSearchPayload, + summarizeProcessFailure, + summarizeSpawnFailure, + spawnBufferedProcess, + resolveValidatedHitTarget, + openSearchHit +} = require('../../../extensions/vscode/runtime.js'); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-runtime-')); + +const directCommand = resolveConfiguredCli('C:/repo', 'pairofcleats', ['--verbose'], { + command: 'pairofcleats', + jsExtension: '.js' +}); +assert.equal(directCommand.ok, true); +assert.equal(directCommand.command, 'pairofcleats'); +assert.deepEqual(directCommand.argsPrefix, ['--verbose']); + +const missing = resolveConfiguredCli('C:/repo', 'missing/pairofcleats.js', [], { + command: 'pairofcleats', + jsExtension: '.js' +}); +assert.equal(missing.ok, false); +assert.match(missing.message, /does not exist/i); + +const invalidDir = resolveConfiguredCli(tempRoot, '.', [], { + command: 'pairofcleats', + jsExtension: '.js' +}); +assert.equal(invalidDir.ok, false); +assert.match(invalidDir.message, /not a file/i); + +const timeout = summarizeProcessFailure({ + code: null, + timedOut: true, + cancelled: false, + stderr: '', + stdout: '', + stdoutTruncated: false, + stderrTruncated: false, + timeoutMs: 60000 +}); +assert.equal(timeout.kind, 'timeout'); + +const cancelled = summarizeProcessFailure({ + code: null, + timedOut: false, + cancelled: true, + stderr: '', + stdout: '', + stdoutTruncated: false, + stderrTruncated: false, + timeoutMs: 60000 +}); +assert.equal(cancelled.kind, 'cancelled'); + +const nonzero = summarizeProcessFailure({ + code: 7, + timedOut: false, + cancelled: false, + stderr: 'boom', + stdout: '', + stdoutTruncated: false, + stderrTruncated: true, + timeoutMs: 60000 +}); +assert.equal(nonzero.kind, 'nonzero-exit'); +assert.match(nonzero.detail, /\[output truncated\]/); + +const truncated = summarizeProcessFailure({ + code: 0, + timedOut: false, + cancelled: false, + stderr: '', + stdout: '{"incomplete"', + stdoutTruncated: true, + stderrTruncated: false, + timeoutMs: 60000 +}); +assert.equal(truncated.kind, 'truncated-output'); + +assert.equal( + summarizeProcessFailure({ + code: 0, + timedOut: false, + cancelled: false, + stderr: 'warning', + stdout: '{"ok":true}', + stdoutTruncated: false, + stderrTruncated: true, + timeoutMs: 60000 + }), + null +); + +const parsedOk = parseSearchPayload('{"code":[{"file":"a.js"}]}'); +assert.equal(parsedOk.ok, true); + +const parsedBad = parseSearchPayload('{"broken"', { stdoutTruncated: false }); +assert.equal(parsedBad.ok, false); +assert.equal(parsedBad.kind, 'invalid-json'); + +const parsedTruncated = parseSearchPayload('{"code":[', { stdoutTruncated: true }); +assert.equal(parsedTruncated.ok, false); +assert.equal(parsedTruncated.kind, 'stdout-truncated'); + +const parsedEmpty = parseSearchPayload('', { stdoutTruncated: false }); +assert.equal(parsedEmpty.ok, false); +assert.equal(parsedEmpty.kind, 'empty-output'); + +const spawnOk = spawnBufferedProcess( + { + spawn(command, args, options) { + return { command, args, options }; + } + }, + 'node', + ['tool.js'], + { cwd: tempRoot } +); +assert.equal(spawnOk.ok, true); +assert.equal(spawnOk.child.command, 'node'); + +const spawnFail = spawnBufferedProcess( + { + spawn() { + throw new Error('spawn denied'); + } + }, + 'node', + ['tool.js'], + { cwd: tempRoot } +); +assert.equal(spawnFail.ok, false); +assert.match(summarizeSpawnFailure('PairOfCleats search', spawnFail.error).message, /failed to start/i); + +const revealCalls = []; +class FakeUri { + constructor({ scheme, path: uriPath, fsPath, authority = '', query = '', fragment = '' }) { + this.scheme = scheme; + this.path = uriPath; + this.fsPath = fsPath; + this.authority = authority; + this.query = query; + this.fragment = fragment; + } + + with(changes = {}) { + const nextPath = Object.prototype.hasOwnProperty.call(changes, 'path') + ? changes.path + : this.path; + return new FakeUri({ + scheme: Object.prototype.hasOwnProperty.call(changes, 'scheme') ? changes.scheme : this.scheme, + path: nextPath, + fsPath: this.scheme === 'file' ? String(nextPath || '').replace(/\//g, path.sep) : nextPath, + authority: Object.prototype.hasOwnProperty.call(changes, 'authority') ? changes.authority : this.authority, + query: Object.prototype.hasOwnProperty.call(changes, 'query') ? changes.query : this.query, + fragment: Object.prototype.hasOwnProperty.call(changes, 'fragment') ? changes.fragment : this.fragment + }); + } + + toString() { + return `${this.scheme}:${this.path || this.fsPath || ''}`; + } +} + +const fakeVscode = { + workspace: { + async openTextDocument(uri) { + return { uri }; + } + }, + window: { + async showTextDocument() { + return { + selection: null, + revealRange(range, mode) { + revealCalls.push({ range, mode }); + } + }; + } + }, + Uri: { + file(filePath) { + return new FakeUri({ + scheme: 'file', + fsPath: filePath, + path: filePath + }); + }, + parse(value) { + const text = String(value || ''); + const match = text.match(/^([a-z0-9+.-]+):(.*)$/i); + const scheme = match ? match[1] : 'file'; + const uriPath = match ? match[2] : text; + return new FakeUri({ + scheme, + path: uriPath, + fsPath: scheme === 'file' ? uriPath.replace(/\//g, path.sep) : uriPath + }); + }, + joinPath(base, ...segments) { + const joined = path.posix.join(base.path || '', ...segments); + return new FakeUri({ + scheme: base.scheme, + path: joined, + fsPath: joined.replace(/\//g, path.sep), + authority: base.authority || '', + query: base.query || '', + fragment: base.fragment || '' + }); + } + }, + Position: class Position { + constructor(line, character) { + this.line = line; + this.character = character; + } + }, + Range: class Range { + constructor(start, end) { + this.start = start; + this.end = end; + } + }, + Selection: class Selection { + constructor(start, end) { + this.start = start; + this.end = end; + } + }, + TextEditorRevealType: { + InCenter: 'center' + } +}; + +const openOk = await openSearchHit(fakeVscode, 'C:/repo', { + file: 'src/index.js', + startLine: 4, + startCol: 3, + endLine: 4, + endCol: 12 +}); +assert.equal(openOk.ok, true); +assert.equal(openOk.filePath, path.join('C:/repo', 'src/index.js')); +assert.equal(revealCalls.length, 1); +assert.equal(revealCalls[0].range.start.line, 3); +assert.equal(revealCalls[0].range.start.character, 2); +assert.equal(revealCalls[0].range.end.character, 11); + +const openRemote = await openSearchHit(fakeVscode, { + repoRoot: null, + repoUri: { scheme: 'vscode-remote', path: '/workspace/repo', fsPath: '/workspace/repo' } +}, { + file: 'src/remote.ts', + startLine: 2, + startCol: 1, + endLine: 2, + endCol: 7 +}); +assert.equal(openRemote.ok, true); +assert.equal(openRemote.filePath, path.join(path.sep, 'workspace', 'repo', 'src', 'remote.ts')); + +const remoteAbsoluteTarget = resolveValidatedHitTarget(fakeVscode, { + repoRoot: null, + repoUri: new FakeUri({ scheme: 'vscode-remote', path: '/workspace/repo', fsPath: '/workspace/repo' }) +}, { + file: '/workspace/repo/src/absolute.ts' +}); +assert.equal(remoteAbsoluteTarget.ok, true); +assert.equal(remoteAbsoluteTarget.targetUri instanceof FakeUri, true, 'expected remote absolute hit to return a real Uri instance'); +assert.equal(remoteAbsoluteTarget.targetUri.scheme, 'vscode-remote'); +assert.equal(remoteAbsoluteTarget.targetUri.path, '/workspace/repo/src/absolute.ts'); + +const remoteOutsideTarget = resolveValidatedHitTarget(fakeVscode, { + repoRoot: null, + repoUri: new FakeUri({ scheme: 'vscode-remote', path: '/workspace/repo', fsPath: '/workspace/repo' }) +}, { + file: '/workspace/other/outside.ts' +}); +assert.equal(remoteOutsideTarget.ok, false); +assert.match(remoteOutsideTarget.message, /outside the repo/i); + +const openTraversal = await openSearchHit(fakeVscode, 'C:/repo', { + file: '../outside.js' +}); +assert.equal(openTraversal.ok, false); +assert.match(openTraversal.message, /outside the repo/i); + +const navigateFail = await openSearchHit({ + ...fakeVscode, + window: { + async showTextDocument() { + throw new Error('cannot reveal'); + } + } +}, 'C:/repo', { file: 'src/index.js' }); +assert.equal(navigateFail.ok, false); +assert.match(navigateFail.message, /could not navigate/i); +assert.match(navigateFail.detail, /cannot reveal/i); + +const openFail = await openSearchHit({ + ...fakeVscode, + workspace: { + async openTextDocument() { + throw new Error('missing file'); + } + } +}, 'C:/repo', { file: 'missing.js' }); +assert.equal(openFail.ok, false); +assert.match(openFail.message, /could not open/i); +assert.match(openFail.detail, /missing file/i); + +console.log('vscode runtime contract test passed'); diff --git a/tests/tooling/vscode/search-contract-matrix.test.js b/tests/tooling/vscode/search-contract-matrix.test.js new file mode 100644 index 000000000..73fbb673a --- /dev/null +++ b/tests/tooling/vscode/search-contract-matrix.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + buildSearchArgs, + readSearchOptions, + collectSearchHits +} = require('../../../extensions/vscode/search-contract.js'); + +const args = buildSearchArgs('needle', '/repo', { + mode: 'extracted-prose', + backend: 'sqlite-fts', + annEnabled: false, + explain: true, + maxResults: 7, + contextLines: 3, + file: 'src/index.js', + path: 'src/', + lang: 'javascript', + ext: '.js', + type: 'Function', + asOf: 'snap:latest', + filter: 'lang:javascript', + author: 'Jane Doe', + modifiedAfter: '2025-01-01', + modifiedSince: '30', + churn: '10', + caseSensitive: true, + extraArgs: ['--risk', 'high'] +}); +assert.deepEqual(args, [ + 'search', '--json', '--top', '7', '--mode', 'extracted-prose', '--backend', 'sqlite-fts', '--no-ann', + '--context', '3', '--file', 'src/index.js', '--path', 'src/', '--lang', 'javascript', '--ext', '.js', + '--type', 'Function', '--as-of', 'snap:latest', '--filter', 'lang:javascript', '--author', 'Jane Doe', + '--modified-after', '2025-01-01', '--modified-since', '30', '--churn', '10', '--case', '--explain', + '--repo', '/repo', '--risk', 'high', '--', 'needle' +]); +assert.deepEqual(buildSearchArgs('alpha', null, {}), ['search', '--json', '--top', '25', '--', 'alpha']); +assert.throws(() => buildSearchArgs('alpha', '/repo', { asOf: 'snap:current', snapshot: 'snap-123' }), /searchAsOf and searchSnapshot/i); + +const settings = { + modeKey: 'searchMode', + backendKey: 'searchBackend', + annKey: 'searchAnn', + maxResultsKey: 'maxResults', + contextLinesKey: 'searchContextLines', + fileKey: 'searchFile', + pathKey: 'searchPath', + langKey: 'searchLang', + extKey: 'searchExt', + typeKey: 'searchType', + asOfKey: 'searchAsOf', + snapshotKey: 'searchSnapshot', + filterKey: 'searchFilter', + authorKey: 'searchAuthor', + modifiedAfterKey: 'searchModifiedAfter', + modifiedSinceKey: 'searchModifiedSince', + churnKey: 'searchChurn', + caseSensitiveKey: 'searchCaseSensitive', + extraSearchArgsKey: 'extraSearchArgs' +}; +const options = readSearchOptions({ + get(key) { + return { + searchMode: 'code', + searchBackend: 'sqlite', + searchAnn: false, + maxResults: 50, + searchContextLines: 2, + searchFile: 'src/app.ts', + searchPath: 'src/', + searchLang: 'typescript', + searchExt: '.ts', + searchType: 'Function', + searchAsOf: 'snap:current', + searchSnapshot: '', + searchFilter: 'lang:typescript', + searchAuthor: 'Jane Doe', + searchModifiedAfter: '2025-01-01', + searchModifiedSince: '14', + searchChurn: '25', + searchCaseSensitive: true, + extraSearchArgs: ['--risk', 'high'] + }[key]; + } +}, settings); +assert.deepEqual(options, { + mode: 'code', + backend: 'sqlite', + annEnabled: false, + maxResults: 50, + contextLines: 2, + file: 'src/app.ts', + path: 'src/', + lang: 'typescript', + ext: '.ts', + type: 'Function', + asOf: 'snap:current', + snapshot: '', + filter: 'lang:typescript', + author: 'Jane Doe', + modifiedAfter: '2025-01-01', + modifiedSince: '14', + churn: '25', + caseSensitive: true, + extraArgs: ['--risk', 'high'] +}); + +const hits = collectSearchHits({ + code: [{ file: 'src/app.js', score: 1 }], + prose: [{ file: 'README.md', score: 2 }], + extractedProse: [{ file: 'docs/api.md', score: 3 }], + records: [{ file: 'records.json', score: 4 }], + ignored: [{ file: 'skip-me' }] +}); +assert.equal(hits.length, 4); +assert.deepEqual(hits.map((hit) => hit.section), ['code', 'prose', 'extracted-prose', 'records']); +assert.equal(hits[2].file, 'docs/api.md'); + +console.log('vscode search contract matrix test passed'); diff --git a/tests/tooling/vscode/search-env-runtime.test.js b/tests/tooling/vscode/search-env-runtime.test.js new file mode 100644 index 000000000..abb7291dd --- /dev/null +++ b/tests/tooling/vscode/search-env-runtime.test.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + createVsCodeRuntimeHarness, + prepareVsCodeFixtureWorkspace +} from '../../helpers/vscode/runtime-harness.js'; + +const originalPath = process.env.PATH; +const originalToken = process.env.PAIROFCLEATS_API_TOKEN; + +process.env.PATH = 'process-path'; +process.env.PAIROFCLEATS_API_TOKEN = 'process-token'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-env-' +}); + +const cliHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + configValues: { + env: { + PAIROFCLEATS_API_TOKEN: 'settings-token', + CUSTOM_NUMERIC_FLAG: 17, + CUSTOM_BOOL_FLAG: true + } + } +}); + +try { + cliHarness.activate(); + cliHarness.inputQueue.push('needle'); + cliHarness.quickPickQueue.push(null); + cliHarness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + }) + }); + await cliHarness.runCommand('pairofcleats.search'); + + assert.equal(cliHarness.spawnCalls.length, 1, 'expected one CLI search spawn'); + assert.equal(cliHarness.spawnCalls[0].options.env.PATH, 'process-path'); + assert.equal(cliHarness.spawnCalls[0].options.env.PAIROFCLEATS_API_TOKEN, 'settings-token'); + assert.equal(cliHarness.spawnCalls[0].options.env.CUSTOM_NUMERIC_FLAG, '17'); + assert.equal(cliHarness.spawnCalls[0].options.env.CUSTOM_BOOL_FLAG, 'true'); +} finally { + cliHarness.restoreGlobals(); +} + +const apiHarness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + workspaceFolders: [{ name: 'repo', path: workspace.root }], + configValues: { + apiExecutionMode: 'require', + apiServerUrl: 'http://127.0.0.1:4311', + env: { + PAIROFCLEATS_API_TOKEN: 'settings-token' + } + } +}); + +try { + apiHarness.activate(); + apiHarness.inputQueue.push('AuthToken'); + apiHarness.quickPickQueue.push(null); + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + capabilities: { + search: true + } + } + }); + apiHarness.queuedFetchResults.push({ + status: 200, + json: { + ok: true, + result: { + code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] + } + } + }); + await apiHarness.runCommand('pairofcleats.search'); + + assert.equal(apiHarness.fetchCalls.length, 2, 'expected capabilities probe plus search request'); + assert.equal( + apiHarness.fetchCalls[0].options.headers.Authorization, + 'Bearer settings-token' + ); + assert.equal( + apiHarness.fetchCalls[1].options.headers.Authorization, + 'Bearer settings-token' + ); +} finally { + apiHarness.restoreGlobals(); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalToken === undefined) { + delete process.env.PAIROFCLEATS_API_TOKEN; + } else { + process.env.PAIROFCLEATS_API_TOKEN = originalToken; + } +} + +console.log('vscode search env runtime test passed'); diff --git a/tests/tooling/vscode/search-payload-mapping.test.js b/tests/tooling/vscode/search-payload-mapping.test.js new file mode 100644 index 000000000..c5999225b --- /dev/null +++ b/tests/tooling/vscode/search-payload-mapping.test.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { buildSearchPayload } = require('../../../extensions/vscode/search-contract.js'); + +const payload = buildSearchPayload('needle', '/repo', { + mode: 'records', + backend: 'sqlite', + annEnabled: false, + maxResults: 11, + contextLines: 4, + file: 'src/index.js', + path: 'src/', + lang: 'javascript', + ext: '.js', + type: 'Function', + snapshot: 'snap-123', + filter: 'lang:javascript', + author: 'Jane Doe', + modifiedAfter: '2025-01-01', + modifiedSince: '30', + churn: '10', + caseSensitive: true +}); + +assert.deepEqual(payload, { + query: 'needle', + repo: '/repo', + top: 11, + mode: 'records', + backend: 'sqlite', + ann: false, + context: 4, + file: 'src/index.js', + path: 'src/', + lang: 'javascript', + ext: '.js', + type: 'Function', + snapshotId: 'snap-123', + filter: 'lang:javascript', + author: 'Jane Doe', + modifiedAfter: '2025-01-01', + modifiedSince: '30', + churnMin: 10, + case: true +}); + +const defaultPayload = buildSearchPayload('alpha', '', {}); +assert.deepEqual(defaultPayload, { + query: 'alpha', + repo: '', + top: 25, + mode: 'both' +}); + +assert.throws( + () => buildSearchPayload('alpha', '/repo', { asOf: 'snap:current', snapshot: 'snap-123' }), + /searchAsOf and searchSnapshot/i +); + +console.log('vscode search payload mapping test passed'); diff --git a/tests/tooling/vscode/search-runtime-contract-matrix.test.js b/tests/tooling/vscode/search-runtime-contract-matrix.test.js new file mode 100644 index 000000000..7b268a4d8 --- /dev/null +++ b/tests/tooling/vscode/search-runtime-contract-matrix.test.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { + prepareVsCodeFixtureWorkspace, + createVsCodeRuntimeHarness +} from '../../helpers/vscode/runtime-harness.js'; + +const workspace = await prepareVsCodeFixtureWorkspace('vscode/workspace-root', { + prefix: 'poc-vscode-search-runtime-' +}); + +const emptySelection = { + isEmpty: true, + start: { line: 0, character: 4 }, + end: { line: 0, character: 4 }, + active: { line: 0, character: 4 } +}; +const selectedRange = { + isEmpty: false, + start: { line: 0, character: 0 }, + end: { line: 0, character: 14 }, + active: { line: 0, character: 14 } +}; +const symbolRange = { kind: 'word' }; +const activeDocument = { + uri: { scheme: 'file', fsPath: workspace.resolvePath('src', 'app.ts') }, + getText(range) { + if (range === selectedRange) return 'selected token'; + if (range === symbolRange) return 'AuthToken'; + return ''; + }, + getWordRangeAtPosition() { + return symbolRange; + } +}; +const activeEditor = { + document: activeDocument, + selection: emptySelection, + selections: [emptySelection] +}; + +const harness = createVsCodeRuntimeHarness({ + repoRoot: workspace.root, + activeEditor, + configValues: { + searchMode: 'both', + searchBackend: 'sqlite' + } +}); + +try { + const { extension } = harness; + harness.activate(); + + harness.quickPickQueue.push(null); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] }) + }); + activeEditor.selection = selectedRange; + activeEditor.selections = [selectedRange]; + await harness.runCommand('pairofcleats.searchSelection'); + assert.equal(harness.spawnCalls[0].args.at(-1), 'selected token'); + assert.ok(!harness.spawnCalls[0].args.includes('--explain')); + + harness.quickPickQueue.push(null); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] }) + }); + activeEditor.selection = emptySelection; + activeEditor.selections = [emptySelection]; + await harness.runCommand('pairofcleats.searchSymbolUnderCursor'); + assert.equal(harness.spawnCalls[1].args.at(-1), 'AuthToken'); + assert.ok(!harness.spawnCalls[1].args.includes('--explain')); + + harness.inputQueue.push('why auth matters'); + harness.quickPickQueue.push(null); + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ code: [{ file: 'src/app.ts', score: 1, startLine: 1 }] }) + }); + await extension._test.runExplainSearch(); + assert.equal(harness.spawnCalls[2].args.at(-1), 'why auth matters'); + assert.ok(harness.spawnCalls[2].args.includes('--explain')); + + harness.queuedResults.push({ throw: new Error('sync search spawn failure') }); + await harness.extension._test.executeSearchCommand({ query: 'AuthToken' }); + assert.match(harness.errorMessages.shift(), /PairOfCleats search failed to start/i); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /sync search spawn failure/i.test(event.line))); + + harness.queuedResults.push({ code: 0, stdout: '' }); + await harness.extension._test.executeSearchCommand({ query: 'AuthToken' }); + assert.equal(harness.errorMessages.shift(), 'PairOfCleats search returned no JSON output.'); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /\[search\] parse failure kind=empty-output/i.test(event.line))); + + harness.queuedResults.push({ code: 0, stdout: '{', stderr: 'stderr detail' }); + await harness.extension._test.executeSearchCommand({ query: 'AuthToken' }); + assert.match(harness.errorMessages.shift(), /returned invalid JSON/i); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /stderr detail/i.test(event.line))); + + harness.queuedResults.push({ code: 7, stdout: '', stderr: 'fatal search failure' }); + await harness.extension._test.executeSearchCommand({ query: 'AuthToken' }); + assert.equal(harness.errorMessages.shift(), 'PairOfCleats search failed (exit 7).'); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /fatal search failure/i.test(event.line))); + + harness.quickPickQueue.push((items) => items[0]); + harness.fakeVscode.workspace.openTextDocument = async () => { + throw new Error('cannot open selected hit'); + }; + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + code: [{ file: 'src/app.ts', startLine: 1, score: 1 }] + }) + }); + await harness.extension._test.executeSearchCommand({ query: 'AuthToken' }); + assert.match(harness.errorMessages.shift(), /could not open/i); + assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /cannot open selected hit/i.test(event.line))); +} finally { + harness.restoreGlobals(); +} + +console.log('vscode search runtime contract matrix test passed'); diff --git a/tests/tooling/vscode/session-runtime.test.js b/tests/tooling/vscode/session-runtime.test.js new file mode 100644 index 000000000..3f443e10f --- /dev/null +++ b/tests/tooling/vscode/session-runtime.test.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { + createVsCodeRuntimeTempRepo, + createVsCodeRuntimeHarness +} from '../../helpers/vscode/runtime-harness.js'; +import { + assertNoRuntimeErrors, + assertRegisteredCommands, + assertSpawnArgs +} from '../../helpers/vscode/runtime-assertions.js'; + +const repoA = createVsCodeRuntimeTempRepo({ + prefix: 'poc-vscode-session-a-', + toolScripts: ['tools/config/dump.js'] +}); +const repoB = createVsCodeRuntimeTempRepo({ + prefix: 'poc-vscode-session-b-', + toolScripts: ['tools/config/dump.js'] +}); +const staleSession = { + sessionId: 'stale-running-session', + commandId: 'pairofcleats.configDump', + title: 'PairOfCleats: Config Dump', + repoRoot: repoA, + status: 'running', + startedAt: '2026-03-12T00:00:00.000Z', + invocation: { + kind: 'operator', + command: process.execPath, + args: [path.join(repoA, 'tools', 'config', 'dump.js'), '--json', '--repo', repoA], + timeoutMs: 60000 + } +}; +const harness = createVsCodeRuntimeHarness({ + repoRoot: repoA, + workspaceFolders: [ + { name: 'repo-a', path: repoA }, + { name: 'repo-b', path: repoB } + ], + activeFile: path.join(repoA, 'src', 'app.ts'), + workspaceState: { + 'pairofcleats.workflowSessions': [staleSession] + } +}); + +harness.activate(); + +try { + await new Promise((resolve) => setImmediate(resolve)); + + assertRegisteredCommands(harness.registeredCommands, [ + 'pairofcleats.showWorkflowStatus', + 'pairofcleats.rerunLastWorkflow', + 'pairofcleats.showRecentWorkflows' + ]); + + let storedSessions = harness.workspaceStateStore.get('pairofcleats.workflowSessions'); + const statusBar = harness.statusBarItems[0]; + assert.equal(storedSessions[0].status, 'interrupted'); + assert.match(statusBar.text, /PairOfCleats: .*interrupted/i); + assert.equal(statusBar.command, 'pairofcleats.showWorkflowStatus'); + + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot: repoA, + policy: { quality: { value: 'max', source: 'config' } }, + derived: { cacheRoot: path.join(repoA, '.cache'), repoCacheRoot: path.join(repoA, '.cache', 'repo') } + }) + }); + await harness.runCommand('pairofcleats.configDump'); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Config Dump completed.'); + storedSessions = harness.workspaceStateStore.get('pairofcleats.workflowSessions'); + assert.equal(storedSessions[0].status, 'succeeded'); + assert.equal(storedSessions[0].repoRoot, repoA); + assert.equal(storedSessions[0].commandId, 'pairofcleats.configDump'); + assert.deepEqual( + storedSessions[0].invocation.args, + [path.join(repoA, 'tools', 'config', 'dump.js'), '--json', '--repo', repoA] + ); + assert.match(statusBar.text, /PairOfCleats: .*succeeded/i); + + harness.setActiveFile(path.join(repoB, 'src', 'app.ts')); + assert.equal(statusBar.text, `PairOfCleats: ${path.basename(repoB)}`); + + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot: repoA, + policy: { quality: { value: 'max', source: 'config' } }, + derived: { cacheRoot: path.join(repoA, '.cache'), repoCacheRoot: path.join(repoA, '.cache', 'repo') } + }) + }); + await harness.runCommand('pairofcleats.rerunLastWorkflow'); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Config Dump completed.'); + assertSpawnArgs(harness.spawnCalls, 1, harness.spawnCalls[0].args); + + harness.quickPickQueue.push((items) => items.find((item) => item.action === 'output')); + await harness.runCommand('pairofcleats.showWorkflowStatus'); + assert.ok(harness.outputEvents.some((event) => event.kind === 'show')); + + harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + repoRoot: repoA, + policy: { quality: { value: 'max', source: 'config' } }, + derived: { cacheRoot: path.join(repoA, '.cache'), repoCacheRoot: path.join(repoA, '.cache', 'repo') } + }) + }); + harness.quickPickQueue.push((items) => items.find((item) => item.session)); + await harness.runCommand('pairofcleats.showRecentWorkflows'); + assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Config Dump completed.'); + assertSpawnArgs(harness.spawnCalls, 2, harness.spawnCalls[0].args); + assertNoRuntimeErrors(harness.errorMessages); +} finally { + harness.restoreGlobals(); +} + +console.log('vscode session runtime test passed'); diff --git a/tests/tooling/vscode/toolchain-missing-policy.test.js b/tests/tooling/vscode/toolchain-missing-policy.test.js index c335e4b9e..e5e876a9a 100644 --- a/tests/tooling/vscode/toolchain-missing-policy.test.js +++ b/tests/tooling/vscode/toolchain-missing-policy.test.js @@ -1,22 +1,27 @@ #!/usr/bin/env node import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../../helpers/run-node.js'; import { resolveTestCachePath } from '../../helpers/test-cache.js'; +import { applyTestEnv } from '../../helpers/test-env.js'; const root = process.cwd(); +const env = applyTestEnv({ + extraEnv: { + PATH: '', + Path: '' + }, + syncProcess: false +}); -const run = spawnSync( - process.execPath, +const run = runNode( [path.join(root, 'tools', 'package-vscode.js'), '--out-dir', resolveTestCachePath(root, 'package-vscode-missing-toolchain')], + 'package-vscode missing toolchain', + root, + env, { - cwd: root, - encoding: 'utf8', - env: { - ...process.env, - PATH: '', - Path: '' - } + stdio: 'pipe', + allowFailure: true } ); diff --git a/tests/tooling/vscode/vscode-extension.test.js b/tests/tooling/vscode/vscode-extension.test.js deleted file mode 100644 index a8919935a..000000000 --- a/tests/tooling/vscode/vscode-extension.test.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; - -const root = process.cwd(); -const extensionDir = path.join(root, 'extensions', 'vscode'); -const manifestPath = path.join(extensionDir, 'package.json'); -const entryPath = path.join(extensionDir, 'extension.js'); -const packagingScriptPath = path.join(root, 'tools', 'package-vscode.js'); -const determinismSpecPath = path.join(root, 'docs', 'specs', 'editor-packaging-determinism.md'); - -if (!fs.existsSync(manifestPath)) { - console.error('VS Code extension manifest missing.'); - process.exit(1); -} -if (!fs.existsSync(entryPath)) { - console.error('VS Code extension entrypoint missing.'); - process.exit(1); -} -if (!fs.existsSync(packagingScriptPath)) { - console.error('VS Code deterministic packaging script missing.'); - process.exit(1); -} -if (!fs.existsSync(determinismSpecPath)) { - console.error('Editor packaging determinism spec missing.'); - process.exit(1); -} - -const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); -const activationEvents = new Set(manifest.activationEvents || []); -if (!activationEvents.has('onCommand:pairofcleats.search')) { - console.error('VS Code extension activation event missing.'); - process.exit(1); -} - -const commands = manifest.contributes?.commands || []; -const commandIds = new Set(commands.map((cmd) => cmd.command)); -if (!commandIds.has('pairofcleats.search')) { - console.error('VS Code extension command missing.'); - process.exit(1); -} - -const configProps = manifest.contributes?.configuration?.properties || {}; -const requiredProps = [ - 'pairofcleats.cliPath', - 'pairofcleats.cliArgs', - 'pairofcleats.searchMode', - 'pairofcleats.searchBackend', - 'pairofcleats.searchAnn', - 'pairofcleats.maxResults' -]; -for (const prop of requiredProps) { - if (!configProps[prop]) { - console.error(`VS Code extension config missing ${prop}.`); - process.exit(1); - } -} - -console.log('VS Code extension tests passed'); diff --git a/tests/tooling/vscode/windows-cmd.test.js b/tests/tooling/vscode/windows-cmd.test.js new file mode 100644 index 000000000..1db8444fa --- /dev/null +++ b/tests/tooling/vscode/windows-cmd.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveWindowsCmdInvocation as resolveSharedInvocation } from '../../../src/shared/subprocess/windows-cmd.js'; + +const require = createRequire(import.meta.url); +const { + resolveWindowsCmdInvocation, + resolveWindowsCmdShimPath +} = require('../../../extensions/vscode/windows-cmd.js'); + +const args = ['alpha&beta', '%TEMP%', '!VALUE!', '^caret']; +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-vscode-windows-cmd-')); +try { + const wrapperPath = path.join(tempRoot, 'echo-arg.cmd'); + const bareWrapperPath = path.join(tempRoot, 'npm.cmd'); + await fs.writeFile( + wrapperPath, + '@echo off\r\nnode "%~dp0\\echo-arg.js" %*\r\n', + 'utf8' + ); + await fs.writeFile( + bareWrapperPath, + '@echo off\r\nnode "%~dp0\\echo-arg.js" %*\r\n', + 'utf8' + ); + const shared = resolveSharedInvocation(wrapperPath, args); + const extension = resolveWindowsCmdInvocation(wrapperPath, args); + assert.deepEqual(extension, shared, 'expected VS Code wrapper invocation to match shared Windows cmd escaping'); + const shimEnv = { + ...process.env, + PATH: tempRoot, + Path: tempRoot + }; + assert.equal( + resolveWindowsCmdShimPath('npm', shimEnv), + bareWrapperPath, + 'expected VS Code mirror to expose shared bare shim PATH resolution' + ); + assert.deepEqual( + resolveWindowsCmdInvocation('npm', args, shimEnv), + resolveSharedInvocation('npm', args, shimEnv), + 'expected VS Code bare shim invocation to match shared Windows cmd behavior' + ); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +console.log('vscode windows cmd helper test passed'); diff --git a/tests/tooling/vscode/workflow-runtime.test.js b/tests/tooling/vscode/workflow-runtime.test.js new file mode 100644 index 000000000..8619af54b --- /dev/null +++ b/tests/tooling/vscode/workflow-runtime.test.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createVsCodeRuntimeHarness } from '../../helpers/vscode/runtime-harness.js'; +import { assertRegisteredCommands } from './runtime-test-helpers.js'; + +const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-vscode-workflow-')); +const srcDir = path.join(repoRoot, 'src'); +const testsDir = path.join(repoRoot, 'tests'); +const rulesDir = path.join(repoRoot, 'rules'); +fs.mkdirSync(path.join(repoRoot, 'bin'), { recursive: true }); +fs.mkdirSync(srcDir, { recursive: true }); +fs.mkdirSync(testsDir, { recursive: true }); +fs.mkdirSync(rulesDir, { recursive: true }); +fs.writeFileSync(path.join(repoRoot, 'bin', 'pairofcleats.js'), 'console.log("ok");'); +fs.writeFileSync(path.join(srcDir, 'app.ts'), 'export const value = 1;\n'); +fs.writeFileSync(path.join(testsDir, 'app.test.ts'), 'test("ok", () => {});\n'); +fs.writeFileSync(path.join(rulesDir, 'architecture.rules.json'), '{"version":1,"rules":[]}\n'); +const workspacePath = path.join(repoRoot, '.pairofcleats-workspace.jsonc'); +fs.writeFileSync(workspacePath, '{"name":"Workspace","repos":[]}\n'); + +const harness = createVsCodeRuntimeHarness({ + repoRoot, + activeFile: path.join(srcDir, 'app.ts'), + configValues: { + cliArgs: ['--trace'], + searchMode: 'code' + } +}); + +harness.activate(); +assertRegisteredCommands(harness.registeredCommands, [ + 'pairofcleats.codeMap', + 'pairofcleats.architectureCheck', + 'pairofcleats.impact', + 'pairofcleats.suggestTests', + 'pairofcleats.workspaceManifest', + 'pairofcleats.workspaceStatus', + 'pairofcleats.workspaceBuild', + 'pairofcleats.workspaceCatalog' +]); + +const codeMapSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.codeMap'); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + format: 'html-iso', + outPath: path.join(repoRoot, '.pairofcleats', 'maps', 'vscode-map.iso.html'), + summary: { counts: { files: 4, members: 10, edges: 12 } }, + warnings: [] + }) +}); +await harness.extension._test.runOperatorCommand(codeMapSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Code Map completed.'); +assert.deepEqual( + harness.spawnCalls[0].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'report', + 'map', + '--json', + '--repo', + repoRoot, + '--format', + 'html-iso', + '--out', + path.join(repoRoot, '.pairofcleats', 'maps', 'vscode-map.iso.html') + ] +); +assert.equal(harness.openExternalCalls.length, 1, 'expected code map to open generated artifact'); + +const architectureSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.architectureCheck'); +harness.inputQueue.push(path.join('rules', 'architecture.rules.json')); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + rules: [{ id: 'forbidden-import' }], + violations: [], + warnings: [] + }) +}); +await harness.extension._test.runOperatorCommand(architectureSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Architecture Check completed.'); +assert.deepEqual( + harness.spawnCalls[1].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'architecture-check', + '--json', + '--repo', + repoRoot, + '--rules', + path.join(repoRoot, 'rules', 'architecture.rules.json') + ] +); + +const impactSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.impact'); +harness.inputQueue.push(''); +harness.inputQueue.push('src/app.ts'); +harness.inputQueue.push('2'); +harness.quickPickQueue.push((items) => items.find((item) => item.value === 'downstream')); +harness.quickPickQueue.push(null); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + direction: 'downstream', + depth: 2, + impacted: [{ ref: { type: 'file', path: 'src/app.ts' }, witnessPath: { nodes: [{ path: 'src/app.ts' }] } }], + warnings: [], + truncation: [] + }) +}); +await harness.extension._test.runOperatorCommand(impactSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Impact Analysis completed.'); +assert.deepEqual( + harness.spawnCalls[2].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'impact', + '--json', + '--repo', + repoRoot, + '--direction', + 'downstream', + '--depth', + '2', + '--changed', + 'src/app.ts' + ] +); + +const suggestTestsSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.suggestTests'); +harness.inputQueue.push('src/app.ts'); +harness.inputQueue.push('7'); +harness.quickPickQueue.push(null); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + suggestions: [{ testPath: 'tests/app.test.ts', score: 0.9 }], + warnings: [] + }) +}); +await harness.extension._test.runOperatorCommand(suggestTestsSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Suggest Tests completed.'); +assert.deepEqual( + harness.spawnCalls[3].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'suggest-tests', + '--json', + '--repo', + repoRoot, + '--max', + '7', + '--changed', + 'src/app.ts' + ] +); + +const workspaceStatusSpec = harness.extension._test.OPERATOR_COMMAND_SPECS.find((spec) => spec.id === 'pairofcleats.workspaceStatus'); +harness.inputQueue.push(workspacePath); +harness.queuedResults.push({ + code: 0, + stdout: JSON.stringify({ + ok: true, + workspacePath, + manifestPath: path.join(repoRoot, '.cache', 'workspace_manifest.json'), + repoSetId: 'workspace-alpha', + repos: [] + }) +}); +await harness.extension._test.runOperatorCommand(workspaceStatusSpec); +assert.equal(harness.infoMessages.pop(), 'PairOfCleats: Workspace Status completed.'); +assert.deepEqual( + harness.spawnCalls[4].args, + [ + path.join(repoRoot, 'bin', 'pairofcleats.js'), + '--trace', + 'workspace', + 'status', + '--json', + '--workspace', + workspacePath + ] +); + +assert.equal(harness.errorMessages.length, 0, `unexpected errors: ${harness.errorMessages.join('; ')}`); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /files: 4/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /suggestions: 1/i.test(event.line))); +assert.ok(harness.outputEvents.some((event) => event.kind === 'append' && /repoSetId: workspace-alpha/i.test(event.line))); + +harness.restoreGlobals(); + +console.log('vscode workflow runtime test passed'); diff --git a/tests/tooling/workspace/workspace-build-signal-contract.test.js b/tests/tooling/workspace/build-signal-contract.test.js similarity index 100% rename from tests/tooling/workspace/workspace-build-signal-contract.test.js rename to tests/tooling/workspace/build-signal-contract.test.js diff --git a/tests/tooling/workspace/workspace-build-spawn-throw-contract.test.js b/tests/tooling/workspace/build-spawn-throw-contract.test.js similarity index 100% rename from tests/tooling/workspace/workspace-build-spawn-throw-contract.test.js rename to tests/tooling/workspace/build-spawn-throw-contract.test.js diff --git a/tests/tooling/workspace/catalog-json.test.js b/tests/tooling/workspace/catalog-json.test.js deleted file mode 100644 index dddbda4c5..000000000 --- a/tests/tooling/workspace/catalog-json.test.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { getRepoCacheRoot } from '../../../tools/shared/dict-utils.js'; -import { toRealPathSync } from '../../../src/workspace/identity.js'; - -applyTestEnv(); - -const root = process.cwd(); -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-catalog-json-')); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); -const expectedFederationCacheRoot = path.resolve(tempRoot, 'workspace-cache'); -const toolPath = path.join(root, 'tools', 'workspace', 'catalog.js'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: path.join(tempRoot, 'repo-cache-root') } -}, null, 2), 'utf8'); - -const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repoRoot)); -const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); -const indexDir = path.join(buildRoot, 'index-code'); - -await fs.mkdir(path.join(indexDir), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: 'compat-build-1' -}, null, 2), 'utf8'); -await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'build-1', - buildRoot -}, null, 2), 'utf8'); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "name": "catalog fixture", - "cacheRoot": "./workspace-cache", - "repos": [ - { "root": "./repo", "alias": "sample" } - ] -}`, 'utf8'); - -const run = spawnSync( - process.execPath, - [toolPath, '--workspace', workspacePath, '--json'], - { - encoding: 'utf8', - env: { ...process.env } - } -); - -assert.equal(run.status, 0, run.stderr || run.stdout); -const payload = JSON.parse(run.stdout); - -assert.equal(payload.ok, true); -assert.equal( - toRealPathSync(payload.cacheRoots?.federationCacheRoot), - toRealPathSync(expectedFederationCacheRoot) -); -assert.equal(typeof payload.cacheRoots?.workspaceManifestPath, 'string'); -assert.ok(payload.cacheRoots.workspaceManifestPath.endsWith('.json')); -assert.equal(payload.repos.length, 1); -assert.equal(typeof payload.repos[0]?.repoId, 'string'); -assert.ok(payload.repos[0].repoId.startsWith('repo-')); -assert.ok(payload.repos[0]?.pointer, 'expected pointer/build metadata for repo'); -assert.equal(payload.repos[0]?.pointer?.buildId, 'build-1'); -assert.equal(payload.repos[0]?.pointer?.parseOk, true); -assert.equal(typeof payload.repos[0]?.pointer?.currentJsonPath, 'string'); -assert.equal( - toRealPathSync(payload.repos[0]?.repoCacheRoot), - toRealPathSync(repoCacheRoot), - 'catalog should report repo-specific cache roots' -); - -console.log('workspace catalog json test passed'); diff --git a/tests/tools/service/indexer-daemon-mode.test.js b/tests/tools/service/indexer-daemon-mode.test.js new file mode 100644 index 000000000..20c46b7c9 --- /dev/null +++ b/tests/tools/service/indexer-daemon-mode.test.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { applyTestEnv } from '../../helpers/test-env.js'; +import { runNode } from '../../helpers/run-node.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-indexer-daemon-')); +const repoRoot = path.join(tempRoot, 'repo'); +const queueDir = path.join(tempRoot, 'queue'); +const configPath = path.join(tempRoot, 'service.json'); +const scriptPath = path.join(process.cwd(), 'tools', 'service', 'indexer-service.js'); + +await fs.mkdir(repoRoot, { recursive: true }); +await fs.writeFile(path.join(repoRoot, 'main.js'), 'export const value = 1;\n'); + +await fs.writeFile( + configPath, + JSON.stringify({ + queueDir, + repos: [ + { id: 'repo', path: repoRoot, syncPolicy: 'none' } + ], + worker: { + executionMode: 'daemon', + concurrency: 1, + daemon: { + deterministic: true, + sessionNamespace: 'tests-daemon-mode', + health: { + maxJobsBeforeRecycle: 32, + probeEveryJobs: 1, + maxDictionaryEntries: 16, + maxTreeSitterEntries: 16, + maxEmbeddingWarmEntries: 16, + maxHeapUsedMb: 8192, + maxHeapGrowthMb: 8192, + maxHeapGrowthRatio: 100 + } + } + }, + queue: { + maxRetries: 0 + } + }, null, 2) +); + +const env = applyTestEnv({ + cacheRoot: tempRoot, + embeddings: 'off', + testConfig: { + indexing: { + scm: { provider: 'none' }, + typeInference: false, + typeInferenceCrossFile: false, + treeSitter: { enabled: false } + }, + tooling: { + autoEnableOnDetect: false, + lsp: { enabled: false } + } + } +}); + +const runService = (...args) => { + const result = runNode( + [scriptPath, ...args], + `indexer-service ${args.join(' ')}`, + process.cwd(), + env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } + ); + assert.equal( + result.status, + 0, + result.stderr || result.stdout || `indexer-service command failed: ${args.join(' ')}` + ); + return result; +}; + +runService('enqueue', '--config', configPath, '--repo', repoRoot, '--stage', 'stage1', '--mode', 'code'); +runService('work', '--config', configPath, '--concurrency', '1', '--json'); + +const queuePayload = JSON.parse(await fs.readFile(path.join(queueDir, 'queue.json'), 'utf8')); +const jobs = Array.isArray(queuePayload?.jobs) ? queuePayload.jobs : []; +assert.equal(jobs.length, 1, 'expected one job in queue history'); + +const [jobA] = jobs; +assert.equal(jobA.status, 'done', 'first daemon job should complete'); +assert.equal(jobA?.result?.executionMode, 'daemon', 'first job should report daemon execution'); +assert.ok(jobA?.result?.daemon?.sessionKey, 'first job should include daemon session key'); + +const firstLog = await fs.readFile(jobA.logPath, 'utf8'); +assert.match(firstLog, /\[daemon\] started /, 'daemon run should write daemon start log'); + +await fs.rm(tempRoot, { recursive: true, force: true }); + +console.log('indexer service daemon mode test passed'); diff --git a/tests/tools/service/indexer-service-daemon-mode.test.js b/tests/tools/service/indexer-service-daemon-mode.test.js deleted file mode 100644 index 0fea65ab8..000000000 --- a/tests/tools/service/indexer-service-daemon-mode.test.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { applyTestEnv } from '../../helpers/test-env.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poc-indexer-daemon-')); -const repoRoot = path.join(tempRoot, 'repo'); -const queueDir = path.join(tempRoot, 'queue'); -const configPath = path.join(tempRoot, 'service.json'); -const scriptPath = path.join(process.cwd(), 'tools', 'service', 'indexer-service.js'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, 'main.js'), 'export const value = 1;\n'); - -await fs.writeFile( - configPath, - JSON.stringify({ - queueDir, - repos: [ - { id: 'repo', path: repoRoot, syncPolicy: 'none' } - ], - worker: { - executionMode: 'daemon', - concurrency: 4, - daemon: { - deterministic: true, - sessionNamespace: 'tests-daemon-mode', - health: { - maxJobsBeforeRecycle: 32, - probeEveryJobs: 1, - maxDictionaryEntries: 16, - maxTreeSitterEntries: 16, - maxEmbeddingWarmEntries: 16, - maxHeapUsedMb: 8192, - maxHeapGrowthMb: 8192, - maxHeapGrowthRatio: 100 - } - } - }, - queue: { - maxRetries: 0 - } - }, null, 2) -); - -const env = applyTestEnv({ - cacheRoot: tempRoot, - embeddings: 'off' -}); - -const runService = (...args) => { - const result = spawnSync(process.execPath, [scriptPath, ...args], { - encoding: 'utf8', - env - }); - assert.equal( - result.status, - 0, - result.stderr || result.stdout || `indexer-service command failed: ${args.join(' ')}` - ); - return result; -}; - -runService('enqueue', '--config', configPath, '--repo', repoRoot, '--stage', 'stage1', '--mode', 'code'); -runService('enqueue', '--config', configPath, '--repo', repoRoot, '--stage', 'stage1', '--mode', 'code'); -runService('work', '--config', configPath, '--concurrency', '3', '--json'); - -const queuePayload = JSON.parse(await fs.readFile(path.join(queueDir, 'queue.json'), 'utf8')); -const jobs = Array.isArray(queuePayload?.jobs) ? queuePayload.jobs : []; -assert.equal(jobs.length, 2, 'expected two jobs in queue history'); - -const [jobA, jobB] = jobs; -assert.equal(jobA.status, 'done', 'first daemon job should complete'); -assert.equal(jobB.status, 'done', 'second daemon job should complete'); -assert.equal(jobA?.result?.executionMode, 'daemon', 'first job should report daemon execution'); -assert.equal(jobB?.result?.executionMode, 'daemon', 'second job should report daemon execution'); -assert.ok(jobA?.result?.daemon?.sessionKey, 'first job should include daemon session key'); -assert.equal( - jobA?.result?.daemon?.sessionKey, - jobB?.result?.daemon?.sessionKey, - 'daemon jobs for same repo should reuse the same session key' -); - -const firstLog = await fs.readFile(jobA.logPath, 'utf8'); -const secondLog = await fs.readFile(jobB.logPath, 'utf8'); -assert.match(firstLog, /\[daemon\] started /, 'daemon run should write daemon start log'); -assert.match(secondLog, /\[daemon\] started /, 'daemon run should write daemon start log for subsequent jobs'); - -await fs.rm(tempRoot, { recursive: true, force: true }); - -console.log('indexer service daemon mode test passed'); diff --git a/tests/tui/README.md b/tests/tui/README.md index ebd98d3fe..16380f835 100644 --- a/tests/tui/README.md +++ b/tests/tui/README.md @@ -1,3 +1,3 @@ # TUI Contract Tests -This directory contains protocol, supervisor, cancellation, install/wrapper, observability, and runtime throughput/backpressure tests for the TUI pipeline. +This directory contains protocol, supervisor, cancellation, install/wrapper, observability, frame-capture, and runtime throughput/backpressure tests for the TUI pipeline. diff --git a/tests/tui/build-verify-manifest.test.js b/tests/tui/build-verify-manifest.test.js index 48bad2613..36e56f6a0 100644 --- a/tests/tui/build-verify-manifest.test.js +++ b/tests/tui/build-verify-manifest.test.js @@ -4,8 +4,8 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { resolveTestCachePath } from '../helpers/test-cache.js'; +import { runNode } from '../helpers/run-node.js'; ensureTestingEnv(process.env); @@ -20,14 +20,16 @@ const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex'); await fsPromises.rm(distDir, { recursive: true, force: true }); await fsPromises.mkdir(distDir, { recursive: true }); -const runBuild = (args) => spawnSync(process.execPath, [buildScript, ...args], { - cwd: root, - encoding: 'utf8', - env: { +const runBuild = (args) => runNode( + [buildScript, ...args], + `tui build ${args.join(' ')}`, + root, + { ...process.env, PAIROFCLEATS_TUI_DIST_DIR: distRel - } -}); + }, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); const smoke = runBuild(['--smoke']); if (smoke.status !== 0) { diff --git a/tests/tui/cancel-propagation.test.js b/tests/tui/cancel-propagation.test.js index bfbec0cf6..4fd8faffc 100644 --- a/tests/tui/cancel-propagation.test.js +++ b/tests/tui/cancel-propagation.test.js @@ -1,53 +1,19 @@ #!/usr/bin/env node -import { ensureTestingEnv } from '../helpers/test-env.js'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { spawn } from 'node:child_process'; - -ensureTestingEnv(process.env); +import { createSupervisorProtocolFixture } from './supervisor-fixture.js'; const root = process.cwd(); -const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); const ignoreSigtermFixture = path.join(root, 'tests', 'fixtures', 'tui', 'ignore-sigterm.js'); +const supervisorEventTimeoutMs = 12000; -const child = spawn(process.execPath, [supervisorPath], { - cwd: root, - stdio: ['pipe', 'pipe', 'pipe'] -}); -child.stderr.on('data', () => {}); - -const events = []; -let carry = ''; -child.stdout.on('data', (chunk) => { - const text = `${carry}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = text.split('\n'); - carry = parts.pop() || ''; - for (const line of parts) { - const trimmed = line.trim(); - if (!trimmed) continue; - events.push(JSON.parse(trimmed)); - } -}); - -const waitFor = async (predicate, timeoutMs = 12000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const found = events.find(predicate); - if (found) return found; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('timeout waiting for supervisor event'); -}; - -const send = (payload) => { - child.stdin.write(`${JSON.stringify({ proto: 'poc.tui@1', ...payload })}\n`); -}; +const supervisor = createSupervisorProtocolFixture({ timeoutMs: supervisorEventTimeoutMs }); try { - await waitFor((event) => event.event === 'hello'); + await supervisor.waitForEvent((event) => event.event === 'hello'); const jobId = 'job-cancel-propagation'; - send({ + supervisor.send({ op: 'job:run', jobId, title: 'Cancel Propagation', @@ -56,19 +22,19 @@ try { timeoutMs: 10000 }); - await waitFor((event) => event.event === 'job:spawn' && event.jobId === jobId); - send({ op: 'job:cancel', jobId, reason: 'test_cancel' }); + await supervisor.waitForEvent((event) => event.event === 'job:spawn' && event.jobId === jobId); + supervisor.send({ op: 'job:cancel', jobId, reason: 'test_cancel' }); - const end = await waitFor((event) => event.event === 'job:end' && event.jobId === jobId); + const end = await supervisor.waitForEvent((event) => event.event === 'job:end' && event.jobId === jobId); assert.equal(end.status, 'cancelled'); assert.equal(end.exitCode, 130); - send({ op: 'shutdown', reason: 'test_complete' }); - await new Promise((resolve) => child.once('exit', resolve)); + supervisor.send({ op: 'shutdown', reason: 'test_complete' }); + await supervisor.waitForExit(); console.log('tui cancel propagation test passed'); } catch (error) { - try { child.kill('SIGKILL'); } catch {} + supervisor.kill(); console.error(error?.message || error); process.exit(1); } diff --git a/tests/tui/display-interactive-render-contract.test.js b/tests/tui/display-interactive-render-contract.test.js new file mode 100644 index 000000000..4e37ed6b3 --- /dev/null +++ b/tests/tui/display-interactive-render-contract.test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; + +import { renderDisplay } from '../../src/shared/cli/display/render.js'; +import { + applyDisplayTaskUpdate, + createDisplayState, + ensureDisplayTask +} from '../../src/shared/cli/display/state.js'; + +ensureTestingEnv(process.env); + +const state = createDisplayState(); +state.logLines.push('starting'); + +const { task } = ensureDisplayTask(state, 'files', 'Files', { + stage: 'processing', + mode: 'extracted-prose', + total: 10, + unit: 'files', + message: 'src/index.js' +}, 1000); +applyDisplayTaskUpdate(task, { current: 5, message: 'src/index.js', status: 'done' }, 2000); + +const writes = []; +const term = (chunk) => { + writes.push(String(chunk)); +}; +term.width = 96; +term.up = (count) => writes.push(``); +term.down = (count) => writes.push(``); +term.eraseLine = () => writes.push(''); + +renderDisplay({ + state, + term, + stream: { columns: 96 }, + interactive: true, + canRender: true, + colorEnabled: false, + logWindowSize: 3 +}); + +assert.equal(state.rendered, true, 'expected first render to initialize frame state'); +assert.ok(state.renderFrame.some((line) => line.includes('Extracted Prose Files')), 'expected task label in frame'); +assert.ok(state.renderFrame.some((line) => line.includes('5/10')), 'expected progress suffix in frame'); + +const writeCountAfterFirstRender = writes.length; +renderDisplay({ + state, + term, + stream: { columns: 96 }, + interactive: true, + canRender: true, + colorEnabled: false, + logWindowSize: 3 +}); + +assert.equal(writes.length, writeCountAfterFirstRender, 'unchanged frame should not rewrite rows'); + +console.log('display interactive render contract test passed'); diff --git a/tests/tui/fixtures/bench-replay.json b/tests/tui/fixtures/bench-replay.json new file mode 100644 index 000000000..8f9f7d983 --- /dev/null +++ b/tests/tui/fixtures/bench-replay.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "name": "bench-replay", + "source_mode": "replay", + "run_id": "fixture-bench-replay", + "variants": [ + { + "id": "medium-color", + "width": 100, + "height": 26, + "color": true, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-bench-replay", + "mode": "replay", + "source": "event-log", + "scope": "bench-language:large", + "connection": "replaying", + "note": "replaying retained run" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-bench-replay", + "message": "replay attached: bench-language large run" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-bench-replay", + "jobId": "repo-swift", + "title": "swift/rxhanson/Rectangle" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-bench-replay", + "jobId": "repo-swift", + "taskId": "sourcekit", + "status": "running", + "message": "semantic tokens retry 1" + } + }, + { + "event": { + "event": "runtime:metrics", + "runId": "fixture-bench-replay", + "flow": { + "queueDepth": 12 + } + } + }, + { + "event": { + "event": "log", + "runId": "fixture-bench-replay", + "message": "provider degraded: sourcekit semantic tokens timeout", + "workload": { + "kind": "bench", + "lane": "bench-language", + "tier": "large", + "activeRepo": "swift/rxhanson/Rectangle", + "degradedProviders": ["sourcekit"], + "retainedCrashCount": 1, + "unresolvedImportCount": 3, + "qualitySignals": ["low-yield", "resolver-gap"], + "artifactStall": "field_postings.json", + "timeoutState": "semantic tokens timeout" + } + } + }, + { + "capture_id": "degraded" + }, + { + "event": { + "event": "task:end", + "runId": "fixture-bench-replay", + "jobId": "repo-swift", + "taskId": "sourcekit", + "status": "failed", + "message": "timeout -> fail-open" + } + }, + { + "event": { + "event": "job:end", + "runId": "fixture-bench-replay", + "jobId": "repo-swift", + "status": "failed" + } + }, + { + "capture_id": "failed" + } + ] +} diff --git a/tests/tui/fixtures/external-observability.json b/tests/tui/fixtures/external-observability.json new file mode 100644 index 000000000..7d67b2cc2 --- /dev/null +++ b/tests/tui/fixtures/external-observability.json @@ -0,0 +1,47 @@ +{ + "schema_version": 1, + "name": "external-observability", + "source_mode": "external-observability", + "run_id": "fixture-observability", + "variants": [ + { + "id": "medium-color", + "width": 96, + "height": 24, + "color": true, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-observability", + "mode": "external-observability", + "source": "passive-stream", + "scope": "bench-language:shared-log", + "connection": "passive", + "note": "stream attached without derived jobs" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-observability", + "message": "attached to external observability stream" + } + }, + { + "event": { + "event": "runtime:metrics", + "runId": "fixture-observability", + "flow": { + "queueDepth": 3 + } + } + }, + { + "capture_id": "logs-only" + } + ] +} diff --git a/tests/tui/fixtures/indexing-summary.json b/tests/tui/fixtures/indexing-summary.json new file mode 100644 index 000000000..9982fc907 --- /dev/null +++ b/tests/tui/fixtures/indexing-summary.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "name": "indexing-summary", + "source_mode": "supervised", + "run_id": "fixture-indexing-summary", + "variants": [ + { + "id": "medium-color", + "width": 100, + "height": 26, + "color": true, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-indexing-summary", + "mode": "supervised", + "source": "local-supervisor", + "scope": "repo:fixtures/indexing", + "connection": "connected", + "note": "index build attached" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-indexing-summary", + "jobId": "job-index", + "title": "Index fixtures/indexing" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-indexing-summary", + "jobId": "job-index", + "taskId": "sqlite", + "stage": "sqlite", + "mode": "code", + "status": "running", + "message": "building sqlite sidecar", + "workload": { + "kind": "indexing", + "repo": "fixtures/indexing", + "stage": "sqlite", + "mode": "code", + "heartbeatAgeMs": 1200, + "artifactWriteProgress": "7/12 families", + "sqliteState": "building sidecar", + "validationState": "queued", + "promotionState": "pending" + } + } + }, + { + "capture_id": "sqlite-active" + } + ] +} diff --git a/tests/tui/fixtures/navigation-scroll.json b/tests/tui/fixtures/navigation-scroll.json new file mode 100644 index 000000000..e4818de17 --- /dev/null +++ b/tests/tui/fixtures/navigation-scroll.json @@ -0,0 +1,125 @@ +{ + "schema_version": 1, + "name": "navigation-scroll", + "source_mode": "supervised", + "run_id": "fixture-navigation", + "variants": [ + { + "id": "medium-color", + "width": 100, + "height": 24, + "color": true, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-navigation", + "mode": "supervised", + "source": "local-supervisor", + "scope": "repo:fixtures/navigation", + "connection": "connected", + "note": "navigation fixture" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-navigation", + "jobId": "job-a", + "title": "alpha" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-navigation", + "jobId": "job-b", + "title": "beta" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-navigation", + "jobId": "job-c", + "title": "gamma" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-navigation", + "jobId": "job-c", + "taskId": "task-1", + "status": "running", + "message": "phase 1" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-navigation", + "jobId": "job-c", + "taskId": "task-2", + "status": "running", + "message": "phase 2" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-navigation", + "jobId": "job-b", + "taskId": "task-1", + "status": "running", + "message": "waiting" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-navigation", + "message": "log line 1" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-navigation", + "message": "log line 2" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-navigation", + "message": "log line 3" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-navigation", + "message": "log line 4" + } + }, + { + "capture_id": "before-scroll" + }, + { + "input": "logs_up" + }, + { + "input": "jobs_up" + }, + { + "selected_job": "job-b" + }, + { + "capture_id": "after-scroll" + } + ] +} diff --git a/tests/tui/fixtures/operator-workflows.json b/tests/tui/fixtures/operator-workflows.json new file mode 100644 index 000000000..cdb74a4a9 --- /dev/null +++ b/tests/tui/fixtures/operator-workflows.json @@ -0,0 +1,156 @@ +{ + "schema_version": 1, + "name": "operator-workflows", + "source_mode": "supervised", + "run_id": "fixture-operator-workflows", + "variants": [ + { + "id": "medium-color", + "width": 100, + "height": 28, + "color": true, + "unicode": true + }, + { + "id": "narrow-no-color", + "width": 80, + "height": 24, + "color": false, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-operator-workflows", + "mode": "supervised", + "source": "local-supervisor", + "scope": "repo:fixtures/operator-workflows", + "connection": "connected", + "note": "operator workflows loaded" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-operator-workflows", + "jobId": "job-a", + "title": "Build Index" + } + }, + { + "event": { + "event": "task:start", + "runId": "fixture-operator-workflows", + "jobId": "job-a", + "taskId": "analyze", + "status": "running", + "message": "warming analyzers" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-operator-workflows", + "level": "warn", + "message": "watchdog warning: sourcekit slow-start" + } + }, + { + "event": { + "event": "job:end", + "runId": "fixture-operator-workflows", + "jobId": "job-a", + "status": "failed" + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-operator-workflows", + "jobId": "job-b", + "title": "Serve API" + } + }, + { + "event": { + "event": "task:start", + "runId": "fixture-operator-workflows", + "jobId": "job-b", + "taskId": "boot", + "status": "running", + "message": "listening on :3000" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-operator-workflows", + "level": "error", + "message": "provider degraded: sourcekit timeout storm" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-operator-workflows", + "message": "background refresh complete" + } + }, + { + "capture_id": "baseline" + }, + { + "input": "focus_jobs" + }, + { + "input": "cycle_filter" + }, + { + "capture_id": "jobs-active-filter" + }, + { + "input": "focus_logs" + }, + { + "input": "cycle_filter" + }, + { + "capture_id": "logs-warn-filter" + }, + { + "input": "search_open" + }, + { + "input": "search_type:sourcekit" + }, + { + "input": "search_apply" + }, + { + "capture_id": "logs-search" + }, + { + "input": "toggle_follow" + }, + { + "capture_id": "follow-paused" + }, + { + "input": "toggle_help" + }, + { + "capture_id": "help-overlay" + }, + { + "input": "toggle_help" + }, + { + "input": "toggle_palette" + }, + { + "capture_id": "palette-open" + } + ] +} diff --git a/tests/tui/fixtures/service-summary.json b/tests/tui/fixtures/service-summary.json new file mode 100644 index 000000000..fe0c2816e --- /dev/null +++ b/tests/tui/fixtures/service-summary.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "name": "service-summary", + "source_mode": "supervised", + "run_id": "fixture-service-summary", + "variants": [ + { + "id": "medium-color", + "width": 100, + "height": 26, + "color": true, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-service-summary", + "mode": "supervised", + "source": "local-supervisor", + "scope": "service:indexer", + "connection": "connected", + "note": "service control attached" + } + }, + { + "event": { + "event": "runtime:metrics", + "runId": "fixture-service-summary", + "flow": { + "queueDepth": 7 + }, + "workload": { + "kind": "service", + "service": "indexer", + "activeWorkers": 3, + "activeJobs": 2, + "retryCount": 2, + "quarantineState": "repo/docs-42", + "shutdownState": "accepting", + "queueDepth": 7 + } + } + }, + { + "event": { + "event": "job:start", + "runId": "fixture-service-summary", + "jobId": "job-worker-pool", + "title": "Indexer Worker Pool" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-service-summary", + "jobId": "job-worker-pool", + "taskId": "dispatch", + "status": "running", + "message": "dispatching queued jobs" + } + }, + { + "capture_id": "service-active" + } + ] +} diff --git a/tests/tui/fixtures/supervised-session.json b/tests/tui/fixtures/supervised-session.json new file mode 100644 index 000000000..c59c829dc --- /dev/null +++ b/tests/tui/fixtures/supervised-session.json @@ -0,0 +1,104 @@ +{ + "schema_version": 1, + "name": "supervised-session", + "source_mode": "supervised", + "run_id": "fixture-supervised", + "variants": [ + { + "id": "narrow-color", + "width": 80, + "height": 24, + "color": true, + "unicode": true + }, + { + "id": "wide-no-color", + "width": 120, + "height": 28, + "color": false, + "unicode": true + } + ], + "steps": [ + { + "event": { + "event": "session:attach", + "runId": "fixture-supervised", + "mode": "supervised", + "source": "local-supervisor", + "scope": "repo:fixtures/supervised", + "connection": "connected", + "note": "supervisor ready" + } + }, + { + "capture_id": "startup" + }, + { + "event": { + "event": "job:start", + "runId": "fixture-supervised", + "jobId": "job-index", + "title": "Index Repo" + } + }, + { + "event": { + "event": "task:start", + "runId": "fixture-supervised", + "jobId": "job-index", + "taskId": "discover", + "status": "running", + "message": "discovering repository" + } + }, + { + "event": { + "event": "task:progress", + "runId": "fixture-supervised", + "jobId": "job-index", + "taskId": "discover", + "status": "running", + "message": "files=128" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-supervised", + "message": "indexing active: chunk assembly stable" + } + }, + { + "capture_id": "active" + }, + { + "event": { + "event": "task:end", + "runId": "fixture-supervised", + "jobId": "job-index", + "taskId": "discover", + "status": "done", + "message": "completed" + } + }, + { + "event": { + "event": "job:end", + "runId": "fixture-supervised", + "jobId": "job-index", + "status": "done" + } + }, + { + "event": { + "event": "log", + "runId": "fixture-supervised", + "message": "indexing completed successfully" + } + }, + { + "capture_id": "complete" + } + ] +} diff --git a/tests/tui/flow-credit-no-metrics-echo.test.js b/tests/tui/flow-credit-no-metrics-echo.test.js new file mode 100644 index 000000000..55681b596 --- /dev/null +++ b/tests/tui/flow-credit-no-metrics-echo.test.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import { createSupervisorSession } from '../helpers/supervisor-session.js'; + +ensureTestingEnv(process.env); + +const { events, waitForEvent, send, shutdown, forceKill } = createSupervisorSession(); + +try { + await waitForEvent((event) => event.event === 'hello'); + await waitForEvent((event) => event.event === 'runtime:metrics'); + const initialCount = events.filter((event) => event.event === 'runtime:metrics').length; + + send({ op: 'flow:credit', credits: 64 }); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const nextCount = events.filter((event) => event.event === 'runtime:metrics').length; + assert.equal( + nextCount, + initialCount, + 'flow:credit should not trigger an immediate extra runtime:metrics echo' + ); + + await shutdown(); + console.log('tui flow credit no metrics echo test passed'); +} catch (error) { + forceKill(); + console.error(error?.message || error); + process.exit(1); +} diff --git a/tests/tui/frame-capture-harness.test.js b/tests/tui/frame-capture-harness.test.js new file mode 100644 index 000000000..a13aa0ea4 --- /dev/null +++ b/tests/tui/frame-capture-harness.test.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { runNode } from '../helpers/run-node.js'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const captureScript = path.join(root, 'tools', 'tui', 'capture-fixtures.js'); +const outputRoot = path.join(root, '.testLogs', 'tui', 'frame-capture-test'); +await fsPromises.rm(outputRoot, { recursive: true, force: true }); + +const result = runNode( + [captureScript, '--out-dir', outputRoot], + 'tui frame capture harness', + root, + process.env, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); + +if (result.status !== 0) { + console.error('tui frame capture harness test failed: capture script exited non-zero'); + if (result.stdout) console.error(result.stdout.trim()); + if (result.stderr) console.error(result.stderr.trim()); + process.exit(result.status ?? 1); +} + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); +const readText = (filePath) => fs.readFileSync(filePath, 'utf8'); +const readFixtureManifest = (fixtureName) => + readJson(path.join(outputRoot, fixtureName, 'capture-manifest.json')); +const getCapturePaths = (fixtureManifest, captureId, variantId) => { + const output = fixtureManifest.outputs.find( + (entry) => entry.capture_id === captureId && entry.variant_id === variantId + ); + assert(output, `missing capture ${captureId}/${variantId} in ${fixtureManifest.fixture_name}`); + return output; +}; + +const supervisedManifest = readFixtureManifest('supervised-session'); +assert.equal(supervisedManifest.fixture_name, 'supervised-session'); +assert(supervisedManifest.outputs.length >= 6, 'expected multiple supervised outputs'); + +const startupFramePath = getCapturePaths( + supervisedManifest, + 'startup', + 'narrow-color' +).frame_path; +const startupFrame = readText(startupFramePath); +assert.match(startupFrame, /Session/); +assert.match(startupFrame, /Operator/); +assert.match(startupFrame, /Workload|Indexing/); +assert.match(startupFrame, /Hints/); +assert.match(startupFrame, /Jobs/); +assert.match(startupFrame, /mode supervised/); +assert.match(startupFrame, /no supervised jobs|supervisor ready/); +assert.doesNotMatch(startupFrame, /\{\"connection\"/); + +const activeMetaPath = getCapturePaths( + supervisedManifest, + 'active', + 'narrow-color' +).metadata_path; +const activeMeta = readJson(activeMetaPath); +assert.equal(activeMeta.source_mode, 'supervised'); +assert.equal(activeMeta.session_mode, 'supervised'); +assert.equal(activeMeta.session_source, 'local-supervisor'); +assert.equal(activeMeta.selected_job, 'job-index'); +assert(activeMeta.non_default_style_cells > 0, 'expected styled job rows in color capture'); + +const noColorMetaPath = getCapturePaths( + supervisedManifest, + 'active', + 'wide-no-color' +).metadata_path; +const noColorMeta = readJson(noColorMetaPath); +assert.equal(noColorMeta.color, false); +assert.equal(noColorMeta.non_default_style_cells, 0, 'no-color variant should avoid styled cells'); + +const replayManifest = readFixtureManifest('bench-replay'); +const replayFrame = readText( + getCapturePaths(replayManifest, 'degraded', 'medium-color').frame_path +); +assert.match(replayFrame, /mode replay/); +assert.match(replayFrame, /Bench/); +assert.match(replayFrame, /lane bench-language/); +assert.match(replayFrame, /tier large/); +assert.match(replayFrame, /degraded sourcekit/); +assert.match(replayFrame, /stall fie/); +assert.match(replayFrame, /sourcekit/); +assert.match(replayFrame, /provider degraded/); +assert.doesNotMatch(replayFrame, /\{\"event\"/); + +const observabilityManifest = readFixtureManifest('external-observability'); +const observabilityFrame = readText( + getCapturePaths(observabilityManifest, 'logs-only', 'medium-color').frame_path +); +assert.match(observabilityFrame, /mode external-observability/); +assert.match(observabilityFrame, /external stream without/); +assert.match(observabilityFrame, /attached to external observability/); +assert.doesNotMatch(observabilityFrame, /\{\"event\"/); + +const navigationManifest = readFixtureManifest('navigation-scroll'); +const navigationBefore = readJson( + getCapturePaths(navigationManifest, 'before-scroll', 'medium-color').metadata_path +); +const navigationAfter = readJson( + getCapturePaths(navigationManifest, 'after-scroll', 'medium-color').metadata_path +); +assert.equal(navigationBefore.job_scroll, 0); +assert.equal(navigationAfter.job_scroll, 1); +assert.equal(navigationAfter.log_scroll, 1); +assert.equal(navigationAfter.selected_job, 'job-b'); + +const operatorManifest = readFixtureManifest('operator-workflows'); +const operatorBaseline = readText( + getCapturePaths(operatorManifest, 'baseline', 'medium-color').frame_path +); +assert.match(operatorBaseline, /Operator/); +assert.match(operatorBaseline, /focus jobs/); +assert.match(operatorBaseline, /follow live/); + +const jobsFilterFrame = readText( + getCapturePaths(operatorManifest, 'jobs-active-filter', 'medium-color').frame_path +); +assert.match(jobsFilterFrame, /Jobs \* \| filter active/); +assert.match(jobsFilterFrame, /job-b \| running \| Serve A/); +assert.doesNotMatch(jobsFilterFrame, /^│job-a \| failed/m); + +const logFilterFrame = readText( + getCapturePaths(operatorManifest, 'logs-warn-filter', 'medium-color').frame_path +); +assert.match(logFilterFrame, /Logs \* \| filter warn\+\/all/); +assert.match(logFilterFrame, /warn \| supervisor \| watchdog warning/); +assert.match(logFilterFrame, /error \| supervisor \| provider degraded/); +assert.doesNotMatch(logFilterFrame, /background refresh complete/); + +const searchFrame = readText( + getCapturePaths(operatorManifest, 'logs-search', 'medium-color').frame_path +); +assert.match(searchFrame, /search sourcekit/); +assert.match(searchFrame, /sourcekit/); +assert.doesNotMatch(searchFrame, /background refresh complete/); + +const pausedFrame = readText( + getCapturePaths(operatorManifest, 'follow-paused', 'medium-color').frame_path +); +assert.match(pausedFrame, /follow paused/); + +const helpFrame = readText( + getCapturePaths(operatorManifest, 'help-overlay', 'medium-color').frame_path +); +assert.match(helpFrame, /Operator Help/); +assert.match(helpFrame, /Tab switch focus panels/); + +const paletteFrame = readText( + getCapturePaths(operatorManifest, 'palette-open', 'medium-color').frame_path +); +assert.match(paletteFrame, /Actions/); +assert.match(paletteFrame, /Toggle follow \/ pause/); + +const indexingManifest = readFixtureManifest('indexing-summary'); +const indexingFrame = readText( + getCapturePaths(indexingManifest, 'sqlite-active', 'medium-color').frame_path +); +assert.match(indexingFrame, /Indexing/); +assert.match(indexingFrame, /repo fixtures\/indexing/); +assert.match(indexingFrame, /stage sqlite/); +assert.match(indexingFrame, /mode code/); +assert.match(indexingFrame, /sqlite building sidecar/); + +const serviceManifest = readFixtureManifest('service-summary'); +const serviceFrame = readText( + getCapturePaths(serviceManifest, 'service-active', 'medium-color').frame_path +); +assert.match(serviceFrame, /Service/); +assert.match(serviceFrame, /service indexer/); +assert.match(serviceFrame, /workers 3/); +assert.match(serviceFrame, /jobs 2/); +assert.match(serviceFrame, /queue 7/); +assert.match(serviceFrame, /retries 2/); +assert.match(serviceFrame, /quarantine repo\/docs-42/); +assert.match(serviceFrame, /shutdown a/); + +console.log('tui frame capture harness test passed'); diff --git a/tests/tui/headless-smoke.test.js b/tests/tui/headless-smoke.test.js index 359e20bd7..9abcd93fc 100644 --- a/tests/tui/headless-smoke.test.js +++ b/tests/tui/headless-smoke.test.js @@ -2,7 +2,7 @@ import { ensureTestingEnv } from '../helpers/test-env.js'; import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { runNode } from '../helpers/run-node.js'; ensureTestingEnv(process.env); @@ -16,14 +16,16 @@ const checksumPath = `${manifestPath}.sha256`; fs.rmSync(testDistDir, { recursive: true, force: true }); fs.mkdirSync(invokeCwd, { recursive: true }); -const result = spawnSync(process.execPath, [buildScript, '--smoke'], { - cwd: invokeCwd, - encoding: 'utf8', - env: { +const result = runNode( + [buildScript, '--smoke'], + 'tui headless smoke build', + invokeCwd, + { ...process.env, PAIROFCLEATS_TUI_DIST_DIR: testDistRel - } -}); + }, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } +); if (result.status !== 0) { console.error('tui headless smoke test failed: build script exited non-zero'); diff --git a/tests/tui/install-layout-default-root.test.js b/tests/tui/install-layout-default-root.test.js new file mode 100644 index 000000000..ad03fc3bb --- /dev/null +++ b/tests/tui/install-layout-default-root.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { getCacheRoot } from '../../src/shared/cache-roots.js'; +import { + TUI_INSTALL_LAYOUT_DIR, + resolveTuiInstallLayout +} from '../../tools/tui/targets.js'; + +ensureTestingEnv(process.env); + +const previousCacheRoot = process.env.PAIROFCLEATS_CACHE_ROOT; +const tempCacheRoot = path.join(os.tmpdir(), `poc-tui-cache-${process.pid}`); +process.env.PAIROFCLEATS_CACHE_ROOT = tempCacheRoot; + +try { + const layout = resolveTuiInstallLayout({ + root: process.cwd(), + triple: 'x86_64-pc-windows-msvc', + artifactName: 'pairofcleats-tui.exe' + }); + const expectedInstallRoot = path.join(getCacheRoot(), 'tui', TUI_INSTALL_LAYOUT_DIR); + assert.equal(layout.baseInstallRoot, expectedInstallRoot); + assert.equal( + layout.baseInstallRoot.includes(`${path.sep}.cache${path.sep}`), + false, + 'default TUI install root should not fall back to repo-local .cache' + ); +} finally { + if (typeof previousCacheRoot === 'string') { + process.env.PAIROFCLEATS_CACHE_ROOT = previousCacheRoot; + } else { + delete process.env.PAIROFCLEATS_CACHE_ROOT; + } +} + +console.log('tui install layout default root test passed'); diff --git a/tests/tui/installer-unit.test.js b/tests/tui/installer-unit.test.js index 869cbfd28..842a82b80 100644 --- a/tests/tui/installer-unit.test.js +++ b/tests/tui/installer-unit.test.js @@ -6,9 +6,9 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { stableStringify } from '../../src/shared/stable-json.js'; import { resolveHostTargetTriple, resolveTargetForTriple, readTargetsManifestSync } from '../../tools/tui/targets.js'; +import { runNode } from '../helpers/run-node.js'; ensureTestingEnv(process.env); @@ -75,17 +75,15 @@ try { ); installRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tui-install-')); - const runInstaller = () => spawnSync( - process.execPath, + const runInstaller = () => runNode( [installScript, '--json', '--target', triple, '--install-root', installRoot], + 'tui installer', + invokeCwd, { - cwd: invokeCwd, - encoding: 'utf8', - env: { - ...process.env, - PAIROFCLEATS_TUI_DIST_DIR: testDistRel - } - } + ...process.env, + PAIROFCLEATS_TUI_DIST_DIR: testDistRel + }, + { stdio: 'pipe', encoding: 'utf8', allowFailure: true } ); const result = runInstaller(); diff --git a/tests/tui/local-rust-verification-docs.test.js b/tests/tui/local-rust-verification-docs.test.js new file mode 100644 index 000000000..ce9a08450 --- /dev/null +++ b/tests/tui/local-rust-verification-docs.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const guidePath = path.join(root, 'docs', 'guides', 'tui.md'); + +const guide = fs.readFileSync(guidePath, 'utf8'); +const requiredLines = [ + 'cargo fmt --check --manifest-path .\\crates\\pairofcleats-tui\\Cargo.toml', + 'cargo check --locked --manifest-path .\\crates\\pairofcleats-tui\\Cargo.toml', + 'cargo test --locked --manifest-path .\\crates\\pairofcleats-tui\\Cargo.toml', + 'cargo clippy --locked --manifest-path .\\crates\\pairofcleats-tui\\Cargo.toml -- -D warnings' +]; + +for (const line of requiredLines) { + if (!guide.includes(line)) { + console.error(`tui local rust verification docs test failed: missing "${line}"`); + process.exit(1); + } +} + +console.log('tui local rust verification docs test passed'); diff --git a/tests/tui/observability/replay-determinism.test.js b/tests/tui/observability/replay-determinism.test.js index fb8b0bd79..0234f10ff 100644 --- a/tests/tui/observability/replay-determinism.test.js +++ b/tests/tui/observability/replay-determinism.test.js @@ -1,78 +1,41 @@ #!/usr/bin/env node -import { ensureTestingEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { + createSupervisorFixture, + pathExists, + readTextLines, + resolveEventLogPath +} from './supervisor-fixture.js'; -ensureTestingEnv(process.env); - -const root = process.cwd(); -const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); -const logDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tui-replay-')); const runId = `run-replay-${process.pid}`; - -const child = spawn(process.execPath, [supervisorPath], { - cwd: root, - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - PAIROFCLEATS_TUI_EVENT_LOG_DIR: logDir, - PAIROFCLEATS_TUI_RUN_ID: runId - } -}); -child.stderr.on('data', () => {}); - -const stdoutLines = []; -let carry = ''; -child.stdout.on('data', (chunk) => { - const text = `${carry}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = text.split('\n'); - carry = parts.pop() || ''; - for (const line of parts) { - const trimmed = line.trim(); - if (!trimmed) continue; - stdoutLines.push(trimmed); - } -}); - -const waitForLine = async (predicate, timeoutMs = 12000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const found = stdoutLines.find((line) => predicate(JSON.parse(line))); - if (found) return found; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('timeout waiting for supervisor line'); -}; - -const send = (payload) => { - child.stdin.write(`${JSON.stringify({ proto: 'poc.tui@1', ...payload })}\n`); -}; - +let fixture; try { - await waitForLine((event) => event.event === 'hello'); + fixture = await createSupervisorFixture({ + tempPrefix: 'poc-tui-replay-', + runId + }); + + await fixture.waitForEvent((event) => event.event === 'hello'); const jobId = 'job-replay-1'; - send({ + fixture.send({ op: 'job:run', jobId, title: 'replay', command: process.execPath, args: ['-e', 'console.log("alpha"); console.error("beta");'] }); - await waitForLine((event) => event.event === 'job:end' && event.jobId === jobId); - send({ op: 'shutdown', reason: 'test_complete' }); - await new Promise((resolve) => child.once('exit', resolve)); + await fixture.waitForEvent((event) => event.event === 'job:end' && event.jobId === jobId); + await fixture.shutdown(); - const replayPath = path.join(logDir, `${runId}.jsonl`); - assert.equal(fs.existsSync(replayPath), true, 'expected replay log file'); - const replayLines = fs.readFileSync(replayPath, 'utf8').split(/\r?\n/).filter(Boolean); - assert.deepEqual(replayLines, stdoutLines, 'replay log must match emitted protocol stream exactly'); + const replayPath = resolveEventLogPath(fixture); + assert.equal(pathExists(replayPath), true, 'expected replay log file'); + assert.deepEqual( + readTextLines(replayPath), + fixture.stdoutLines, + 'replay log must match emitted protocol stream exactly' + ); console.log('tui observability replay determinism test passed'); } finally { - try { child.kill('SIGKILL'); } catch {} - await fsPromises.rm(logDir, { recursive: true, force: true }); + await fixture?.cleanup(); } diff --git a/tests/tui/observability/run-id-path-safety.test.js b/tests/tui/observability/run-id-path-safety.test.js index 183675b07..a8300207f 100644 --- a/tests/tui/observability/run-id-path-safety.test.js +++ b/tests/tui/observability/run-id-path-safety.test.js @@ -1,81 +1,51 @@ #!/usr/bin/env node -import { ensureTestingEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { + createSupervisorFixture, + findLogArtifacts, + pathExists, + readJsonFile, + removePath, + resolveLogArtifactPath, + resolveMetadataEventLogPath, + resolveSiblingPath +} from './supervisor-fixture.js'; -ensureTestingEnv(process.env); - -const root = process.cwd(); -const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); -const logDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tui-runid-safe-')); const runId = '../escape-run'; -const escapedPath = path.resolve(logDir, '..', 'escape-run.jsonl'); -await fsPromises.rm(escapedPath, { force: true }); - -const child = spawn(process.execPath, [supervisorPath], { - cwd: root, - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - PAIROFCLEATS_TUI_EVENT_LOG_DIR: logDir, - PAIROFCLEATS_TUI_RUN_ID: runId - } -}); - -const events = []; -let carry = ''; -child.stdout.on('data', (chunk) => { - const text = `${carry}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = text.split('\n'); - carry = parts.pop() || ''; - for (const line of parts) { - const trimmed = line.trim(); - if (!trimmed) continue; - events.push(JSON.parse(trimmed)); - } -}); -child.stderr.on('data', () => {}); - -const waitFor = async (predicate, timeoutMs = 12000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const found = events.find(predicate); - if (found) return found; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('timeout waiting for supervisor event'); -}; +let escapedPath = ''; +let fixture; try { - await waitFor((event) => event.event === 'hello'); - child.stdin.write(`${JSON.stringify({ proto: 'poc.tui@1', op: 'shutdown', reason: 'test_complete' })}\n`); - await new Promise((resolve) => child.once('exit', resolve)); - - assert.equal(fs.existsSync(escapedPath), false, 'runId traversal should not write outside log dir'); - - const entries = await fsPromises.readdir(logDir); - const jsonl = entries.find((entry) => entry.endsWith('.jsonl')); - const meta = entries.find((entry) => entry.endsWith('.meta.json')); + fixture = await createSupervisorFixture({ + tempPrefix: 'poc-tui-runid-safe-', + runId, + beforeStart: async ({ logDir }) => { + escapedPath = resolveSiblingPath(logDir, 'escape-run.jsonl'); + await removePath(escapedPath); + } + }); + await fixture.waitForEvent((event) => event.event === 'hello'); + await fixture.shutdown(); + + assert.equal(pathExists(escapedPath), false, 'runId traversal should not write outside log dir'); + + const { jsonl, meta } = await findLogArtifacts(fixture.logDir); assert.ok(jsonl, 'expected event log file in configured log dir'); assert.ok(meta, 'expected session metadata file in configured log dir'); - const metaBody = JSON.parse(await fsPromises.readFile(path.join(logDir, meta), 'utf8')); + const metaBody = await readJsonFile(resolveLogArtifactPath(fixture.logDir, meta)); assert.equal(metaBody.runId, runId, 'metadata should preserve logical runId'); - const eventLogPath = String(metaBody.eventLogPath || ''); - const resolvedEventLogPath = path.resolve(root, eventLogPath.replace(/\//g, path.sep)); assert.equal( - resolvedEventLogPath, - path.join(logDir, jsonl), + resolveMetadataEventLogPath(metaBody.eventLogPath), + resolveLogArtifactPath(fixture.logDir, jsonl), 'metadata should reference the event log file created under the configured log dir' ); console.log('tui run id path safety test passed'); } finally { - try { child.kill('SIGKILL'); } catch {} - await fsPromises.rm(escapedPath, { force: true }); - await fsPromises.rm(logDir, { recursive: true, force: true }); + if (fixture) { + await fixture.cleanup({ extraPaths: [escapedPath] }); + } else if (escapedPath) { + await removePath(escapedPath); + } } diff --git a/tests/tui/observability/session-correlation.test.js b/tests/tui/observability/session-correlation.test.js index 5ee7073ed..175728d30 100644 --- a/tests/tui/observability/session-correlation.test.js +++ b/tests/tui/observability/session-correlation.test.js @@ -1,88 +1,49 @@ #!/usr/bin/env node -import { ensureTestingEnv } from '../../helpers/test-env.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { + createSupervisorFixture, + pathExists, + readJsonFile, + readJsonlEvents, + resolveEventLogPath, + resolveMetaPath +} from './supervisor-fixture.js'; -ensureTestingEnv(process.env); - -const root = process.cwd(); -const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); -const logDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tui-observe-')); const runId = `run-correlation-${process.pid}`; - -const child = spawn(process.execPath, [supervisorPath], { - cwd: root, - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - PAIROFCLEATS_TUI_EVENT_LOG_DIR: logDir, - PAIROFCLEATS_TUI_RUN_ID: runId - } -}); -child.stderr.on('data', () => {}); - -const events = []; -let carry = ''; -child.stdout.on('data', (chunk) => { - const text = `${carry}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = text.split('\n'); - carry = parts.pop() || ''; - for (const line of parts) { - const trimmed = line.trim(); - if (!trimmed) continue; - events.push(JSON.parse(trimmed)); - } -}); - -const waitFor = async (predicate, timeoutMs = 12000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const found = events.find(predicate); - if (found) return found; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('timeout waiting for supervisor event'); -}; - -const send = (payload) => { - child.stdin.write(`${JSON.stringify({ proto: 'poc.tui@1', ...payload })}\n`); -}; - +let fixture; try { - await waitFor((event) => event.event === 'hello'); + fixture = await createSupervisorFixture({ + tempPrefix: 'poc-tui-observe-', + runId + }); + + await fixture.waitForEvent((event) => event.event === 'hello'); const jobId = 'job-observe-1'; - send({ + fixture.send({ op: 'job:run', jobId, title: 'observability', command: process.execPath, args: ['-e', 'console.log("hello");'] }); - await waitFor((event) => event.event === 'job:end' && event.jobId === jobId); - send({ op: 'shutdown', reason: 'test_complete' }); - await new Promise((resolve) => child.once('exit', resolve)); + await fixture.waitForEvent((event) => event.event === 'job:end' && event.jobId === jobId); + await fixture.shutdown(); - const eventLogPath = path.join(logDir, `${runId}.jsonl`); - const metaPath = path.join(logDir, `${runId}.meta.json`); - assert.equal(fs.existsSync(eventLogPath), true, 'expected replay event log'); - assert.equal(fs.existsSync(metaPath), true, 'expected replay metadata'); + const eventLogPath = resolveEventLogPath(fixture); + const metaPath = resolveMetaPath(fixture); + assert.equal(pathExists(eventLogPath), true, 'expected replay event log'); + assert.equal(pathExists(metaPath), true, 'expected replay metadata'); - const logLines = fs.readFileSync(eventLogPath, 'utf8').split(/\r?\n/).filter(Boolean); - assert(logLines.length > 0, 'expected replay log lines'); - const loggedEvents = logLines.map((line) => JSON.parse(line)); + const loggedEvents = readJsonlEvents(eventLogPath); + assert(loggedEvents.length > 0, 'expected replay log lines'); for (const event of loggedEvents) { assert.equal(event.runId, runId, 'expected stable run correlation in replay log'); } - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + const meta = await readJsonFile(metaPath); assert.equal(meta.runId, runId, 'metadata must carry runId'); console.log('tui observability session correlation test passed'); } finally { - try { child.kill('SIGKILL'); } catch {} - await fsPromises.rm(logDir, { recursive: true, force: true }); + await fixture?.cleanup(); } diff --git a/tests/tui/observability/supervisor-fixture.js b/tests/tui/observability/supervisor-fixture.js new file mode 100644 index 000000000..5b25dd204 --- /dev/null +++ b/tests/tui/observability/supervisor-fixture.js @@ -0,0 +1,138 @@ +import { ensureTestingEnv } from '../../helpers/test-env.js'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +ensureTestingEnv(process.env); + +const PROTOCOL = 'poc.tui@1'; +const POLL_INTERVAL_MS = 20; + +export const root = process.cwd(); + +const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const splitProtocolLines = (state, chunk) => { + const normalized = `${state.pending}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const parts = normalized.split('\n'); + state.pending = parts.pop() || ''; + return parts.map((line) => line.trim()).filter(Boolean); +}; + +const createProtocolCapture = (stream) => { + const state = { pending: '' }; + const lines = []; + const events = []; + stream.on('data', (chunk) => { + for (const line of splitProtocolLines(state, chunk)) { + lines.push(line); + events.push(JSON.parse(line)); + } + }); + return { events, lines }; +}; + +const waitForChildExit = (child) => { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve) => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); +}; + +export const createSupervisorFixture = async ({ tempPrefix, runId, beforeStart } = {}) => { + const logDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), tempPrefix)); + if (typeof beforeStart === 'function') { + await beforeStart({ logDir }); + } + + const child = spawn(process.execPath, [supervisorPath], { + cwd: root, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + PAIROFCLEATS_TUI_EVENT_LOG_DIR: logDir, + PAIROFCLEATS_TUI_RUN_ID: runId + } + }); + child.stderr.on('data', () => {}); + + const capture = createProtocolCapture(child.stdout); + const waitForEvent = async ( + predicate, + { timeoutMs = 12000, message = 'timeout waiting for supervisor event' } = {} + ) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const match = capture.events.find(predicate); + if (match) return match; + await delay(POLL_INTERVAL_MS); + } + throw new Error(message); + }; + + const send = (payload) => { + child.stdin.write(`${JSON.stringify({ proto: PROTOCOL, ...payload })}\n`); + }; + + const shutdown = async (reason = 'test_complete') => { + send({ op: 'shutdown', reason }); + return await waitForChildExit(child); + }; + + const cleanup = async ({ extraPaths = [] } = {}) => { + try { child.kill('SIGKILL'); } catch {} + for (const extraPath of extraPaths.filter(Boolean)) { + await fsPromises.rm(extraPath, { recursive: true, force: true }); + } + await fsPromises.rm(logDir, { recursive: true, force: true }); + }; + + return { + child, + events: capture.events, + logDir, + runId, + send, + shutdown, + stdoutLines: capture.lines, + waitForEvent, + cleanup + }; +}; + +export const pathExists = (targetPath) => fs.existsSync(targetPath); + +export const resolveEventLogPath = ({ logDir, runId }) => path.join(logDir, `${runId}.jsonl`); + +export const resolveMetaPath = ({ logDir, runId }) => path.join(logDir, `${runId}.meta.json`); + +export const resolveLogArtifactPath = (logDir, filename) => path.join(logDir, filename); + +export const resolveSiblingPath = (baseDir, filename) => path.resolve(baseDir, '..', filename); + +export const resolveMetadataEventLogPath = (eventLogPath) => ( + path.resolve(root, String(eventLogPath || '').replace(/\//g, path.sep)) +); + +export const readJsonFile = async (targetPath) => JSON.parse(await fsPromises.readFile(targetPath, 'utf8')); + +export const readTextLines = (targetPath) => fs.readFileSync(targetPath, 'utf8').split(/\r?\n/).filter(Boolean); + +export const readJsonlEvents = (targetPath) => readTextLines(targetPath).map((line) => JSON.parse(line)); + +export const findLogArtifacts = async (logDir) => { + const entries = await fsPromises.readdir(logDir); + return { + jsonl: entries.find((entry) => entry.endsWith('.jsonl')), + meta: entries.find((entry) => entry.endsWith('.meta.json')) + }; +}; + +export const removePath = async (targetPath) => { + await fsPromises.rm(targetPath, { recursive: true, force: true }); +}; diff --git a/tests/tui/runtime-metrics-log-suppression.test.js b/tests/tui/runtime-metrics-log-suppression.test.js new file mode 100644 index 000000000..d0a2247a4 --- /dev/null +++ b/tests/tui/runtime-metrics-log-suppression.test.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const mainPath = path.join(root, 'crates', 'pairofcleats-tui', 'src', 'main.rs'); +const source = fs.readFileSync(mainPath, 'utf8'); +const runtimeMetricsIndex = source.indexOf('if event_name == "runtime:metrics"'); +const logPushIndex = source.indexOf('model.push_log_entry(log_line, &log_level, &log_source);'); +const runtimeReturnIndex = source.indexOf('return;', runtimeMetricsIndex); + +if (runtimeMetricsIndex === -1) { + console.error('runtime metrics log suppression test failed: missing runtime metrics branch'); + process.exit(1); +} +if (logPushIndex === -1) { + console.error('runtime metrics log suppression test failed: missing generic log sink'); + process.exit(1); +} +if (runtimeReturnIndex === -1 || runtimeReturnIndex > logPushIndex) { + console.error('runtime metrics log suppression test failed: runtime metrics should bypass the generic log sink'); + process.exit(1); +} + +console.log('tui runtime metrics log suppression test passed'); diff --git a/tests/tui/session-runtime-model.test.js b/tests/tui/session-runtime-model.test.js new file mode 100644 index 000000000..5322341f7 --- /dev/null +++ b/tests/tui/session-runtime-model.test.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import { createSupervisorSession } from '../helpers/supervisor-session.js'; +import assert from 'node:assert/strict'; + +ensureTestingEnv(process.env); + +const { waitForEvent, shutdown, forceKill } = createSupervisorSession(); + +try { + const hello = await waitForEvent((event) => event.event === 'hello'); + assert.equal(hello.session?.mode, 'supervised'); + assert.equal(hello.session?.source, 'local-supervisor'); + assert.equal(hello.session?.connection, 'connected'); + assert.equal(hello.session?.controllable, true); + assert.equal(typeof hello.session?.scope, 'string'); + assert.notEqual(String(hello.session?.scope || '').trim(), '', 'expected non-empty session scope'); + + await shutdown(); + + console.log('tui session runtime model test passed'); +} catch (error) { + forceKill(); + console.error(error?.message || error); + process.exit(1); +} diff --git a/tests/tui/session-snapshot-recovery.test.js b/tests/tui/session-snapshot-recovery.test.js index e56197f7d..9b7588a40 100644 --- a/tests/tui/session-snapshot-recovery.test.js +++ b/tests/tui/session-snapshot-recovery.test.js @@ -21,5 +21,13 @@ if (!source.includes('last-state.json')) { console.error('session snapshot recovery test failed: missing canonical last-state.json snapshot target'); process.exit(1); } +if (!source.includes('PAIROFCLEATS_TUI_INSTALL_ROOT') || !source.includes('PAIROFCLEATS_CACHE_ROOT')) { + console.error('session snapshot recovery test failed: snapshot path should derive from install/cache root inputs'); + process.exit(1); +} +if (source.includes('Path::new(".cache").join("tui").join("last-state.json")')) { + console.error('session snapshot recovery test failed: snapshot path should not default to repo-local .cache'); + process.exit(1); +} console.log('tui session snapshot recovery test passed'); diff --git a/tests/tui/startup-handshake-contract.test.js b/tests/tui/startup-handshake-contract.test.js new file mode 100644 index 000000000..a14379450 --- /dev/null +++ b/tests/tui/startup-handshake-contract.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { ensureTestingEnv } from '../helpers/test-env.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +ensureTestingEnv(process.env); + +const root = process.cwd(); +const mainPath = path.join(root, 'crates', 'pairofcleats-tui', 'src', 'main.rs'); +const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); +const mainSource = fs.readFileSync(mainPath, 'utf8'); +const supervisorSource = fs.readFileSync(supervisorPath, 'utf8'); + +if (!supervisorSource.includes('emitHello({ supervisorVersion, session: buildSessionDescriptor() });')) { + console.error('startup handshake contract test failed: supervisor must emit startup hello'); + process.exit(1); +} + +if (mainSource.includes('"op": "hello"')) { + console.error('startup handshake contract test failed: TUI client should not send a redundant startup hello request'); + process.exit(1); +} + +if (!mainSource.includes('"op": "flow:credit"')) { + console.error('startup handshake contract test failed: TUI client should still pre-seed flow credits'); + process.exit(1); +} + +console.log('tui startup handshake contract test passed'); diff --git a/tests/tui/supervisor-fixture.js b/tests/tui/supervisor-fixture.js new file mode 100644 index 000000000..78cecdec8 --- /dev/null +++ b/tests/tui/supervisor-fixture.js @@ -0,0 +1,16 @@ +import { ensureTestingEnv } from '../helpers/test-env.js'; +import { createSupervisorSession } from '../helpers/supervisor-session.js'; + +ensureTestingEnv(process.env); + +export const createSupervisorProtocolFixture = (options = {}) => { + const session = createSupervisorSession(options); + return { + child: session.child, + events: session.events, + kill: session.forceKill, + send: session.send, + waitForEvent: session.waitForEvent, + waitForExit: session.waitForExit + }; +}; diff --git a/tests/tui/ui-termination-mid-job.test.js b/tests/tui/ui-termination-mid-job.test.js index 6f1dcd1a1..c202a8123 100644 --- a/tests/tui/ui-termination-mid-job.test.js +++ b/tests/tui/ui-termination-mid-job.test.js @@ -1,53 +1,19 @@ #!/usr/bin/env node -import { ensureTestingEnv } from '../helpers/test-env.js'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { spawn } from 'node:child_process'; - -ensureTestingEnv(process.env); +import { createSupervisorProtocolFixture } from './supervisor-fixture.js'; const root = process.cwd(); -const supervisorPath = path.join(root, 'tools', 'tui', 'supervisor.js'); const ignoreSigtermFixture = path.join(root, 'tests', 'fixtures', 'tui', 'ignore-sigterm.js'); +const supervisorEventTimeoutMs = 12000; -const child = spawn(process.execPath, [supervisorPath], { - cwd: root, - stdio: ['pipe', 'pipe', 'pipe'] -}); -child.stderr.on('data', () => {}); - -const events = []; -let carry = ''; -child.stdout.on('data', (chunk) => { - const text = `${carry}${String(chunk)}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = text.split('\n'); - carry = parts.pop() || ''; - for (const line of parts) { - const trimmed = line.trim(); - if (!trimmed) continue; - events.push(JSON.parse(trimmed)); - } -}); - -const waitFor = async (predicate, timeoutMs = 12000) => { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const found = events.find(predicate); - if (found) return found; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('timeout waiting for supervisor event'); -}; - -const send = (payload) => { - child.stdin.write(`${JSON.stringify({ proto: 'poc.tui@1', ...payload })}\n`); -}; +const supervisor = createSupervisorProtocolFixture({ timeoutMs: supervisorEventTimeoutMs }); try { - await waitFor((event) => event.event === 'hello'); + await supervisor.waitForEvent((event) => event.event === 'hello'); const jobId = 'job-ui-terminate'; - send({ + supervisor.send({ op: 'job:run', jobId, title: 'UI Mid Job', @@ -56,21 +22,21 @@ try { timeoutMs: 20000 }); - await waitFor((event) => event.event === 'job:spawn' && event.jobId === jobId); + await supervisor.waitForEvent((event) => event.event === 'job:spawn' && event.jobId === jobId); const shutdownStart = Date.now(); - send({ op: 'shutdown', reason: 'ui_exit' }); - await new Promise((resolve) => child.once('exit', resolve)); + supervisor.send({ op: 'shutdown', reason: 'ui_exit' }); + await supervisor.waitForExit(); const shutdownDurationMs = Date.now() - shutdownStart; assert(shutdownDurationMs < 12000, 'expected bounded shutdown duration'); - const end = events.find((event) => event.event === 'job:end' && event.jobId === jobId); + const end = supervisor.events.find((event) => event.event === 'job:end' && event.jobId === jobId); assert(end, 'expected job:end event for in-flight job during shutdown'); assert.equal(end.status, 'cancelled'); console.log('tui ui termination mid-job test passed'); } catch (error) { - try { child.kill('SIGKILL'); } catch {} + supervisor.kill(); console.error(error?.message || error); process.exit(1); } diff --git a/tests/tui/wrapper-behavior.test.js b/tests/tui/wrapper-behavior.test.js index a1f2c802f..b80d2fd53 100644 --- a/tests/tui/wrapper-behavior.test.js +++ b/tests/tui/wrapper-behavior.test.js @@ -5,12 +5,12 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { readTargetsManifestSync, resolveHostTargetTriple, resolveTargetForTriple } from '../../tools/tui/targets.js'; +import { runNode } from '../helpers/run-node.js'; ensureTestingEnv(process.env); @@ -18,14 +18,16 @@ const root = process.cwd(); const wrapperPath = path.join(root, 'bin', 'pairofcleats-tui.js'); const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-tui-wrapper-')); -const runWrapper = () => spawnSync(process.execPath, [wrapperPath], { - cwd: root, - encoding: 'utf8', - env: { +const runWrapper = () => runNode( + [wrapperPath], + 'tui wrapper behavior', + root, + { ...process.env, PAIROFCLEATS_TUI_INSTALL_ROOT: tempRoot - } -}); + }, + { stdio: 'pipe', allowFailure: true } +); try { const missingManifest = runWrapper(); diff --git a/tests/usr-full-conformance/usr-full-conformance.manifest.json b/tests/usr-full-conformance/usr-full-conformance.manifest.json new file mode 100644 index 000000000..f9bd7f4e3 --- /dev/null +++ b/tests/usr-full-conformance/usr-full-conformance.manifest.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "lane": "usr-full-conformance", + "selectionMode": "ordered-manifest", + "durationBucket": "ci", + "targetMaxDurationSeconds": 60, + "notes": "Ordered USR all-language conformance surface lane.", + "sourceOrderFile": "tests/usr-full-conformance/usr-full-conformance.order.txt", + "sourceConfigFile": "tests/runner/lane-manifests.jsonc", + "timingArtifactPaths": [], + "suiteCategorySummary": { + "hero": 10, + "matrix": 0, + "meta": 1, + "soak": 0, + "heavy-runtime": 0 + }, + "tests": [ + { + "id": "tooling/usr/full-conformance-surface", + "order": 1, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/foundation/validation", + "order": 2, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/javascript-typescript/validation", + "order": 3, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/systems-languages/validation", + "order": 4, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/managed-languages/validation", + "order": 5, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/dynamic-languages/validation", + "order": 6, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/markup-style-template/validation", + "order": 7, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/data-interface-dsl/validation", + "order": 8, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/build-infra-dsl/validation", + "order": 9, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "conformance/language-shards/cross-language-integration/validation", + "order": 10, + "suiteCategory": "hero", + "suiteCategoryReason": "default-standalone-behavior" + }, + { + "id": "tooling/docs/usr-contract-checklists", + "order": 11, + "suiteCategory": "meta", + "suiteCategoryReason": "meta-cohort-prefix" + } + ] +} diff --git a/tests/usr-full-conformance/usr-full-conformance.order.txt b/tests/usr-full-conformance/usr-full-conformance.order.txt new file mode 100644 index 000000000..a26505cc4 --- /dev/null +++ b/tests/usr-full-conformance/usr-full-conformance.order.txt @@ -0,0 +1,11 @@ +tooling/usr/full-conformance-surface +conformance/language-shards/foundation/validation +conformance/language-shards/javascript-typescript/validation +conformance/language-shards/systems-languages/validation +conformance/language-shards/managed-languages/validation +conformance/language-shards/dynamic-languages/validation +conformance/language-shards/markup-style-template/validation +conformance/language-shards/data-interface-dsl/validation +conformance/language-shards/build-infra-dsl/validation +conformance/language-shards/cross-language-integration/validation +tooling/docs/usr-contract-checklists diff --git a/tests/workspace/alias-uniqueness-and-tags-normalization.test.js b/tests/workspace/alias-uniqueness-and-tags-normalization.test.js deleted file mode 100644 index 5fc054931..000000000 --- a/tests/workspace/alias-uniqueness-and-tags-normalization.test.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadWorkspaceConfig, WORKSPACE_ERROR_CODES } from '../../src/workspace/config.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-alias-tags-')); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const duplicateAliasFile = path.join(tempRoot, 'workspace-dup-alias.jsonc'); -const normalizeTagsFile = path.join(tempRoot, 'workspace-tags.jsonc'); - -await fs.mkdir(repoA, { recursive: true }); -await fs.mkdir(repoB, { recursive: true }); -await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); -await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); - -await fs.writeFile(duplicateAliasFile, `{ - "schemaVersion": 1, - "repos": [ - { "root": "./repo-a", "alias": "Core" }, - { "root": "./repo-b", "alias": "core" } - ] -}`, 'utf8'); - -assert.throws(() => loadWorkspaceConfig(duplicateAliasFile), (error) => { - assert.equal(error.code, WORKSPACE_ERROR_CODES.DUPLICATE_ALIAS); - return true; -}); - -await fs.writeFile(normalizeTagsFile, `{ - "schemaVersion": 1, - "defaults": { "tags": [" Team ", "team", ""] }, - "repos": [ - { "root": "./repo-a", "alias": " ", "tags": ["Service", " service ", "", "CORE"] }, - { "root": "./repo-b" } - ] -}`, 'utf8'); - -const resolved = loadWorkspaceConfig(normalizeTagsFile); -assert.equal(resolved.repos[0].alias, null, 'empty alias should normalize to null'); -assert.deepEqual(resolved.repos[0].tags, ['core', 'service']); -assert.deepEqual(resolved.repos[1].tags, ['team'], 'defaults.tags should apply when entry omits tags'); - -console.log('workspace alias uniqueness and tags normalization test passed'); diff --git a/tests/workspace/build-pointer-invalid-scenarios.test.js b/tests/workspace/build-pointer-invalid-scenarios.test.js deleted file mode 100644 index be6375572..000000000 --- a/tests/workspace/build-pointer-invalid-scenarios.test.js +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; -import { generateWorkspaceManifest } from '../../src/workspace/manifest.js'; -import { toRealPathSync } from '../../src/workspace/identity.js'; -import { - createWorkspaceFixture, - removeWorkspaceFixture, - writeIndexArtifacts -} from '../helpers/workspace-fixture.js'; - -const readManifest = async (workspacePath) => { - const workspaceConfig = loadWorkspaceConfig(workspacePath); - const { manifest } = await generateWorkspaceManifest(workspaceConfig, { write: false }); - return manifest; -}; - -const hasInvalidPointerWarning = (manifest, expectedSnippet = null) => ( - Array.isArray(manifest?.diagnostics?.warnings) - && manifest.diagnostics.warnings.some((entry) => ( - entry?.code === 'WARN_WORKSPACE_INVALID_BUILD_POINTER' - && ( - !expectedSnippet - || String(entry?.message || '').includes(expectedSnippet) - ) - )) -); - -const scenarios = [ - { - name: 'absolute-buildid-points-outside-cache', - prefix: 'pairofcleats-workspace-buildid-absolute-', - setup: async ({ tempRoot, repoCacheRoot }) => { - const externalBuildRoot = path.join(tempRoot, 'external-build'); - await writeIndexArtifacts({ - buildRoot: externalBuildRoot, - compatibilityKey: 'compat-external' - }); - const buildsRoot = path.join(repoCacheRoot, 'builds'); - await fs.mkdir(buildsRoot, { recursive: true }); - await fs.writeFile(path.join(buildsRoot, 'current.json'), JSON.stringify({ - buildId: externalBuildRoot, - modes: ['code'] - }), 'utf8'); - }, - verify: (manifest) => { - const repo = manifest.repos[0]; - assert.equal(repo.build.parseOk, true, 'current.json should parse'); - assert.equal(repo.build.buildRoot, null, 'absolute buildId should be treated as invalid pointer'); - assert.equal(repo.indexes.code.availabilityReason, 'invalid-pointer'); - assert.equal(repo.indexes.code.indexSignatureHash, null, 'invalid pointer should not use external index signatures'); - assert.equal( - hasInvalidPointerWarning(manifest, 'buildId points outside repo cache'), - true, - 'expected invalid absolute buildId warning' - ); - } - }, - { - name: 'traversal-buildid-escapes-cache', - prefix: 'pairofcleats-workspace-buildid-traversal-', - setup: async ({ tempRoot, repoCacheRoot }) => { - const buildsRoot = path.join(repoCacheRoot, 'builds'); - const externalBuildRoot = path.join(tempRoot, 'external-build'); - await writeIndexArtifacts({ - buildRoot: externalBuildRoot, - compatibilityKey: 'compat-external' - }); - await fs.mkdir(buildsRoot, { recursive: true }); - const escapedBuildId = path.relative(buildsRoot, externalBuildRoot); - await fs.writeFile(path.join(buildsRoot, 'current.json'), JSON.stringify({ - buildId: escapedBuildId, - modes: ['code'] - }), 'utf8'); - }, - verify: (manifest) => { - const repo = manifest.repos[0]; - assert.equal(repo.build.parseOk, true, 'current.json should parse'); - assert.equal(repo.build.buildRoot, null, 'escaped buildId should be treated as invalid pointer'); - assert.equal(repo.indexes.code.availabilityReason, 'invalid-pointer'); - assert.equal(repo.indexes.code.present, false, 'invalid pointer should not load external index directories'); - assert.equal(repo.indexes.code.indexSignatureHash, null, 'invalid pointer should not compute external signatures'); - assert.equal( - hasInvalidPointerWarning(manifest, 'buildId points outside repo cache'), - true, - 'expected invalid buildId pointer warning' - ); - } - }, - { - name: 'unresolved-buildroot-is-invalid', - prefix: 'pairofcleats-workspace-unresolved-buildroot-', - setup: async ({ tempRoot, repoCacheRoot }) => { - const externalBuildRoot = path.join(tempRoot, 'external-build'); - await writeIndexArtifacts({ - buildRoot: externalBuildRoot, - compatibilityKey: 'compat-external' - }); - const localBuildRoot = path.join(repoCacheRoot, 'builds', 'build-external'); - await writeIndexArtifacts({ - buildRoot: localBuildRoot, - compatibilityKey: 'compat-local' - }); - const buildsRoot = path.join(repoCacheRoot, 'builds'); - await fs.mkdir(buildsRoot, { recursive: true }); - await fs.writeFile(path.join(buildsRoot, 'current.json'), JSON.stringify({ - buildId: 'build-external', - buildRoot: externalBuildRoot - }), 'utf8'); - }, - verify: (manifest) => { - const repo = manifest.repos[0]; - assert.equal(repo.build.parseOk, true, 'current.json should parse'); - assert.equal(repo.build.buildRoot, null, 'unresolved buildRoot should be treated as invalid'); - assert.equal(repo.indexes.code.availabilityReason, 'invalid-pointer'); - assert.equal(repo.indexes.code.indexSignatureHash, null, 'invalid build pointer should not load external index signatures'); - assert.equal(repo.indexes.code.present, false, 'invalid pointer should not fall back to same-buildId local indexes'); - assert.equal( - hasInvalidPointerWarning(manifest), - true, - 'expected unresolved buildRoot warning' - ); - } - }, - { - name: 'malformed-current-json-treated-missing', - prefix: 'pairofcleats-workspace-invalid-pointer-', - setup: async ({ repoCacheRoot }) => { - const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); - await writeIndexArtifacts({ - buildRoot, - compatibilityKey: 'compat-a' - }); - const buildsRoot = path.join(repoCacheRoot, 'builds'); - await fs.mkdir(buildsRoot, { recursive: true }); - await fs.writeFile(path.join(buildsRoot, 'current.json'), '{invalid json', 'utf8'); - }, - verify: (manifest) => { - const repo = manifest.repos[0]; - const codeMode = repo.indexes.code; - assert.equal(repo.build.currentJsonExists, true, 'current.json should be detected'); - assert.equal(repo.build.parseOk, false, 'invalid current.json should be treated as missing pointer'); - assert.equal(repo.build.buildId, null, 'invalid pointer should clear buildId'); - assert.equal(codeMode.availabilityReason, 'invalid-pointer'); - assert.equal(codeMode.indexSignatureHash, null, 'invalid pointer should not preserve stale index signatures'); - } - }, - { - name: 'buildid-fallback-prefers-builds-root', - prefix: 'pairofcleats-workspace-buildid-prefers-builds-root-', - setup: async ({ repoCacheRoot }) => { - const buildId = 'build-1'; - const buildsRoot = path.join(repoCacheRoot, 'builds'); - const canonicalBuildRoot = path.join(buildsRoot, buildId); - await writeIndexArtifacts({ - buildRoot: canonicalBuildRoot, - compatibilityKey: 'compat-builds' - }); - const rogueBuildRoot = path.join(repoCacheRoot, buildId); - await writeIndexArtifacts({ - buildRoot: rogueBuildRoot, - compatibilityKey: 'compat-rogue' - }); - await fs.mkdir(buildsRoot, { recursive: true }); - await fs.writeFile(path.join(buildsRoot, 'current.json'), JSON.stringify({ - buildId, - modes: ['code'] - }), 'utf8'); - }, - verify: (manifest, { repoCacheRoot }) => { - const repo = manifest.repos[0]; - const canonicalBuildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); - const canonicalIndexDir = path.join(canonicalBuildRoot, 'index-code'); - assert.equal( - repo.build.buildRoot, - toRealPathSync(canonicalBuildRoot), - 'buildId fallback should resolve to builds/' - ); - assert.equal( - repo.indexes.code.indexDir, - toRealPathSync(canonicalIndexDir), - 'index path should come from builds//index-code' - ); - assert.equal( - repo.indexes.code.compatibilityKey, - 'compat-builds', - 'manifest should read index_state from builds root, not repo-cache sibling path' - ); - } - } -]; - -for (const scenario of scenarios) { - const fixture = await createWorkspaceFixture(scenario.prefix); - try { - await scenario.setup(fixture); - const manifest = await readManifest(fixture.workspacePath); - scenario.verify(manifest, fixture); - } finally { - await removeWorkspaceFixture(fixture.tempRoot); - } -} - -console.log('workspace invalid build pointer scenarios test passed'); diff --git a/tests/workspace/config-contract-matrix.test.js b/tests/workspace/config-contract-matrix.test.js new file mode 100644 index 000000000..f97cdb962 --- /dev/null +++ b/tests/workspace/config-contract-matrix.test.js @@ -0,0 +1,237 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { loadWorkspaceConfig, WORKSPACE_ERROR_CODES } from '../../src/workspace/config.js'; +import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; +import { normalizeIdentityPath } from '../../src/workspace/identity.js'; + +const makeTempRoot = (suffix) => fs.mkdtemp(path.join(os.tmpdir(), `pairofcleats-workspace-${suffix}-`)); + +const runConfigParsingCase = async () => { + const tempRoot = await makeTempRoot('config'); + const workspaceDir = path.join(tempRoot, 'workspace'); + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspaceFile = path.join(workspaceDir, '.pairofcleats-workspace.jsonc'); + + await fs.mkdir(path.join(repoA, 'nested'), { recursive: true }); + await fs.mkdir(repoB, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); + await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); + + await fs.writeFile(workspaceFile, `{ + "schemaVersion": 1, + "name": " Workspace Parse ", + "cacheRoot": "../cache-root", + "defaults": { + "enabled": false, + "priority": 7, + "tags": [" Team ", "team"] + }, + "repos": [ + { "root": "../repo-a/nested" }, + { "root": "../repo-b", "alias": "Repo-B", "enabled": true, "priority": 2, "tags": [" API ", "api", ""] } + ] + }`, 'utf8'); + + const resolved = loadWorkspaceConfig(workspaceFile); + assert.equal(resolved.schemaVersion, 1); + assert.equal(resolved.name, 'Workspace Parse'); + assert.equal(resolved.cacheRoot, normalizeIdentityPath(path.join(workspaceDir, '..', 'cache-root'))); + assert.equal(resolved.repos.length, 2); + assert.equal(resolved.repos[0].enabled, false); + assert.equal(resolved.repos[0].priority, 7); + assert.deepEqual(resolved.repos[0].tags, ['team']); + assert.equal(resolved.repos[1].alias, 'Repo-B'); + assert.deepEqual(resolved.repos[1].tags, ['api']); + assert.ok(resolved.repoSetId.startsWith('ws1-')); + assert.ok(resolved.workspaceConfigHash.startsWith('wsc1-')); + + const unknownKeyFile = path.join(workspaceDir, 'workspace-unknown.jsonc'); + await fs.writeFile(unknownKeyFile, `{ + "schemaVersion": 1, + "repos": [{ "root": "../repo-a", "unknownField": true }] + }`, 'utf8'); + assert.throws(() => loadWorkspaceConfig(unknownKeyFile), (error) => { + assert.equal(error.code, WORKSPACE_ERROR_CODES.UNKNOWN_KEY); + assert.equal(error.field, 'unknownField'); + return true; + }); +}; + +const runAliasAndTagNormalizationCase = async () => { + const tempRoot = await makeTempRoot('alias-tags'); + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const duplicateAliasFile = path.join(tempRoot, 'workspace-dup-alias.jsonc'); + const normalizeTagsFile = path.join(tempRoot, 'workspace-tags.jsonc'); + + await fs.mkdir(repoA, { recursive: true }); + await fs.mkdir(repoB, { recursive: true }); + await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); + await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); + + await fs.writeFile(duplicateAliasFile, `{ + "schemaVersion": 1, + "repos": [ + { "root": "./repo-a", "alias": "Core" }, + { "root": "./repo-b", "alias": "core" } + ] + }`, 'utf8'); + assert.throws(() => loadWorkspaceConfig(duplicateAliasFile), (error) => { + assert.equal(error.code, WORKSPACE_ERROR_CODES.DUPLICATE_ALIAS); + return true; + }); + + await fs.writeFile(normalizeTagsFile, `{ + "schemaVersion": 1, + "defaults": { "tags": [" Team ", "team", ""] }, + "repos": [ + { "root": "./repo-a", "alias": " ", "tags": ["Service", " service ", "", "CORE"] }, + { "root": "./repo-b" } + ] + }`, 'utf8'); + + const resolved = loadWorkspaceConfig(normalizeTagsFile); + assert.equal(resolved.repos[0].alias, null); + assert.deepEqual(resolved.repos[0].tags, ['core', 'service']); + assert.deepEqual(resolved.repos[1].tags, ['team']); +}; + +const runRepoCanonicalizationCases = async () => { + const tempRoot = await makeTempRoot('canonical'); + const repoRoot = path.join(tempRoot, 'repo'); + const nested = path.join(repoRoot, 'src', 'nested'); + const workspaceFile = path.join(tempRoot, 'workspace.jsonc'); + + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), '{}', 'utf8'); + await fs.writeFile(workspaceFile, `{ + "schemaVersion": 1, + "repos": [ + { "root": "./repo" }, + { "root": "./repo/src/nested" } + ] + }`, 'utf8'); + assert.throws(() => loadWorkspaceConfig(workspaceFile), (error) => { + assert.equal(error.code, WORKSPACE_ERROR_CODES.DUPLICATE_REPO_ROOT); + return true; + }); + + const winPathA = normalizeIdentityPath('C:\\Repo\\Svc', { platform: 'win32' }); + const winPathB = normalizeIdentityPath('c:\\repo\\svc', { platform: 'win32' }); + assert.equal(winPathA, winPathB); +}; + +const runRepoRootMustBeDirectoryCase = async () => { + const tempRoot = await makeTempRoot('repo-root-file'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + const notDirectoryPath = path.join(tempRoot, 'repo-root.txt'); + + await fs.writeFile(notDirectoryPath, 'not a directory', 'utf8'); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "repos": [ + { "root": "./repo-root.txt" } + ] + }`, 'utf8'); + assert.throws(() => loadWorkspaceConfig(workspacePath), (error) => { + assert.equal(error.code, WORKSPACE_ERROR_CODES.REPO_ROOT_NOT_DIRECTORY); + assert.equal(error.field, 'root'); + return true; + }); +}; + +const runRepoSetDeterminismCase = async () => { + const tempRoot = await makeTempRoot('reposet'); + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspaceA = path.join(tempRoot, 'workspace-a.jsonc'); + const workspaceB = path.join(tempRoot, 'workspace-b.jsonc'); + + await fs.mkdir(repoA, { recursive: true }); + await fs.mkdir(repoB, { recursive: true }); + await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); + await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); + + await fs.writeFile(workspaceA, `{ + "schemaVersion": 1, + "name": "First", + "repos": [ + { "root": "./repo-a", "alias": "A", "tags": ["x"], "enabled": true, "priority": 0 }, + { "root": "./repo-b", "alias": "B", "tags": ["y"], "enabled": true, "priority": 0 } + ] + }`, 'utf8'); + await fs.writeFile(workspaceB, `{ + "schemaVersion": 1, + "name": "Second", + "repos": [ + { "root": "./repo-b", "alias": "Repo Bee", "tags": ["display"], "enabled": false, "priority": 999 }, + { "root": "./repo-a", "alias": "Repo Ay", "tags": ["metadata"], "enabled": true, "priority": -3 } + ] + }`, 'utf8'); + + const resolvedA = loadWorkspaceConfig(workspaceA); + const resolvedB = loadWorkspaceConfig(workspaceB); + assert.equal(resolvedA.repoSetId, resolvedB.repoSetId); + assert.notEqual(resolvedA.workspaceConfigHash, resolvedB.workspaceConfigHash); +}; + +const runWindowsCanonicalizationCase = async () => { + const tempRoot = await makeTempRoot('win-canon'); + const cacheRoot = path.join(tempRoot, 'cache'); + const repoRoot = path.join(tempRoot, 'RepoCase'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } + }, null, 2), 'utf8'); + + const repoVariant = repoRoot.replace('RepoCase', 'REPOCASE'); + try { + await fs.access(repoVariant); + } catch { + await fs.symlink(repoRoot, repoVariant, 'dir'); + } + const workspaceA = path.join(tempRoot, 'workspace-a.jsonc'); + const workspaceB = path.join(tempRoot, 'workspace-b.jsonc'); + await fs.writeFile(workspaceA, JSON.stringify({ + schemaVersion: 1, + repos: [{ root: repoRoot }] + }, null, 2), 'utf8'); + await fs.writeFile(workspaceB, JSON.stringify({ + schemaVersion: 1, + repos: [{ root: repoVariant }] + }, null, 2), 'utf8'); + + const configA = loadWorkspaceConfig(workspaceA, { platform: 'win32' }); + const configB = loadWorkspaceConfig(workspaceB, { platform: 'win32' }); + const repoA = configA.repos[0]; + const repoB = configB.repos[0]; + assert.equal(repoA.repoRootCanonical, repoB.repoRootCanonical); + assert.equal(repoA.repoId, repoB.repoId); + assert.equal(configA.repoSetId, configB.repoSetId); + assert.equal(getRepoCacheRoot(repoA.repoRootCanonical), getRepoCacheRoot(repoB.repoRootCanonical)); + + const duplicateWorkspace = path.join(tempRoot, 'workspace-dup.jsonc'); + await fs.writeFile(duplicateWorkspace, JSON.stringify({ + schemaVersion: 1, + repos: [{ root: repoRoot }, { root: repoVariant }] + }, null, 2), 'utf8'); + assert.throws( + () => loadWorkspaceConfig(duplicateWorkspace, { platform: 'win32' }), + /Duplicate canonical repo root/i + ); +}; + +await runConfigParsingCase(); +await runAliasAndTagNormalizationCase(); +await runRepoCanonicalizationCases(); +await runRepoRootMustBeDirectoryCase(); +await runRepoSetDeterminismCase(); +await runWindowsCanonicalizationCase(); + +console.log('workspace config contract matrix test passed'); diff --git a/tests/workspace/config-parsing.test.js b/tests/workspace/config-parsing.test.js deleted file mode 100644 index 751a48cce..000000000 --- a/tests/workspace/config-parsing.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadWorkspaceConfig, WORKSPACE_ERROR_CODES } from '../../src/workspace/config.js'; -import { normalizeIdentityPath } from '../../src/workspace/identity.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-config-')); -const workspaceDir = path.join(tempRoot, 'workspace'); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspaceFile = path.join(workspaceDir, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(path.join(repoA, 'nested'), { recursive: true }); -await fs.mkdir(repoB, { recursive: true }); -await fs.mkdir(workspaceDir, { recursive: true }); -await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); -await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); - -await fs.writeFile(workspaceFile, `{ - "schemaVersion": 1, - "name": " Workspace Parse ", - "cacheRoot": "../cache-root", - "defaults": { - "enabled": false, - "priority": 7, - "tags": [" Team ", "team"] - }, - "repos": [ - { "root": "../repo-a/nested" }, - { "root": "../repo-b", "alias": "Repo-B", "enabled": true, "priority": 2, "tags": [" API ", "api", ""] } - ] -}`, 'utf8'); - -const resolved = loadWorkspaceConfig(workspaceFile); -assert.equal(resolved.schemaVersion, 1); -assert.equal(resolved.name, 'Workspace Parse'); -assert.equal(resolved.cacheRoot, normalizeIdentityPath(path.join(workspaceDir, '..', 'cache-root'))); -assert.equal(resolved.repos.length, 2); -assert.equal(resolved.repos[0].enabled, false); -assert.equal(resolved.repos[0].priority, 7); -assert.deepEqual(resolved.repos[0].tags, ['team']); -assert.equal(resolved.repos[1].alias, 'Repo-B'); -assert.deepEqual(resolved.repos[1].tags, ['api']); -assert.ok(resolved.repoSetId.startsWith('ws1-')); -assert.ok(resolved.workspaceConfigHash.startsWith('wsc1-')); - -const unknownKeyFile = path.join(workspaceDir, 'workspace-unknown.jsonc'); -await fs.writeFile(unknownKeyFile, `{ - "schemaVersion": 1, - "repos": [{ "root": "../repo-a", "unknownField": true }] -}`, 'utf8'); - -assert.throws(() => loadWorkspaceConfig(unknownKeyFile), (error) => { - assert.equal(error.code, WORKSPACE_ERROR_CODES.UNKNOWN_KEY); - assert.equal(error.field, 'unknownField'); - return true; -}); - -console.log('workspace config parsing test passed'); diff --git a/tests/workspace/index-signature-sharded-variants.test.js b/tests/workspace/index-signature-sharded-variants.test.js deleted file mode 100644 index ffc2967f4..000000000 --- a/tests/workspace/index-signature-sharded-variants.test.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; -import { generateWorkspaceManifest } from '../../src/workspace/manifest.js'; -import { toRealPathSync } from '../../src/workspace/identity.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-signature-variants-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'repo'); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repoRoot)); -const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); -const indexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(path.join(indexDir, 'chunk_meta.parts'), { recursive: true }); -await fs.mkdir(path.join(indexDir, 'token_postings.shards'), { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.meta.json'), '{"parts":1}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'chunk_meta.parts', 'chunk_meta.part-00001.jsonl'), '{"id":1}\n', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.meta.json'), '{"parts":1}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.shards', 'token_postings.part-00001.jsonl'), '{"token":"a"}\n', 'utf8'); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId: 'build-1', - buildRoot -}), 'utf8'); - -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [{ "root": "./repo" }] -}`, 'utf8'); - -const workspaceConfig = loadWorkspaceConfig(workspacePath); -const first = await generateWorkspaceManifest(workspaceConfig, { write: false }); -const firstSignature = first.manifest.repos[0].indexes.code.indexSignatureHash; -assert.ok(firstSignature && firstSignature.startsWith('is1-'), 'sharded artifacts should produce an index signature'); - -await fs.rm(path.join(indexDir, 'chunk_meta.parts'), { recursive: true, force: true }); -await fs.rm(path.join(indexDir, 'token_postings.shards'), { recursive: true, force: true }); -await fs.rm(path.join(indexDir, 'chunk_meta.meta.json'), { force: true }); -await fs.rm(path.join(indexDir, 'token_postings.meta.json'), { force: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.jsonl'), '{"id":1}\n', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.packed.bin'), 'packed', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.packed.meta.json'), '{"rows":1}', 'utf8'); - -const second = await generateWorkspaceManifest(workspaceConfig, { write: false }); -const secondSignature = second.manifest.repos[0].indexes.code.indexSignatureHash; -assert.ok(secondSignature && secondSignature.startsWith('is1-'), 'jsonl/packed variants should produce an index signature'); -assert.notEqual(firstSignature, secondSignature, 'index signature should change when artifact variants change'); - -console.log('workspace index signature sharded variants test passed'); diff --git a/tests/workspace/manifest-contract-matrix.test.js b/tests/workspace/manifest-contract-matrix.test.js new file mode 100644 index 000000000..7f3cf522e --- /dev/null +++ b/tests/workspace/manifest-contract-matrix.test.js @@ -0,0 +1,358 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { applyTestEnv } from '../helpers/test-env.js'; +import { runNode } from '../helpers/run-node.js'; +import { createWorkspaceFixture, removeWorkspaceFixture, writeIndexArtifacts } from '../helpers/workspace-fixture.js'; +import { stableStringify } from '../../src/shared/stable-json.js'; +import { loadWorkspaceConfig } from '../../src/workspace/config.js'; +import { toRealPathSync } from '../../src/workspace/identity.js'; +import { computeManifestHash, generateWorkspaceManifest } from '../../src/workspace/manifest.js'; +import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; + +applyTestEnv(); + +const root = process.cwd(); +const GENERATED_AT = '2026-02-12T00:00:00.000Z'; + +const readManifest = async (workspacePath) => { + const workspaceConfig = loadWorkspaceConfig(workspacePath); + const { manifest } = await generateWorkspaceManifest(workspaceConfig, { write: false }); + return manifest; +}; + +const generateManifestFromWorkspace = async (workspacePath) => { + const workspaceConfig = loadWorkspaceConfig(workspacePath); + return await generateWorkspaceManifest(workspaceConfig, { write: false, generatedAt: GENERATED_AT }); +}; + +const hasInvalidPointerWarning = (manifest, expectedSnippet = null) => ( + Array.isArray(manifest?.diagnostics?.warnings) + && manifest.diagnostics.warnings.some((entry) => ( + entry?.code === 'WARN_WORKSPACE_INVALID_BUILD_POINTER' + && (!expectedSnippet || String(entry?.message || '').includes(expectedSnippet)) + )) +); + +const writeRepoConfig = async (repoRoot, cacheRoot) => { + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ + cache: { root: cacheRoot } + }, null, 2), 'utf8'); +}; + +const writeCurrentBuildMetadata = async (repoCacheRoot, payload, { raw = false } = {}) => { + const buildsRoot = path.join(repoCacheRoot, 'builds'); + await fs.mkdir(buildsRoot, { recursive: true }); + await fs.writeFile(path.join(buildsRoot, 'current.json'), raw ? payload : JSON.stringify(payload), 'utf8'); + return buildsRoot; +}; + +const assertInvalidBuildPointer = (manifest, expected = {}) => { + const repo = manifest.repos[0]; + if (Object.hasOwn(expected, 'currentJsonExists')) { + assert.equal(repo.build.currentJsonExists, expected.currentJsonExists); + } + if (Object.hasOwn(expected, 'parseOk')) { + assert.equal(repo.build.parseOk, expected.parseOk); + } + if (Object.hasOwn(expected, 'buildId')) { + assert.equal(repo.build.buildId, expected.buildId); + } + if (Object.hasOwn(expected, 'buildRoot')) { + assert.equal(repo.build.buildRoot, expected.buildRoot); + } + assert.equal(repo.indexes.code.availabilityReason, 'invalid-pointer'); + assert.equal(repo.indexes.code.indexSignatureHash, null); + if (Object.hasOwn(expected, 'present')) { + assert.equal(repo.indexes.code.present, expected.present); + } + if (expected.warning === true || Object.hasOwn(expected, 'warningSnippet')) { + assert.equal(hasInvalidPointerWarning(manifest, expected.warningSnippet), true); + } +}; + +const runBuildPointerScenarios = async () => { + const scenarios = [ + { + prefix: 'workspace-buildid-absolute', + async setup({ tempRoot, repoCacheRoot }) { + const externalBuildRoot = path.join(tempRoot, 'external-build'); + await writeIndexArtifacts({ buildRoot: externalBuildRoot, compatibilityKey: 'compat-external' }); + await writeCurrentBuildMetadata(repoCacheRoot, { + buildId: externalBuildRoot, + modes: ['code'] + }); + }, + verify(manifest) { + assertInvalidBuildPointer(manifest, { + parseOk: true, + buildRoot: null, + warningSnippet: 'buildId points outside repo cache' + }); + } + }, + { + prefix: 'workspace-buildid-traversal', + async setup({ tempRoot, repoCacheRoot }) { + const buildsRoot = path.join(repoCacheRoot, 'builds'); + const externalBuildRoot = path.join(tempRoot, 'external-build'); + await writeIndexArtifacts({ buildRoot: externalBuildRoot, compatibilityKey: 'compat-external' }); + const escapedBuildId = path.relative(buildsRoot, externalBuildRoot); + await writeCurrentBuildMetadata(repoCacheRoot, { + buildId: escapedBuildId, + modes: ['code'] + }); + }, + verify(manifest) { + assertInvalidBuildPointer(manifest, { + parseOk: true, + buildRoot: null, + present: false, + warningSnippet: 'buildId points outside repo cache' + }); + } + }, + { + prefix: 'workspace-unresolved-buildroot', + async setup({ tempRoot, repoCacheRoot }) { + const externalBuildRoot = path.join(tempRoot, 'external-build'); + await writeIndexArtifacts({ buildRoot: externalBuildRoot, compatibilityKey: 'compat-external' }); + const localBuildRoot = path.join(repoCacheRoot, 'builds', 'build-external'); + await writeIndexArtifacts({ buildRoot: localBuildRoot, compatibilityKey: 'compat-local' }); + await writeCurrentBuildMetadata(repoCacheRoot, { + buildId: 'build-external', + buildRoot: externalBuildRoot + }); + }, + verify(manifest) { + assertInvalidBuildPointer(manifest, { + parseOk: true, + buildRoot: null, + present: false, + warning: true + }); + } + }, + { + prefix: 'workspace-invalid-pointer-json', + async setup({ repoCacheRoot }) { + const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); + await writeIndexArtifacts({ buildRoot, compatibilityKey: 'compat-a' }); + await writeCurrentBuildMetadata(repoCacheRoot, '{invalid json', { raw: true }); + }, + verify(manifest) { + assertInvalidBuildPointer(manifest, { + currentJsonExists: true, + parseOk: false, + buildId: null + }); + } + }, + { + prefix: 'workspace-buildid-prefers-builds-root', + async setup({ repoCacheRoot }) { + const buildId = 'build-1'; + const buildsRoot = path.join(repoCacheRoot, 'builds'); + const canonicalBuildRoot = path.join(buildsRoot, buildId); + await writeIndexArtifacts({ buildRoot: canonicalBuildRoot, compatibilityKey: 'compat-builds' }); + const rogueBuildRoot = path.join(repoCacheRoot, buildId); + await writeIndexArtifacts({ buildRoot: rogueBuildRoot, compatibilityKey: 'compat-rogue' }); + await writeCurrentBuildMetadata(repoCacheRoot, { + buildId, + modes: ['code'] + }); + }, + verify(manifest, { repoCacheRoot }) { + const repo = manifest.repos[0]; + const canonicalBuildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); + const canonicalIndexDir = path.join(canonicalBuildRoot, 'index-code'); + assert.equal(repo.build.buildRoot, toRealPathSync(canonicalBuildRoot)); + assert.equal(repo.indexes.code.indexDir, toRealPathSync(canonicalIndexDir)); + assert.equal(repo.indexes.code.compatibilityKey, 'compat-builds'); + } + } + ]; + + for (const scenario of scenarios) { + const fixture = await createWorkspaceFixture(scenario.prefix); + try { + await scenario.setup(fixture); + const manifest = await readManifest(fixture.workspacePath); + scenario.verify(manifest, fixture); + } finally { + await removeWorkspaceFixture(fixture.tempRoot); + } + } +}; + +const runIndexSignatureVariantCase = async () => { + const { repoCacheRoot, workspacePath } = await createWorkspaceFixture('pairofcleats-workspace-signature-variants-'); + const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); + const indexDir = path.join(buildRoot, 'index-code'); + await fs.mkdir(path.join(indexDir, 'chunk_meta.parts'), { recursive: true }); + await fs.mkdir(path.join(indexDir, 'token_postings.shards'), { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.meta.json'), '{"parts":1}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'chunk_meta.parts', 'chunk_meta.part-00001.jsonl'), '{"id":1}\n', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.meta.json'), '{"parts":1}', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.shards', 'token_postings.part-00001.jsonl'), '{"token":"a"}\n', 'utf8'); + await writeCurrentBuildMetadata(repoCacheRoot, { buildId: 'build-1', buildRoot }); + + const workspaceConfig = loadWorkspaceConfig(workspacePath); + const first = await generateWorkspaceManifest(workspaceConfig, { write: false }); + const firstSignature = first.manifest.repos[0].indexes.code.indexSignatureHash; + assert.ok(firstSignature && firstSignature.startsWith('is1-')); + + await fs.rm(path.join(indexDir, 'chunk_meta.parts'), { recursive: true, force: true }); + await fs.rm(path.join(indexDir, 'token_postings.shards'), { recursive: true, force: true }); + await fs.rm(path.join(indexDir, 'chunk_meta.meta.json'), { force: true }); + await fs.rm(path.join(indexDir, 'token_postings.meta.json'), { force: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.jsonl'), '{"id":1}\n', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.packed.bin'), 'packed', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.packed.meta.json'), '{"rows":1}', 'utf8'); + + const second = await generateWorkspaceManifest(workspaceConfig, { write: false }); + const secondSignature = second.manifest.repos[0].indexes.code.indexSignatureHash; + assert.ok(secondSignature && secondSignature.startsWith('is1-')); + assert.notEqual(firstSignature, secondSignature); +}; + +const runManifestDeterminismAndHashCase = async () => { + { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-manifest-determinism-')); + const cacheRoot = path.join(tempRoot, 'cache'); + const repoA = path.join(tempRoot, 'repo-a'); + const repoB = path.join(tempRoot, 'repo-b'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + + const writeRepoBuild = async (repoRoot, buildId) => { + const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repoRoot)); + const buildRoot = path.join(repoCacheRoot, 'builds', buildId); + await writeIndexArtifacts({ buildRoot, compatibilityKey: `compat-${buildId}` }); + await writeCurrentBuildMetadata(repoCacheRoot, { buildId, buildRoot }); + }; + + await writeRepoConfig(repoA, cacheRoot); + await writeRepoConfig(repoB, cacheRoot); + await writeRepoBuild(repoA, 'build-a'); + await writeRepoBuild(repoB, 'build-b'); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [ + { "root": "./repo-b", "alias": "B" }, + { "root": "./repo-a", "alias": "A" } + ] + }`, 'utf8'); + + const first = await generateManifestFromWorkspace(workspacePath); + const second = await generateManifestFromWorkspace(workspacePath); + assert.equal(first.manifestPath, second.manifestPath); + assert.equal(stableStringify(first.manifest), stableStringify(second.manifest)); + const repoIds = first.manifest.repos.map((entry) => entry.repoId); + assert.deepEqual(repoIds, repoIds.slice().sort()); + } + + { + const { cacheRoot, repoCacheRoot, workspacePath } = await createWorkspaceFixture('pairofcleats-workspace-manifest-hash-'); + const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); + const indexDir = path.join(buildRoot, 'index-code'); + await fs.mkdir(indexDir, { recursive: true }); + await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"a":[1]}', 'utf8'); + await writeCurrentBuildMetadata(repoCacheRoot, { buildId: 'build-1', buildRoot }); + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [{ "root": "./repo", "alias": "initial" }] + }`, 'utf8'); + + const first = await generateManifestFromWorkspace(workspacePath); + const firstSignature = first.manifest.repos[0].indexes.code.indexSignatureHash; + await new Promise((resolve) => setTimeout(resolve, 25)); + await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"a":[1,2,3]}', 'utf8'); + + const second = await generateManifestFromWorkspace(workspacePath); + const secondSignature = second.manifest.repos[0].indexes.code.indexSignatureHash; + assert.notEqual(firstSignature, secondSignature); + assert.notEqual(first.manifest.manifestHash, second.manifest.manifestHash); + + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [{ "root": "./repo", "alias": "renamed-only" }] + }`, 'utf8'); + + const third = await generateManifestFromWorkspace(workspacePath); + assert.equal(second.manifest.manifestHash, third.manifest.manifestHash); + + const baseRepo = third.manifest.repos[0]; + const shiftedGenerationHash = computeManifestHash({ + ...third.manifest, + repos: [{ + ...baseRepo, + build: { + ...baseRepo.build, + activeRoot: path.join(cacheRoot, 'builds', 'build-1-shadow'), + generationKey: 'wm-test-generation-shift' + } + }] + }); + assert.notEqual(third.manifest.manifestHash, shiftedGenerationHash); + } +}; + +const runCatalogJsonCase = async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-catalog-json-')); + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); + const expectedFederationCacheRoot = path.resolve(tempRoot, 'workspace-cache'); + const toolPath = path.join(root, 'tools', 'workspace', 'catalog.js'); + + await writeRepoConfig(repoRoot, path.join(tempRoot, 'repo-cache-root')); + + const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repoRoot)); + const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); + await writeIndexArtifacts({ buildRoot, compatibilityKey: 'compat-build-1' }); + await writeCurrentBuildMetadata(repoCacheRoot, { buildId: 'build-1', buildRoot }); + + await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "name": "catalog fixture", + "cacheRoot": "./workspace-cache", + "repos": [ + { "root": "./repo", "alias": "sample" } + ] + }`, 'utf8'); + + const run = runNode( + [toolPath, '--workspace', workspacePath, '--json'], + 'workspace catalog manifest json', + root, + applyTestEnv({ syncProcess: false }), + { stdio: 'pipe' } + ); + assert.equal(run.status, 0, run.stderr || run.stdout); + const payload = JSON.parse(run.stdout); + assert.equal(payload.ok, true); + assert.equal(toRealPathSync(payload.cacheRoots?.federationCacheRoot), toRealPathSync(expectedFederationCacheRoot)); + assert.equal(typeof payload.cacheRoots?.workspaceManifestPath, 'string'); + assert.ok(payload.cacheRoots.workspaceManifestPath.endsWith('.json')); + assert.equal(payload.repos.length, 1); + assert.ok(payload.repos[0].repoId.startsWith('repo-')); + assert.ok(payload.repos[0]?.pointer); + assert.equal(payload.repos[0]?.pointer?.buildId, 'build-1'); + assert.equal(payload.repos[0]?.pointer?.parseOk, true); + assert.equal(typeof payload.repos[0]?.pointer?.currentJsonPath, 'string'); + assert.equal(toRealPathSync(payload.repos[0]?.repoCacheRoot), toRealPathSync(repoCacheRoot)); +}; + +await runBuildPointerScenarios(); +await runIndexSignatureVariantCase(); +await runManifestDeterminismAndHashCase(); +await runCatalogJsonCase(); + +console.log('workspace manifest contract matrix test passed'); diff --git a/tests/workspace/manifest-determinism-and-hash.test.js b/tests/workspace/manifest-determinism-and-hash.test.js deleted file mode 100644 index 4f41435a3..000000000 --- a/tests/workspace/manifest-determinism-and-hash.test.js +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; -import { generateWorkspaceManifest } from '../../src/workspace/manifest.js'; -import { toRealPathSync } from '../../src/workspace/identity.js'; -import { stableStringify } from '../../src/shared/stable-json.js'; - -const GENERATED_AT = '2026-02-12T00:00:00.000Z'; - -const writeRepoConfig = async (repoRoot, cacheRoot) => { - await fs.mkdir(repoRoot, { recursive: true }); - await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } - }, null, 2), 'utf8'); -}; - -const writeRepoBuild = async (repoRoot, { buildId, tokenPostings = '{}', compatibilityKey = null }) => { - const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repoRoot)); - const buildRoot = path.join(repoCacheRoot, 'builds', buildId); - const indexDir = path.join(buildRoot, 'index-code'); - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), tokenPostings, 'utf8'); - const includeIndexState = compatibilityKey !== false; - if (includeIndexState) { - await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ - compatibilityKey: compatibilityKey || `compat-${buildId}` - }), 'utf8'); - } - await fs.mkdir(path.join(repoCacheRoot, 'builds'), { recursive: true }); - await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ - buildId, - buildRoot - }), 'utf8'); - return { repoCacheRoot, buildRoot, indexDir }; -}; - -const generateManifestFromWorkspace = async (workspacePath) => { - const workspaceConfig = loadWorkspaceConfig(workspacePath); - return await generateWorkspaceManifest(workspaceConfig, { write: false, generatedAt: GENERATED_AT }); -}; - -// Determinism scenario: repeated generation with identical inputs must be byte-stable. -{ - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-manifest-determinism-')); - const cacheRoot = path.join(tempRoot, 'cache'); - const repoA = path.join(tempRoot, 'repo-a'); - const repoB = path.join(tempRoot, 'repo-b'); - const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - - await writeRepoConfig(repoA, cacheRoot); - await writeRepoConfig(repoB, cacheRoot); - await writeRepoBuild(repoA, { buildId: 'build-a' }); - await writeRepoBuild(repoB, { buildId: 'build-b' }); - await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [ - { "root": "./repo-b", "alias": "B" }, - { "root": "./repo-a", "alias": "A" } - ] - }`, 'utf8'); - - const first = await generateManifestFromWorkspace(workspacePath); - const second = await generateManifestFromWorkspace(workspacePath); - - assert.equal(first.manifestPath, second.manifestPath); - assert.equal(stableStringify(first.manifest), stableStringify(second.manifest), 'manifest output should be byte-stable'); - const repoIds = first.manifest.repos.map((entry) => entry.repoId); - assert.deepEqual(repoIds, repoIds.slice().sort(), 'repos should be sorted by repoId'); -} - -// Hash scenario: artifact-content changes should alter hashes; alias-only edits should not. -{ - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-manifest-hash-')); - const cacheRoot = path.join(tempRoot, 'cache'); - const repoRoot = path.join(tempRoot, 'repo'); - const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); - - await writeRepoConfig(repoRoot, cacheRoot); - const { indexDir } = await writeRepoBuild(repoRoot, { - buildId: 'build-1', - tokenPostings: '{"a":[1]}', - compatibilityKey: false - }); - await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [{ "root": "./repo", "alias": "initial" }] - }`, 'utf8'); - - const first = await generateManifestFromWorkspace(workspacePath); - const firstSignature = first.manifest.repos[0].indexes.code.indexSignatureHash; - - await new Promise((resolve) => setTimeout(resolve, 25)); - await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{"a":[1,2,3]}', 'utf8'); - - const second = await generateManifestFromWorkspace(workspacePath); - const secondSignature = second.manifest.repos[0].indexes.code.indexSignatureHash; - assert.notEqual( - firstSignature, - secondSignature, - `indexSignatureHash should change when token_postings.json changes (${firstSignature} vs ${secondSignature})` - ); - assert.notEqual(first.manifest.manifestHash, second.manifest.manifestHash, 'manifestHash should change with index artifact updates'); - - await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [{ "root": "./repo", "alias": "renamed-only" }] - }`, 'utf8'); - - const third = await generateManifestFromWorkspace(workspacePath); - assert.equal(second.manifest.manifestHash, third.manifest.manifestHash, 'display-only edits must not change manifestHash'); -} - -console.log('workspace manifest determinism/hash test passed'); diff --git a/tests/workspace/manifest-schema-validation.test.js b/tests/workspace/manifest-schema-validation.test.js new file mode 100644 index 000000000..c9ccaf941 --- /dev/null +++ b/tests/workspace/manifest-schema-validation.test.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; +import { toRealPathSync } from '../../src/workspace/identity.js'; +import { loadWorkspaceConfig } from '../../src/workspace/config.js'; +import { generateWorkspaceManifest } from '../../src/workspace/manifest.js'; +import { validateWorkspaceManifest } from '../../src/contracts/validators/workspace.js'; + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-manifest-schema-')); +const cacheRoot = path.join(tempRoot, 'cache'); +const repo = path.join(tempRoot, 'repo'); +await fs.mkdir(repo, { recursive: true }); +await fs.writeFile(path.join(repo, '.pairofcleats.json'), JSON.stringify({ cache: { root: cacheRoot } }, null, 2), 'utf8'); + +const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repo)); +const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); +const indexDir = path.join(buildRoot, 'index-code'); +await fs.mkdir(indexDir, { recursive: true }); +await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); +await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); +await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ compatibilityKey: 'compat-a' }), 'utf8'); +await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ buildId: 'build-1', buildRoot }, null, 2), 'utf8'); + +const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); +await fs.writeFile(workspacePath, `{ + "schemaVersion": 1, + "cacheRoot": "./cache", + "repos": [{ "root": "./repo" }] +}`, 'utf8'); + +const resolved = loadWorkspaceConfig(workspacePath); +const { manifest } = await generateWorkspaceManifest(resolved, { write: false, generatedAt: '2026-02-20T00:00:00.000Z' }); + +const validation = validateWorkspaceManifest(manifest); +assert.equal(validation.ok, true, validation.errors.join('; ')); +assert.equal( + manifest.repos[0]?.build?.activeRoot, + toRealPathSync(buildRoot), + 'expected workspace manifest to preserve the canonical active build root' +); +assert.ok( + typeof manifest.repos[0]?.build?.generationKey === 'string' && manifest.repos[0].build.generationKey.length > 0, + 'expected workspace manifest to expose a repo generation key' +); + +console.log('workspace manifest schema validation test passed'); diff --git a/tests/workspace/repo-canonicalization-dedup.test.js b/tests/workspace/repo-canonicalization-dedup.test.js deleted file mode 100644 index 66ac0c5a9..000000000 --- a/tests/workspace/repo-canonicalization-dedup.test.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadWorkspaceConfig, WORKSPACE_ERROR_CODES } from '../../src/workspace/config.js'; -import { normalizeIdentityPath } from '../../src/workspace/identity.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-canonical-')); -const repoRoot = path.join(tempRoot, 'repo'); -const nested = path.join(repoRoot, 'src', 'nested'); -const workspaceFile = path.join(tempRoot, 'workspace.jsonc'); - -await fs.mkdir(nested, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), '{}', 'utf8'); - -await fs.writeFile(workspaceFile, `{ - "schemaVersion": 1, - "repos": [ - { "root": "./repo" }, - { "root": "./repo/src/nested" } - ] -}`, 'utf8'); - -assert.throws(() => loadWorkspaceConfig(workspaceFile), (error) => { - assert.equal(error.code, WORKSPACE_ERROR_CODES.DUPLICATE_REPO_ROOT); - return true; -}); - -const winPathA = normalizeIdentityPath('C:\\Repo\\Svc', { platform: 'win32' }); -const winPathB = normalizeIdentityPath('c:\\repo\\svc', { platform: 'win32' }); -assert.equal(winPathA, winPathB, 'win32 canonicalization should be case-insensitive'); - -if (process.platform === 'win32') { - const casingVariant = path.join(tempRoot, 'workspace-casing.jsonc'); - const repoRootUpper = repoRoot.toUpperCase(); - await fs.writeFile(casingVariant, `{ - "schemaVersion": 1, - "repos": [ - { "root": "${repoRoot.replace(/\\/g, '\\\\')}" }, - { "root": "${repoRootUpper.replace(/\\/g, '\\\\')}" } - ] -}`, 'utf8'); - assert.throws(() => loadWorkspaceConfig(casingVariant, { platform: 'win32' }), (error) => { - assert.equal(error.code, WORKSPACE_ERROR_CODES.DUPLICATE_REPO_ROOT); - return true; - }); -} - -console.log('workspace repo canonicalization dedupe test passed'); diff --git a/tests/workspace/repo-root-must-be-directory.test.js b/tests/workspace/repo-root-must-be-directory.test.js deleted file mode 100644 index a450bc1db..000000000 --- a/tests/workspace/repo-root-must-be-directory.test.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadWorkspaceConfig, WORKSPACE_ERROR_CODES } from '../../src/workspace/config.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-repo-root-file-')); -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); -const notDirectoryPath = path.join(tempRoot, 'repo-root.txt'); - -await fs.writeFile(notDirectoryPath, 'not a directory', 'utf8'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "repos": [ - { "root": "./repo-root.txt" } - ] -}`, 'utf8'); - -assert.throws(() => loadWorkspaceConfig(workspacePath), (error) => { - assert.equal(error.code, WORKSPACE_ERROR_CODES.REPO_ROOT_NOT_DIRECTORY); - assert.equal(error.field, 'root'); - return true; -}); - -console.log('workspace repo root must be directory test passed'); diff --git a/tests/workspace/repo-set-id-determinism.test.js b/tests/workspace/repo-set-id-determinism.test.js deleted file mode 100644 index 861fbd5e7..000000000 --- a/tests/workspace/repo-set-id-determinism.test.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-reposet-')); -const repoA = path.join(tempRoot, 'repo-a'); -const repoB = path.join(tempRoot, 'repo-b'); -const workspaceA = path.join(tempRoot, 'workspace-a.jsonc'); -const workspaceB = path.join(tempRoot, 'workspace-b.jsonc'); - -await fs.mkdir(repoA, { recursive: true }); -await fs.mkdir(repoB, { recursive: true }); -await fs.writeFile(path.join(repoA, '.pairofcleats.json'), '{}', 'utf8'); -await fs.writeFile(path.join(repoB, '.pairofcleats.json'), '{}', 'utf8'); - -await fs.writeFile(workspaceA, `{ - "schemaVersion": 1, - "name": "First", - "repos": [ - { "root": "./repo-a", "alias": "A", "tags": ["x"], "enabled": true, "priority": 0 }, - { "root": "./repo-b", "alias": "B", "tags": ["y"], "enabled": true, "priority": 0 } - ] -}`, 'utf8'); - -await fs.writeFile(workspaceB, `{ - "schemaVersion": 1, - "name": "Second", - "repos": [ - { "root": "./repo-b", "alias": "Repo Bee", "tags": ["display"], "enabled": false, "priority": 999 }, - { "root": "./repo-a", "alias": "Repo Ay", "tags": ["metadata"], "enabled": true, "priority": -3 } - ] -}`, 'utf8'); - -const resolvedA = loadWorkspaceConfig(workspaceA); -const resolvedB = loadWorkspaceConfig(workspaceB); - -assert.equal(resolvedA.repoSetId, resolvedB.repoSetId, 'repoSetId should be order/display independent'); -assert.notEqual( - resolvedA.workspaceConfigHash, - resolvedB.workspaceConfigHash, - 'workspaceConfigHash should include display metadata differences' -); - -console.log('workspace repoSetId determinism test passed'); diff --git a/tests/workspace/workspace-schema-validation.test.js b/tests/workspace/schema-validation.test.js similarity index 100% rename from tests/workspace/workspace-schema-validation.test.js rename to tests/workspace/schema-validation.test.js diff --git a/tests/workspace/windows-path-canonicalization-contract.test.js b/tests/workspace/windows-path-canonicalization-contract.test.js deleted file mode 100644 index 04379ad5f..000000000 --- a/tests/workspace/windows-path-canonicalization-contract.test.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -import { applyTestEnv } from '../helpers/test-env.js'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; - -applyTestEnv(); - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-win-canon-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repoRoot = path.join(tempRoot, 'RepoCase'); -await fs.mkdir(repoRoot, { recursive: true }); -await fs.writeFile(path.join(repoRoot, '.pairofcleats.json'), JSON.stringify({ - cache: { root: cacheRoot } -}, null, 2), 'utf8'); - -const repoVariant = repoRoot.replace('RepoCase', 'REPOCASE'); -try { - await fs.access(repoVariant); -} catch { - await fs.symlink(repoRoot, repoVariant, 'dir'); -} -const workspaceA = path.join(tempRoot, 'workspace-a.jsonc'); -const workspaceB = path.join(tempRoot, 'workspace-b.jsonc'); -await fs.writeFile(workspaceA, JSON.stringify({ - schemaVersion: 1, - repos: [{ root: repoRoot }] -}, null, 2), 'utf8'); -await fs.writeFile(workspaceB, JSON.stringify({ - schemaVersion: 1, - repos: [{ root: repoVariant }] -}, null, 2), 'utf8'); - -const configA = loadWorkspaceConfig(workspaceA, { platform: 'win32' }); -const configB = loadWorkspaceConfig(workspaceB, { platform: 'win32' }); -const repoA = configA.repos[0]; -const repoB = configB.repos[0]; - -assert.equal(repoA.repoRootCanonical, repoB.repoRootCanonical); -assert.equal(repoA.repoId, repoB.repoId); -assert.equal(configA.repoSetId, configB.repoSetId); -assert.equal( - getRepoCacheRoot(repoA.repoRootCanonical), - getRepoCacheRoot(repoB.repoRootCanonical), - 'cache keys should remain stable for mixed-case path variants on win32' -); - -const duplicateWorkspace = path.join(tempRoot, 'workspace-dup.jsonc'); -await fs.writeFile(duplicateWorkspace, JSON.stringify({ - schemaVersion: 1, - repos: [{ root: repoRoot }, { root: repoVariant }] -}, null, 2), 'utf8'); -assert.throws( - () => loadWorkspaceConfig(duplicateWorkspace, { platform: 'win32' }), - /Duplicate canonical repo root/i, - 'mixed-case duplicates should collapse to one canonical identity' -); - -console.log('windows path canonicalization contract test passed'); diff --git a/tests/workspace/workspace-manifest-schema-validation.test.js b/tests/workspace/workspace-manifest-schema-validation.test.js deleted file mode 100644 index 42e8da8c5..000000000 --- a/tests/workspace/workspace-manifest-schema-validation.test.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { getRepoCacheRoot } from '../../tools/shared/dict-utils.js'; -import { toRealPathSync } from '../../src/workspace/identity.js'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; -import { generateWorkspaceManifest } from '../../src/workspace/manifest.js'; -import { validateWorkspaceManifest } from '../../src/contracts/validators/workspace.js'; - -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pairofcleats-workspace-manifest-schema-')); -const cacheRoot = path.join(tempRoot, 'cache'); -const repo = path.join(tempRoot, 'repo'); -await fs.mkdir(repo, { recursive: true }); -await fs.writeFile(path.join(repo, '.pairofcleats.json'), JSON.stringify({ cache: { root: cacheRoot } }, null, 2), 'utf8'); - -const repoCacheRoot = getRepoCacheRoot(toRealPathSync(repo)); -const buildRoot = path.join(repoCacheRoot, 'builds', 'build-1'); -const indexDir = path.join(buildRoot, 'index-code'); -await fs.mkdir(indexDir, { recursive: true }); -await fs.writeFile(path.join(indexDir, 'chunk_meta.json'), '[]', 'utf8'); -await fs.writeFile(path.join(indexDir, 'token_postings.json'), '{}', 'utf8'); -await fs.writeFile(path.join(indexDir, 'index_state.json'), JSON.stringify({ compatibilityKey: 'compat-a' }), 'utf8'); -await fs.writeFile(path.join(repoCacheRoot, 'builds', 'current.json'), JSON.stringify({ buildId: 'build-1', buildRoot }, null, 2), 'utf8'); - -const workspacePath = path.join(tempRoot, '.pairofcleats-workspace.jsonc'); -await fs.writeFile(workspacePath, `{ - "schemaVersion": 1, - "cacheRoot": "./cache", - "repos": [{ "root": "./repo" }] -}`, 'utf8'); - -const resolved = loadWorkspaceConfig(workspacePath); -const { manifest } = await generateWorkspaceManifest(resolved, { write: false, generatedAt: '2026-02-20T00:00:00.000Z' }); - -const validation = validateWorkspaceManifest(manifest); -assert.equal(validation.ok, true, validation.errors.join('; ')); - -console.log('workspace manifest schema validation test passed'); diff --git a/tools/analysis/context-pack.js b/tools/analysis/context-pack.js index 886fa5bb4..4a64bb0c7 100644 --- a/tools/analysis/context-pack.js +++ b/tools/analysis/context-pack.js @@ -1,7 +1,13 @@ #!/usr/bin/env node import { runContextPackCli } from '../../src/integrations/tooling/context-pack.js'; -runContextPackCli().catch((err) => { - console.error(err?.message || err); - process.exit(1); -}); +runContextPackCli() + .then((result) => { + if (result?.ok === false) { + process.exit(1); + } + }) + .catch((err) => { + console.error(err?.message || err); + process.exit(1); + }); diff --git a/tools/analysis/delta-risk.js b/tools/analysis/delta-risk.js new file mode 100644 index 000000000..217a1ee80 --- /dev/null +++ b/tools/analysis/delta-risk.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +import { createCli } from '../../src/shared/cli.js'; +import { isDirectExecution } from '../../src/shared/direct-execution.js'; +import { buildRiskDeltaPayload } from '../../src/context-pack/risk-delta.js'; +import { resolveRepoConfig } from '../shared/dict-utils.js'; +import { emitCliError, emitCliOutput, resolveFormat } from '../../src/integrations/tooling/cli-helpers.js'; +import { ERROR_CODES } from '../../src/shared/error-codes.js'; +import { projectCliRiskDeltaRequest } from './risk-request.js'; +import { + REPORT_FORMAT_OPTIONS, + RISK_FILTER_OPTIONS, + RISK_PARTIAL_FLOW_OPTIONS, + mergeCliOptions +} from '../../src/shared/cli-options.js'; + +const RISK_DELTA_OPTIONS = Object.freeze(mergeCliOptions({ + repo: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + seed: { type: 'string' } +}, RISK_PARTIAL_FLOW_OPTIONS, RISK_FILTER_OPTIONS, REPORT_FORMAT_OPTIONS)); + +const renderRiskDeltaMarkdown = (payload) => { + const lines = [ + '# Risk Delta', + '', + `Seed: ${payload.seed?.type || 'unknown'} ${payload.seed?.chunkUid || payload.seed?.symbolId || payload.seed?.path || ''}`.trim(), + `From: ${payload.from?.canonical || payload.from?.requestedRef || ''}`, + `To: ${payload.to?.canonical || payload.to?.requestedRef || ''}`, + '', + '## Summary', + `- flows: +${payload.summary?.flowCounts?.added || 0} / -${payload.summary?.flowCounts?.removed || 0} / ~${payload.summary?.flowCounts?.changed || 0}`, + `- partial flows: +${payload.summary?.partialFlowCounts?.added || 0} / -${payload.summary?.partialFlowCounts?.removed || 0} / ~${payload.summary?.partialFlowCounts?.changed || 0}` + ]; + if (payload.from?.seedStatus !== 'resolved' || payload.to?.seedStatus !== 'resolved') { + lines.push('', '## Seed Resolution'); + lines.push(`- from: ${payload.from?.seedStatus || 'unknown'}`); + lines.push(`- to: ${payload.to?.seedStatus || 'unknown'}`); + } + const renderEntryList = (title, entries, idField) => { + if (!Array.isArray(entries) || entries.length === 0) return; + lines.push('', `## ${title}`); + for (const entry of entries) { + lines.push(`- ${entry?.[idField] || ''}`); + } + }; + renderEntryList('Added Flows', payload.deltas?.flows?.added, 'flowId'); + renderEntryList('Removed Flows', payload.deltas?.flows?.removed, 'flowId'); + if (Array.isArray(payload.deltas?.flows?.changed) && payload.deltas.flows.changed.length > 0) { + lines.push('', '## Changed Flows'); + for (const entry of payload.deltas.flows.changed) { + lines.push(`- ${entry?.flowId || ''}: ${(entry?.changedFields || []).join(', ') || ''}`); + } + } + if (payload.includePartialFlows === true) { + renderEntryList('Added Partial Flows', payload.deltas?.partialFlows?.added, 'partialFlowId'); + renderEntryList('Removed Partial Flows', payload.deltas?.partialFlows?.removed, 'partialFlowId'); + if (Array.isArray(payload.deltas?.partialFlows?.changed) && payload.deltas.partialFlows.changed.length > 0) { + lines.push('', '## Changed Partial Flows'); + for (const entry of payload.deltas.partialFlows.changed) { + lines.push(`- ${entry?.partialFlowId || ''}: ${(entry?.changedFields || []).join(', ') || ''}`); + } + } + } + return `${lines.join('\n')}\n`; +}; + +export async function runRiskDeltaCli(rawArgs = process.argv.slice(2)) { + const argv = createCli({ + scriptName: 'risk delta', + options: RISK_DELTA_OPTIONS, + aliases: { + 'include-partial-flows': 'includePartialFlows' + } + }).parse(rawArgs); + const format = resolveFormat(argv); + const repoArg = typeof argv.repo === 'string' ? argv.repo.trim() : ''; + const riskRequest = projectCliRiskDeltaRequest(argv); + const fromArg = riskRequest.fromRef; + const toArg = riskRequest.toRef; + const seedArg = riskRequest.seed; + if (!repoArg || !fromArg || !toArg || !seedArg) { + return emitCliError({ + format, + code: ERROR_CODES.INVALID_REQUEST, + message: 'Usage: pairofcleats risk delta --repo --from --to --seed ' + }); + } + + const validation = riskRequest.filterValidation; + if (!validation.ok) { + return emitCliError({ + format, + code: ERROR_CODES.INVALID_REQUEST, + message: `Invalid risk filters: ${validation.errors.join('; ')}`, + details: { + canonicalCode: ERROR_CODES.INVALID_REQUEST, + reason: 'invalid_risk_filters' + } + }); + } + + try { + const { repoRoot, userConfig } = resolveRepoConfig(repoArg); + const payload = await buildRiskDeltaPayload({ + repoRoot, + userConfig, + from: fromArg, + to: toArg, + seed: seedArg, + filters: riskRequest.filters, + includePartialFlows: riskRequest.includePartialFlows + }); + return emitCliOutput({ + format, + payload, + renderMarkdown: renderRiskDeltaMarkdown + }); + } catch (err) { + return emitCliError({ + format, + code: err?.code || ERROR_CODES.INTERNAL, + message: err?.message || 'Failed to build risk delta.', + details: err?.reason ? { canonicalCode: err?.code || ERROR_CODES.INTERNAL, reason: err.reason } : undefined + }); + } +} + +if (isDirectExecution(import.meta.url)) { + const result = await runRiskDeltaCli(); + if (result?.ok === false) process.exitCode = 1; +} diff --git a/tools/analysis/explain-risk.js b/tools/analysis/explain-risk.js index 3f61e7787..a7d361e92 100644 --- a/tools/analysis/explain-risk.js +++ b/tools/analysis/explain-risk.js @@ -2,239 +2,321 @@ import fs from 'node:fs'; import path from 'node:path'; import { createCli } from '../../src/shared/cli.js'; +import { isDirectExecution } from '../../src/shared/direct-execution.js'; import { loadChunkMeta, loadJsonArrayArtifact, - loadJsonObjectArtifact, - loadPiecesManifest -} from '../../src/shared/artifact-io.js'; - -const argv = createCli({ - scriptName: 'risk explain', - options: { - index: { type: 'string' }, - chunk: { type: 'string' }, - max: { type: 'number', default: 20 }, - 'source-rule': { type: 'string' }, - 'sink-rule': { type: 'string' }, - json: { type: 'boolean', default: false } - } -}).parse(); - -const indexArg = argv.index ? String(argv.index) : ''; -const chunkArg = argv.chunk ? String(argv.chunk) : ''; -if (!indexArg || !chunkArg) { - console.error('Usage: pairofcleats risk explain --index --chunk [--max N]'); - process.exit(1); -} + loadJsonObjectArtifact +} from '../../src/shared/artifact-io/loaders.js'; +import { loadPiecesManifest } from '../../src/shared/artifact-io/manifest.js'; +import { + buildRiskExplanationPresentationFromStandalone +} from '../../src/retrieval/output/risk-explain.js'; +import { createError, ERROR_CODES } from '../../src/shared/error-codes.js'; +import { + filterRiskFlows, + filterRiskPartialFlows +} from '../../src/shared/risk-filters.js'; +import { projectCliRiskExplainRequest } from './risk-request.js'; +import { emitCliError, emitCliOutput, resolveFormat } from '../../src/integrations/tooling/cli-helpers.js'; +import { + REPORT_FORMAT_OPTIONS, + RISK_FILTER_OPTIONS, + RISK_PARTIAL_FLOW_OPTIONS, + mergeCliOptions +} from '../../src/shared/cli-options.js'; -const indexDir = path.resolve(indexArg); -if (!fs.existsSync(indexDir)) { - console.error(`Missing index directory: ${indexDir}`); - process.exit(1); -} +const RISK_EXPLAIN_OPTIONS = Object.freeze(mergeCliOptions({ + index: { type: 'string' }, + chunk: { type: 'string' }, + max: { type: 'number', default: 20 }, + maxPartialFlows: { type: 'number', default: 20 } +}, RISK_PARTIAL_FLOW_OPTIONS, RISK_FILTER_OPTIONS, REPORT_FORMAT_OPTIONS)); -const manifest = loadPiecesManifest(indexDir, { strict: true }); -const chunkMeta = await loadChunkMeta(indexDir, { manifest, strict: true }); -const resolveChunkUid = (entry) => entry?.chunkUid || entry?.metaV2?.chunkUid || null; -const resolveChunkFile = (entry) => entry?.file || entry?.metaV2?.file || entry?.virtualPath || null; -const resolveChunkName = (entry) => entry?.name || entry?.metaV2?.symbol?.name || entry?.metaV2?.name || null; -const resolveChunkKind = (entry) => entry?.kind || entry?.metaV2?.symbol?.kind || null; - -const chunkByUid = new Map(); -for (const entry of chunkMeta) { - const uid = resolveChunkUid(entry); - if (uid) chunkByUid.set(uid, entry); -} +const buildCliErrorDetails = (canonicalCode, reason = null) => { + const details = { + canonicalCode + }; + if (reason) details.reason = reason; + return details; +}; -const targetChunk = chunkByUid.get(chunkArg) || null; -if (!targetChunk) { - console.error(`Unknown chunkUid: ${chunkArg}`); - process.exit(1); -} +export async function buildRiskExplainPayload({ + indexDir, + chunkUid, + max = 20, + filters = null, + includePartialFlows = false, + maxPartialFlows = 20 +}) { + const manifest = loadPiecesManifest(indexDir, { strict: true }); + const chunkMeta = await loadChunkMeta(indexDir, { manifest, strict: true }); + const resolveChunkUid = (entry) => entry?.chunkUid || entry?.metaV2?.chunkUid || null; + const resolveChunkFile = (entry) => entry?.file || entry?.metaV2?.file || entry?.virtualPath || null; + const resolveChunkName = (entry) => entry?.name || entry?.metaV2?.symbol?.name || entry?.metaV2?.name || null; + const resolveChunkKind = (entry) => entry?.kind || entry?.metaV2?.symbol?.kind || null; -const safeLoadArray = async (name) => { - try { - return await loadJsonArrayArtifact(indexDir, name, { manifest, strict: true }); - } catch { - return null; + const chunkByUid = new Map(); + for (const entry of chunkMeta) { + const uid = resolveChunkUid(entry); + if (uid) chunkByUid.set(uid, entry); } -}; -const safeLoadObject = async (name) => { - try { - return await loadJsonObjectArtifact(indexDir, name, { manifest, strict: true }); - } catch { - return null; + + const targetChunk = chunkByUid.get(chunkUid) || null; + if (!targetChunk) { + throw createError(ERROR_CODES.INVALID_REQUEST, `Unknown chunkUid: ${chunkUid}`, { + reason: 'unknown_chunk_uid' + }); } -}; -const riskSummaries = await safeLoadArray('risk_summaries'); -const riskFlows = await safeLoadArray('risk_flows'); -const callSites = await safeLoadArray('call_sites'); -const stats = await safeLoadObject('risk_interprocedural_stats'); - -const summaryRow = Array.isArray(riskSummaries) - ? riskSummaries.find((row) => row?.chunkUid === chunkArg) - : null; - -const summaryFromChunk = targetChunk?.docmeta?.risk?.summary || targetChunk?.metaV2?.risk?.summary || null; -const summary = summaryFromChunk || (summaryRow ? { - sources: { count: summaryRow?.totals?.sources || 0 }, - sinks: { count: summaryRow?.totals?.sinks || 0 }, - sanitizers: { count: summaryRow?.totals?.sanitizers || 0 }, - localFlows: { count: summaryRow?.totals?.localFlows || 0 } -} : null); - -const callSiteById = new Map(); -if (Array.isArray(callSites)) { - for (const entry of callSites) { - if (entry?.callSiteId) callSiteById.set(entry.callSiteId, entry); + const safeLoadArray = async (name) => { + try { + return await loadJsonArrayArtifact(indexDir, name, { manifest, strict: true }); + } catch { + return null; + } + }; + const safeLoadObject = async (name) => { + try { + return await loadJsonObjectArtifact(indexDir, name, { manifest, strict: true }); + } catch { + return null; + } + }; + + const riskSummaries = await safeLoadArray('risk_summaries'); + const riskFlows = await safeLoadArray('risk_flows'); + const riskPartialFlows = includePartialFlows ? await safeLoadArray('risk_partial_flows') : null; + const callSites = await safeLoadArray('call_sites'); + const stats = await safeLoadObject('risk_interprocedural_stats'); + + const summaryRow = Array.isArray(riskSummaries) + ? riskSummaries.find((row) => row?.chunkUid === chunkUid) + : null; + + const summaryFromChunk = targetChunk?.docmeta?.risk?.summary || targetChunk?.metaV2?.risk?.summary || null; + const summary = summaryFromChunk || summaryRow || null; + + const callSiteById = new Map(); + if (Array.isArray(callSites)) { + for (const entry of callSites) { + if (entry?.callSiteId) callSiteById.set(entry.callSiteId, entry); + } } -} -const matchesRule = (flow, sourceRule, sinkRule) => { - if (sourceRule && flow?.source?.ruleId !== sourceRule) return false; - if (sinkRule && flow?.sink?.ruleId !== sinkRule) return false; - return true; -}; + const flows = Array.isArray(riskFlows) ? riskFlows : []; + const relevantFlows = flows.filter((flow) => { + const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; + const isRelevant = flow?.source?.chunkUid === chunkUid + || flow?.sink?.chunkUid === chunkUid + || chunkUids.includes(chunkUid); + return isRelevant; + }); + const matchingFlows = filterRiskFlows(relevantFlows, filters); -const flows = Array.isArray(riskFlows) ? riskFlows : []; -const sourceRule = argv['source-rule'] ? String(argv['source-rule']) : null; -const sinkRule = argv['sink-rule'] ? String(argv['sink-rule']) : null; -const matchingFlows = flows.filter((flow) => { - const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; - const isRelevant = flow?.source?.chunkUid === chunkArg - || flow?.sink?.chunkUid === chunkArg - || chunkUids.includes(chunkArg); - if (!isRelevant) return false; - return matchesRule(flow, sourceRule, sinkRule); -}); - -matchingFlows.sort((a, b) => { - const confA = Number.isFinite(a?.confidence) ? a.confidence : -1; - const confB = Number.isFinite(b?.confidence) ? b.confidence : -1; - if (confA !== confB) return confB - confA; - const idA = a?.flowId || ''; - const idB = b?.flowId || ''; - return idA.localeCompare(idB); -}); - -const max = Number.isFinite(argv.max) ? Math.max(1, Math.floor(argv.max)) : 20; -const limitedFlows = matchingFlows.slice(0, max); - -const formatChunkLabel = (uid) => { - const entry = chunkByUid.get(uid) || null; - const file = resolveChunkFile(entry) || null; - const symbol = resolveChunkName(entry) || null; - if (file && symbol) return `${file}::${symbol}`; - if (file) return file; - return uid || 'unknown'; -}; + matchingFlows.sort((a, b) => { + const confA = Number.isFinite(a?.confidence) ? a.confidence : -1; + const confB = Number.isFinite(b?.confidence) ? b.confidence : -1; + if (confA !== confB) return confB - confA; + const idA = a?.flowId || ''; + const idB = b?.flowId || ''; + return idA.localeCompare(idB); + }); -const formatCallSite = (site) => { - if (!site) return 'unknown call site'; - const file = site.file || 'unknown-file'; - const loc = site.startLine ? `${site.startLine}:${site.startCol || 1}` : '?:?'; - const callee = site.calleeNormalized || site.calleeRaw || 'call'; - const args = Array.isArray(site.args) ? site.args.join(', ') : ''; - return `${file}:${loc} ${callee}${args ? `(${args})` : ''}`; -}; + const maxFlows = Number.isFinite(max) ? Math.max(1, Math.floor(max)) : 20; + const limitedFlows = matchingFlows.slice(0, maxFlows); + const maxPartials = Number.isFinite(maxPartialFlows) ? Math.max(1, Math.floor(maxPartialFlows)) : 20; -const buildFlowPayload = (flow) => { - const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; - const stepIds = Array.isArray(flow?.path?.callSiteIdsByStep) ? flow.path.callSiteIdsByStep : []; - const callSitesByStep = stepIds.map((ids) => (ids || []).map((id) => ({ - callSiteId: id, - details: callSiteById.get(id) || null - }))); - return { - flowId: flow?.flowId || null, - confidence: flow?.confidence ?? null, - source: flow?.source || null, - sink: flow?.sink || null, - notes: flow?.notes || null, - path: { - chunkUids, - labels: chunkUids.map((uid) => formatChunkLabel(uid)), - callSiteIdsByStep: stepIds - }, - callSitesByStep + const formatChunkLabel = (uid) => { + const entry = chunkByUid.get(uid) || null; + const file = resolveChunkFile(entry) || null; + const symbol = resolveChunkName(entry) || null; + if (file && symbol) return `${file}::${symbol}`; + if (file) return file; + return uid || 'unknown'; + }; + + const buildPathEvidencePayload = (flow) => { + const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; + const stepIds = Array.isArray(flow?.path?.callSiteIdsByStep) ? flow.path.callSiteIdsByStep : []; + const watchByStep = Array.isArray(flow?.path?.watchByStep) ? flow.path.watchByStep : []; + const callSitesByStep = stepIds.map((ids) => (ids || []).map((id) => ({ + callSiteId: id, + details: callSiteById.get(id) || null + }))); + return { + path: { + nodes: chunkUids.map((uid) => ({ type: 'chunk', chunkUid: uid })), + labels: chunkUids.map((uid) => formatChunkLabel(uid)), + callSiteIdsByStep: stepIds, + watchByStep: watchByStep.slice(0, stepIds.length).map((entry) => (entry && typeof entry === 'object' ? { ...entry } : null)) + }, + evidence: { + callSitesByStep + } + }; }; -}; -if (argv.json) { - const output = { + const buildFlowPayload = (flow) => { + return { + flowId: flow?.flowId || null, + confidence: flow?.confidence ?? null, + source: flow?.source || null, + sink: flow?.sink || null, + notes: flow?.notes || null, + category: flow?.sink?.category || flow?.source?.category || null, + ...buildPathEvidencePayload(flow) + }; + }; + + const partialFlows = Array.isArray(riskPartialFlows) ? riskPartialFlows : []; + const relevantPartialFlows = filterRiskPartialFlows(partialFlows.filter((flow) => { + const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; + return flow?.source?.chunkUid === chunkUid + || flow?.frontier?.chunkUid === chunkUid + || chunkUids.includes(chunkUid); + }), filters); + relevantPartialFlows.sort((a, b) => { + const confA = Number.isFinite(a?.confidence) ? a.confidence : -1; + const confB = Number.isFinite(b?.confidence) ? b.confidence : -1; + if (confA !== confB) return confB - confA; + const idA = a?.partialFlowId || ''; + const idB = b?.partialFlowId || ''; + return idA.localeCompare(idB); + }); + + const buildPartialFlowPayload = (flow) => { + return { + partialFlowId: flow?.partialFlowId || null, + confidence: flow?.confidence ?? null, + source: flow?.source || null, + frontier: flow?.frontier || null, + notes: flow?.notes || null, + ...buildPathEvidencePayload(flow) + }; + }; + + return { chunk: { - chunkUid: chunkArg, + chunkUid, file: resolveChunkFile(targetChunk), name: resolveChunkName(targetChunk), kind: resolveChunkKind(targetChunk) }, summary, stats: stats || null, - filters: { sourceRule, sinkRule }, - flows: limitedFlows.map(buildFlowPayload) + filters, + flows: limitedFlows.map(buildFlowPayload), + partialFlows: includePartialFlows + ? relevantPartialFlows.slice(0, maxPartials).map(buildPartialFlowPayload) + : [] }; - console.log(JSON.stringify(output, null, 2)); - process.exit(0); } -const headerFile = resolveChunkFile(targetChunk) || 'unknown-file'; -const headerName = resolveChunkName(targetChunk); -const headerKind = resolveChunkKind(targetChunk); -console.log(`Chunk ${chunkArg}`); -console.log(`- file: ${headerFile}`); -if (headerName) { - console.log(`- symbol: ${headerName}${headerKind ? ` (${headerKind})` : ''}`); -} -if (summary) { - const sources = summary?.sources?.count ?? 0; - const sinks = summary?.sinks?.count ?? 0; - const sanitizers = summary?.sanitizers?.count ?? 0; - const localFlows = summary?.localFlows?.count ?? 0; - const categories = Array.isArray(summary?.topCategories) ? summary.topCategories.join(', ') : ''; - const tags = Array.isArray(summary?.topTags) ? summary.topTags.join(', ') : ''; - console.log(`- summary: sources ${sources}, sinks ${sinks}, sanitizers ${sanitizers}, localFlows ${localFlows}`); - if (categories) console.log(` top categories: ${categories}`); - if (tags) console.log(` top tags: ${tags}`); -} -if (stats) { - const flowsEmitted = stats?.counts?.flowsEmitted ?? null; - const callSitesReferenced = stats?.counts?.uniqueCallSitesReferenced ?? null; - const status = stats?.status || 'unknown'; - const caps = Array.isArray(stats?.capsHit) ? stats.capsHit.join(', ') : ''; - console.log(`- interprocedural: status ${status}` + - `${flowsEmitted !== null ? `, flows ${flowsEmitted}` : ''}` + - `${callSitesReferenced !== null ? `, call sites ${callSitesReferenced}` : ''}` + - `${caps ? `, caps hit: ${caps}` : ''}`); -} +export async function runRiskExplainCli(rawArgs = process.argv.slice(2)) { + const argv = createCli({ + scriptName: 'risk explain', + options: RISK_EXPLAIN_OPTIONS, + aliases: { + 'include-partial-flows': 'includePartialFlows', + 'max-partial-flows': 'maxPartialFlows' + } + }).parse(rawArgs); + const format = resolveFormat(argv); -if (!limitedFlows.length) { - console.log('No interprocedural flows found for this chunk.'); - process.exit(0); -} + const indexArg = argv.index ? String(argv.index) : ''; + const riskRequest = projectCliRiskExplainRequest(argv); + const chunkArg = riskRequest.chunkUid; + if (!indexArg || !chunkArg) { + if (format === 'md') { + console.error('Usage: pairofcleats risk explain --index --chunk [--max N]'); + } + return emitCliError({ + format, + code: 'ERR_INVALID_REQUEST', + message: 'Missing --index or --chunk.', + details: buildCliErrorDetails(ERROR_CODES.INVALID_REQUEST, 'missing_required_arguments') + }); + } -console.log(`Flows (${limitedFlows.length}/${matchingFlows.length})`); -for (const flow of limitedFlows) { - const confidence = Number.isFinite(flow?.confidence) ? flow.confidence.toFixed(2) : 'n/a'; - console.log(`- [${confidence}] ${flow.flowId || 'unknown-flow'}`); - if (flow?.source?.ruleId || flow?.sink?.ruleId) { - const sourceRuleId = flow?.source?.ruleId || 'unknown-source'; - const sinkRuleId = flow?.sink?.ruleId || 'unknown-sink'; - console.log(` rules: ${sourceRuleId} -> ${sinkRuleId}`); + const indexDir = path.resolve(indexArg); + if (!fs.existsSync(indexDir)) { + const message = `Missing index directory: ${indexDir}`; + if (format === 'md') { + console.error(message); + } + return emitCliError({ + format, + code: 'ERR_INDEX_DIR_MISSING', + message, + details: buildCliErrorDetails(ERROR_CODES.NO_INDEX, 'missing_index_dir') + }); } - const chunkUids = Array.isArray(flow?.path?.chunkUids) ? flow.path.chunkUids : []; - if (chunkUids.length) { - const pathLabels = chunkUids.map((uid) => formatChunkLabel(uid)); - console.log(` path: ${pathLabels.join(' -> ')}`); + + const filterValidation = riskRequest.filterValidation; + if (!filterValidation.ok) { + const message = `Invalid risk filters: ${filterValidation.errors.join('; ')}`; + if (format === 'md') { + console.error(message); + } + return emitCliError({ + format, + code: 'ERR_RISK_FILTERS_INVALID', + message, + details: buildCliErrorDetails(ERROR_CODES.INVALID_REQUEST, 'invalid_risk_filters') + }); } - const steps = Array.isArray(flow?.path?.callSiteIdsByStep) ? flow.path.callSiteIdsByStep : []; - if (steps.length && callSiteById.size) { - for (let idx = 0; idx < steps.length; idx += 1) { - const ids = steps[idx] || []; - if (!ids.length) continue; - const rendered = ids.map((id) => formatCallSite(callSiteById.get(id))).join('; '); - console.log(` step ${idx + 1}: ${rendered}`); + + try { + const output = await buildRiskExplainPayload({ + indexDir, + chunkUid: chunkArg, + max: riskRequest.max, + filters: riskRequest.filters, + includePartialFlows: riskRequest.includePartialFlows, + maxPartialFlows: riskRequest.maxPartialFlows + }); + const maxItems = Number.isFinite(argv.max) ? Math.max(1, Math.floor(argv.max)) : 20; + const maxPartialItems = Number.isFinite(argv.maxPartialFlows) ? Math.max(1, Math.floor(argv.maxPartialFlows)) : 20; + const presentation = buildRiskExplanationPresentationFromStandalone(output, { + surface: 'standalone', + maxFlows: maxItems, + maxEvidencePerFlow: maxItems, + maxPartialFlows: maxPartialItems + }); + + if (format === 'md' && !output.flows.length && !output.partialFlows.length) { + console.log('No interprocedural flows found for this chunk.'); + return { ok: true, payload: output }; } + + return emitCliOutput({ + format, + payload: output, + renderMarkdown: () => presentation.markdown, + renderJson: () => ({ + ...output, + rendered: presentation.json + }) + }); + } catch (err) { + const message = err?.message || 'Failed to build risk explanation.'; + const isUnknownChunk = ( + err?.code === ERROR_CODES.INVALID_REQUEST && err?.reason === 'unknown_chunk_uid' + ) || /Unknown chunkUid/i.test(message); + const details = isUnknownChunk + ? buildCliErrorDetails(ERROR_CODES.INVALID_REQUEST, 'unknown_chunk_uid') + : buildCliErrorDetails(ERROR_CODES.INTERNAL); + const code = isUnknownChunk + ? 'ERR_INVALID_REQUEST' + : 'ERR_RISK_EXPLAIN'; + return emitCliError({ format, code, message, details }); + } +} + +if (isDirectExecution(import.meta.url)) { + const result = await runRiskExplainCli(); + if (result?.ok === false) { + process.exit(1); } } diff --git a/tools/analysis/graph-context.js b/tools/analysis/graph-context.js index 332f72ca3..43e559d92 100644 --- a/tools/analysis/graph-context.js +++ b/tools/analysis/graph-context.js @@ -1,7 +1,13 @@ #!/usr/bin/env node import { runGraphContextCli } from '../../src/integrations/tooling/graph-context.js'; -runGraphContextCli().catch((err) => { - console.error(err?.message || err); - process.exit(1); -}); +runGraphContextCli() + .then((result) => { + if (result?.ok === false) { + process.exit(1); + } + }) + .catch((err) => { + console.error(err?.message || err); + process.exit(1); + }); diff --git a/tools/analysis/impact.js b/tools/analysis/impact.js index 8e94bb64e..0ab884aa9 100644 --- a/tools/analysis/impact.js +++ b/tools/analysis/impact.js @@ -1,7 +1,13 @@ #!/usr/bin/env node import { runImpactCli } from '../../src/integrations/tooling/impact.js'; -runImpactCli().catch((err) => { - console.error(err?.message || err); - process.exit(1); -}); +runImpactCli() + .then((result) => { + if (result?.ok === false) { + process.exit(1); + } + }) + .catch((err) => { + console.error(err?.message || err); + process.exit(1); + }); diff --git a/tools/analysis/map-iso-safe-join.js b/tools/analysis/map-iso-safe-join.js index f9ce9f9e3..0abc5aca7 100644 --- a/tools/analysis/map-iso-safe-join.js +++ b/tools/analysis/map-iso-safe-join.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { isRelativePathEscape } from '../../src/shared/files.js'; +import { isRelativePathEscape } from '../../src/shared/file-paths.js'; /** * Resolve a request path under a fixed base directory. diff --git a/tools/analysis/map-iso-serve.js b/tools/analysis/map-iso-serve.js index 0ca120985..2e1472f28 100644 --- a/tools/analysis/map-iso-serve.js +++ b/tools/analysis/map-iso-serve.js @@ -3,13 +3,13 @@ import fs from 'node:fs'; import path from 'node:path'; import https from 'node:https'; import { spawn } from 'node:child_process'; -import { spawnSubprocessSync } from '../../src/shared/subprocess.js'; -import { fileURLToPath } from 'node:url'; +import { spawnSubprocessSync } from '../../src/shared/subprocess/runner.js'; import { createCli } from '../../src/shared/cli.js'; import selfsigned from 'selfsigned'; -import { getRuntimeConfig, resolveRepoConfig, resolveRuntimeEnv, resolveToolRoot } from '../shared/dict-utils.js'; +import { bootstrapRuntime, resolveToolRoot } from '../shared/dict-utils.js'; import { exitLikeCommandResult } from '../shared/cli-utils.js'; import { decodePathnameSafe, safeJoinUnderBase } from './map-iso-safe-join.js'; +import { serveMapIsoStaticFileOr404 } from './map-iso-static.js'; const argv = createCli({ scriptName: 'map-iso', @@ -27,9 +27,7 @@ const argv = createCli({ const toolRoot = resolveToolRoot(); const repoArg = argv.repo || argv.dir || null; -const { repoRoot, userConfig } = resolveRepoConfig(repoArg); -const runtimeConfig = getRuntimeConfig(repoRoot, userConfig); -const runtimeEnv = resolveRuntimeEnv(runtimeConfig, process.env); +const { repoRoot, runtimeEnv } = bootstrapRuntime(repoArg); const mapsDir = path.join(repoRoot, '.pairofcleats', 'maps'); const outPath = argv.out ? path.resolve(argv.out) : path.join(mapsDir, 'map.iso.html'); const threeUrl = argv['three-url'] || '/three/three.module.js'; @@ -77,18 +75,6 @@ const runReport = () => { } }; -const contentTypeFor = (filePath) => { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.html') return 'text/html; charset=utf-8'; - if (ext === '.js') return 'application/javascript; charset=utf-8'; - if (ext === '.json') return 'application/json; charset=utf-8'; - if (ext === '.map') return 'application/json; charset=utf-8'; - if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; - if (ext === '.png') return 'image/png'; - if (ext === '.hdr') return 'application/octet-stream'; - return 'application/octet-stream'; -}; - const openBrowser = (url) => { if (argv.open === false) return; if (process.platform === 'win32') { @@ -117,7 +103,7 @@ const server = https.createServer({ key, cert }, (req, res) => { return; } if (pathname === '/' || pathname === '/map.iso.html') { - serveStaticFileOr404(res, outPath, 'map.iso.html not found.'); + serveMapIsoStaticFileOr404(res, outPath, 'map.iso.html not found.'); return; } if (pathname.startsWith('/three/examples/')) { @@ -128,7 +114,7 @@ const server = https.createServer({ key, cert }, (req, res) => { res.end('three.js example asset not found.'); return; } - serveStaticFileOr404(res, targetPath, 'three.js example asset not found.'); + serveMapIsoStaticFileOr404(res, targetPath, 'three.js example asset not found.'); return; } if (pathname.startsWith('/three/')) { @@ -139,7 +125,7 @@ const server = https.createServer({ key, cert }, (req, res) => { res.end('three.js asset not found.'); return; } - serveStaticFileOr404(res, targetPath, 'three.js asset not found.'); + serveMapIsoStaticFileOr404(res, targetPath, 'three.js asset not found.'); return; } if (pathname.startsWith('/assets/isomap/')) { @@ -150,7 +136,7 @@ const server = https.createServer({ key, cert }, (req, res) => { res.end('isomap asset not found.'); return; } - serveStaticFileOr404(res, targetPath, 'isomap asset not found.'); + serveMapIsoStaticFileOr404(res, targetPath, 'isomap asset not found.'); return; } if (pathname.startsWith('/isomap/')) { @@ -161,7 +147,7 @@ const server = https.createServer({ key, cert }, (req, res) => { res.end('isomap client asset not found.'); return; } - serveStaticFileOr404(res, targetPath, 'isomap client asset not found.'); + serveMapIsoStaticFileOr404(res, targetPath, 'isomap client asset not found.'); return; } res.writeHead(404); diff --git a/tools/analysis/map-iso-static.js b/tools/analysis/map-iso-static.js new file mode 100644 index 000000000..c233aec35 --- /dev/null +++ b/tools/analysis/map-iso-static.js @@ -0,0 +1,50 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const contentTypeForMapIsoPath = (filePath) => { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.html') return 'text/html; charset=utf-8'; + if (ext === '.js') return 'application/javascript; charset=utf-8'; + if (ext === '.json') return 'application/json; charset=utf-8'; + if (ext === '.map') return 'application/json; charset=utf-8'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.png') return 'image/png'; + if (ext === '.hdr') return 'application/octet-stream'; + return 'application/octet-stream'; +}; + +export const serveMapIsoStaticFileOr404 = (res, filePath, notFoundMessage) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + res.writeHead(404); + res.end(notFoundMessage); + return; + } + } catch { + res.writeHead(404); + res.end(notFoundMessage); + return; + } + + res.writeHead(200, { 'Content-Type': contentTypeForMapIsoPath(filePath) }); + const stream = fs.createReadStream(filePath); + const onResponseClose = () => { + if (!stream.destroyed) { + stream.destroy(); + } + }; + res.once('close', onResponseClose); + stream.once('close', () => { + res.off('close', onResponseClose); + }); + stream.on('error', () => { + if (!res.headersSent) { + res.writeHead(404); + } + if (!res.writableEnded) { + res.end(notFoundMessage); + } + }); + stream.pipe(res); +}; diff --git a/tools/analysis/risk-request.js b/tools/analysis/risk-request.js new file mode 100644 index 000000000..8ce3d5ff1 --- /dev/null +++ b/tools/analysis/risk-request.js @@ -0,0 +1,98 @@ +import { + buildValidatedRiskFilters, + normalizeValidatedRiskFilters +} from '../../src/shared/risk-filters.js'; + +const toTrimmedString = (value) => ( + value == null ? '' : String(value).trim() +); + +const resolveNestedRiskFilters = (input) => normalizeValidatedRiskFilters(input?.filters || null); + +/** + * Project API/MCP-style risk explain input into the payload builder shape. + * Repo resolution, index checks, progress, and transport errors stay local to + * each surface. + * + * @param {object} [input] + * @returns {{ + * chunkUid:string, + * max:unknown, + * filters:object|null, + * filterValidation:{ok:boolean,errors:string[]}, + * includePartialFlows:boolean, + * maxPartialFlows:unknown + * }} + */ +export function projectRiskExplainRequest(input = {}) { + const { filters, validation: filterValidation } = resolveNestedRiskFilters(input); + return { + chunkUid: toTrimmedString(input?.chunk), + max: input?.max, + filters, + filterValidation, + includePartialFlows: input?.includePartialFlows === true, + maxPartialFlows: input?.maxPartialFlows + }; +} + +/** + * Project CLI risk explain argv, where filter fields live on argv itself. + * + * @param {object} [argv] + * @returns {ReturnType} + */ +export function projectCliRiskExplainRequest(argv = {}) { + const { filters, validation: filterValidation } = buildValidatedRiskFilters(argv); + return { + chunkUid: toTrimmedString(argv?.chunk), + max: argv?.max, + filters, + filterValidation, + includePartialFlows: argv?.includePartialFlows === true, + maxPartialFlows: argv?.maxPartialFlows + }; +} + +/** + * Project API/MCP-style risk delta input into the payload builder shape. + * + * @param {object} [input] + * @returns {{ + * fromRef:string, + * toRef:string, + * seed:string, + * filters:object|null, + * filterValidation:{ok:boolean,errors:string[]}, + * includePartialFlows:boolean + * }} + */ +export function projectRiskDeltaRequest(input = {}) { + const { filters, validation: filterValidation } = resolveNestedRiskFilters(input); + return { + fromRef: toTrimmedString(input?.from), + toRef: toTrimmedString(input?.to), + seed: toTrimmedString(input?.seed), + filters, + filterValidation, + includePartialFlows: input?.includePartialFlows === true + }; +} + +/** + * Project CLI risk delta argv, where filter fields live on argv itself. + * + * @param {object} [argv] + * @returns {ReturnType} + */ +export function projectCliRiskDeltaRequest(argv = {}) { + const { filters, validation: filterValidation } = buildValidatedRiskFilters(argv); + return { + fromRef: toTrimmedString(argv?.from), + toRef: toTrimmedString(argv?.to), + seed: toTrimmedString(argv?.seed), + filters, + filterValidation, + includePartialFlows: argv?.includePartialFlows === true + }; +} diff --git a/tools/analysis/structural-search-paths.js b/tools/analysis/structural-search-paths.js index 3594c4b52..8299cc215 100644 --- a/tools/analysis/structural-search-paths.js +++ b/tools/analysis/structural-search-paths.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { isAbsolutePathNative } from '../../src/shared/files.js'; +import { isAbsolutePathNative } from '../../src/shared/file-paths.js'; import { joinPathSafe } from '../../src/shared/path-normalize.js'; /** diff --git a/tools/analysis/suggest-tests.js b/tools/analysis/suggest-tests.js index 151e5d114..093f417c3 100644 --- a/tools/analysis/suggest-tests.js +++ b/tools/analysis/suggest-tests.js @@ -1,7 +1,13 @@ #!/usr/bin/env node import { runSuggestTestsCli } from '../../src/integrations/tooling/suggest-tests.js'; -runSuggestTestsCli().catch((err) => { - console.error(err?.message || err); - process.exit(1); -}); +runSuggestTestsCli() + .then((result) => { + if (result?.ok === false) { + process.exit(1); + } + }) + .catch((err) => { + console.error(err?.message || err); + process.exit(1); + }); diff --git a/tools/api/redact.js b/tools/api/redact.js index abbeb795e..de09273b6 100644 --- a/tools/api/redact.js +++ b/tools/api/redact.js @@ -1,4 +1,4 @@ -import { isAbsolutePathAny } from '../../src/shared/files.js'; +import { isAbsolutePathAny } from '../../src/shared/file-paths.js'; const REDACTED_ABSOLUTE_PATH = ''; const REDACTED_PATH_REF = 'path:'; diff --git a/tools/api/router.js b/tools/api/router.js index 260eb7ae9..563abeead 100644 --- a/tools/api/router.js +++ b/tools/api/router.js @@ -1,21 +1,44 @@ import path from 'node:path'; import { search, status } from '../../src/integrations/core/index.js'; +import { MCP_SCHEMA_VERSION } from '../../src/integrations/mcp/defs.js'; import { runFederatedSearch } from '../../src/retrieval/federation/coordinator.js'; -import { loadWorkspaceConfig } from '../../src/workspace/config.js'; -import { resolveFederationCacheRoot } from '../../src/workspace/manifest.js'; -import { createFederatedSearchValidator, createSearchValidator } from './validation.js'; +import { + createContextPackValidator, + createFederatedSearchValidator, + createRiskDeltaValidator, + createRiskExplainValidator, + createSearchValidator +} from './validation.js'; import { sendError, sendJson } from './response.js'; import { ERROR_CODES } from '../../src/shared/error-codes.js'; -import { isWithinRoot, toRealPathSync } from '../shared/dict-utils.js'; +import { getToolVersion, toRealPathSync } from '../shared/dict-utils.js'; import { createSseResponder } from './sse.js'; import { createAuthGuard } from './router/auth.js'; import { createBodyParser } from './router/body.js'; import { createRepoCacheManager } from './router/cache.js'; import { createCorsResolver } from './router/cors.js'; import { createRepoResolver } from './router/paths.js'; +import { getRepoCacheGenerationContext } from '../../src/shared/repo-cache-config.js'; import { handleIndexDiffsRoute } from './router/index-diffs.js'; import { handleIndexSnapshotsRoute } from './router/index-snapshots.js'; +import { handleContextPackRoute, handleRiskDeltaRoute, handleRiskExplainRoute } from './router/analysis.js'; +import { + classifyRepoResolveError, + classifyWorkspaceRequestError, + parseJsonBodyOrSendError, + resolveRepoOrSendError, + sendClassifiedRequestError +} from './router/request-helpers.js'; import { buildSearchParams, buildSearchPayloadFromQuery, isNoIndexError } from './router/search.js'; +import { createWorkspaceAllowlist } from './router/workspace-allowlist.js'; +import { getApiWorkflowCapabilities, getRuntimeCapabilityManifest } from '../../src/shared/runtime-capability-manifest.js'; +import { buildApiTrustBoundaryStatusView } from './trust-boundary.js'; +import { + attachObservability, + buildChildObservability, + buildObservabilityHeaders, + normalizeObservability +} from '../../src/shared/observability.js'; /** * Create an API router for the HTTP server. @@ -28,6 +51,7 @@ import { buildSearchParams, buildSearchPayloadFromQuery, isNoIndexError } from ' * auth?:{token?:string|null,required?:boolean}, * allowedRepoRoots?:string[], * maxBodyBytes?:number, + * trustBoundary?:object|null, * repoCache?:{maxEntries?:number,ttlMs?:number}, * indexCache?:{maxEntries?:number,ttlMs?:number}, * sqliteCache?:{maxEntries?:number,ttlMs?:number} @@ -42,12 +66,17 @@ export const createApiRouter = ({ auth = {}, allowedRepoRoots = [], maxBodyBytes = 1_000_000, + trustBoundary = null, repoCache = {}, indexCache = {}, sqliteCache = {} }) => { + const toolVersion = getToolVersion() || '0.0.0'; const validateSearchPayload = createSearchValidator(); const validateFederatedPayload = createFederatedSearchValidator(); + const validateRiskExplainPayload = createRiskExplainValidator(); + const validateRiskDeltaPayload = createRiskDeltaValidator(); + const validateContextPackPayload = createContextPackValidator(); const { resolveCorsHeaders } = createCorsResolver(cors); const { isAuthorized } = createAuthGuard(auth); const { parseJsonBody } = createBodyParser({ maxBodyBytes }); @@ -58,28 +87,23 @@ export const createApiRouter = ({ indexCache, sqliteCache }); - const canonicalConfiguredAllowedRoots = [defaultRepo, ...allowedRepoRoots] - .filter((entry) => typeof entry === 'string' && entry.trim()) - .map((entry) => toRealPathSync(path.resolve(entry))); - const canonicalWorkspacePolicyRoots = Array.from(new Set([ - ...canonicalConfiguredAllowedRoots, - // Always include the default federation cache root so explicit repo-root - // allowlists do not accidentally block workspace-path/cache-root workflows. - resolveFederationCacheRoot(null) - ])); - const isAllowedWorkspacePath = (workspacePath) => { - if (!canonicalWorkspacePolicyRoots.length) return true; - const workspaceCanonical = toRealPathSync(workspacePath); - return canonicalWorkspacePolicyRoots.some((root) => isWithinRoot(workspaceCanonical, root)); - }; - - const resolveWorkspacePath = (payload) => { - const value = payload?.workspacePath; - if (typeof value !== 'string') return ''; - const trimmed = value.trim(); - if (!trimmed) return ''; - return path.resolve(trimmed); - }; + const buildSearchObservability = (requestObservability, repoPath, caches, extraContext = {}) => buildChildObservability( + requestObservability, + { + surface: 'search', + operation: 'search', + context: { + repoRoot: repoPath, + ...extraContext, + ...getRepoCacheGenerationContext(caches) + } + } + ); + const { ensureWorkspaceAllowlist } = createWorkspaceAllowlist({ + defaultRepo, + allowedRepoRoots, + resolveRepo + }); /** * Classify federated failures caused by client input that passed schema shape @@ -102,44 +126,158 @@ export const createApiRouter = ({ || message.includes('multiple cohorts detected'); }; - /** - * Validate federated workspace inputs against server path allowlists. - * - * This enforces both repo roots and the resolved federated cache root so - * manifest/query-cache writes cannot escape configured allowed roots. The - * returned workspace config snapshot is then passed into the federated - * coordinator as trusted input to avoid a post-validation reload race. - * - * @param {any} payload - * @returns {Promise} - */ - const ensureWorkspaceAllowlist = async (payload) => { - const resolvedWorkspacePath = resolveWorkspacePath(payload); - if (!resolvedWorkspacePath) { - throw new Error('Federated search requires workspacePath.'); - } - if (!isAllowedWorkspacePath(resolvedWorkspacePath)) { - const err = new Error('Workspace path not permitted by server configuration.'); - err.code = ERROR_CODES.FORBIDDEN; - throw err; + const mergeResponseHeaders = (headers, observability = null) => ({ + ...(headers || {}), + ...buildObservabilityHeaders(observability) + }); + + const sendStatusStreamRepoResolveError = async (sse, err) => { + const classification = classifyRepoResolveError(err); + await sse.sendHeaders(); + await sse.sendEvent('error', { + ok: false, + code: classification.code, + message: classification.message + }); + await sse.sendEvent('done', { ok: false }); + sse.end(); + }; + + const createRequestObservability = (req, requestUrl, operation, context = {}) => normalizeObservability({ + correlationId: req?.headers?.['x-correlation-id'] || null, + parentCorrelationId: req?.headers?.['x-parent-correlation-id'] || null, + requestId: req?.headers?.['x-request-id'] || null + }, { + surface: 'api', + operation, + context: { + method: req?.method || null, + path: requestUrl?.pathname || null, + ...context } - const workspaceConfig = loadWorkspaceConfig(resolvedWorkspacePath); - for (const repo of workspaceConfig.repos) { - await resolveRepo(repo.repoRootCanonical); + }); + + const prepareSearchRequest = async ({ + req, + res, + requestUrl, + routeName, + corsHeaders, + parseJsonBody, + resolveRepo, + defaultOutput, + readPayload = null, + beforeBodyRead = null + }) => { + const requestObservability = createRequestObservability(req, requestUrl, routeName); + const responseHeaders = mergeResponseHeaders(corsHeaders, requestObservability); + const controller = new AbortController(); + const abortRequest = () => controller.abort(); + req.on('aborted', abortRequest); + res.on('close', abortRequest); + res.on('error', abortRequest); + + if (typeof beforeBodyRead === 'function') { + await beforeBodyRead({ requestObservability, responseHeaders, controller }); } - const federationCacheRoot = resolveFederationCacheRoot(workspaceConfig); - if (!isAllowedWorkspacePath(federationCacheRoot)) { - const err = new Error('Workspace cache root not permitted by server configuration.'); - err.code = ERROR_CODES.FORBIDDEN; - throw err; + + const payloadResult = typeof readPayload === 'function' + ? await readPayload({ req, res, requestUrl, responseHeaders }) + : await parseJsonBodyOrSendError(req, res, parseJsonBody, responseHeaders); + if (!payloadResult.ok) return { ok: false }; + const payload = payloadResult.payload; + const validation = validateSearchPayload(payload); + if (!validation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { + errors: validation.errors + }, responseHeaders); + return { ok: false }; } - if (payload?.workspaceId && payload.workspaceId !== workspaceConfig.repoSetId) { - throw new Error('workspaceId does not match the provided workspacePath.'); + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + payload?.repoPath || payload?.repo, + responseHeaders + ); + if (!resolvedRepo.ok) return { ok: false }; + const repoPath = resolvedRepo.repoPath; + const searchParams = buildSearchParams(repoPath, payload || {}, defaultOutput); + if (!searchParams.ok) { + sendError( + res, + 400, + ERROR_CODES.INVALID_REQUEST, + searchParams.message || 'Invalid search payload.', + {}, + responseHeaders + ); + return { ok: false }; } - return workspaceConfig; + return { ok: true, requestObservability, responseHeaders, controller, repoPath, searchParams }; }; + const runPreparedSearch = async ({ + requestObservability, + controller, + repoPath, + searchParams, + searchContext = {} + }) => { + const caches = getRepoCaches(repoPath); + await refreshBuildPointer(caches); + const searchObservability = buildSearchObservability(requestObservability, repoPath, caches, searchContext); + return await search(repoPath, { + args: searchParams.args, + query: searchParams.query, + emitOutput: false, + exitOnError: false, + indexCache: caches.indexCache, + sqliteCache: caches.sqliteCache, + signal: controller.signal, + observability: searchObservability, + generationContext: getRepoCacheGenerationContext(caches) + }); + }; + const sendPreparedJsonSearch = async ({ + req, + res, + prepared, + ignoreControllerAbort = false + }) => { + const { + requestObservability, + responseHeaders, + controller, + repoPath, + searchParams + } = prepared; + try { + const body = await runPreparedSearch({ + requestObservability, + controller, + repoPath, + searchParams + }); + sendJson(res, 200, attachObservability({ ok: true, result: body }, requestObservability), responseHeaders); + } catch (err) { + if (req.aborted || res.writableEnded || (!ignoreControllerAbort && controller.signal.aborted)) return; + if (isNoIndexError(err)) { + sendError(res, 409, ERROR_CODES.NO_INDEX, err?.message || 'Index not found.', { + error: err?.message || String(err) + }, responseHeaders); + return; + } + sendError( + res, + 500, + ERROR_CODES.INTERNAL, + 'Search failed.', + { error: err?.message || String(err) }, + responseHeaders + ); + } + }; @@ -170,6 +308,24 @@ export const createApiRouter = ({ return; } + if (requestUrl.pathname === '/capabilities' && req.method === 'GET') { + const runtimeManifest = getRuntimeCapabilityManifest(); + sendJson(res, 200, { + ok: true, + schemaVersion: MCP_SCHEMA_VERSION, + toolVersion, + serverInfo: { + name: 'PairOfCleats', + version: toolVersion + }, + trustBoundary: buildApiTrustBoundaryStatusView(trustBoundary), + capabilities: getApiWorkflowCapabilities({ runtimeCapabilities: runtimeManifest.runtimeCapabilities }), + runtimeCapabilities: runtimeManifest.runtimeCapabilities, + runtimeManifest + }, corsHeaders || {}); + return; + } + if (requestUrl.pathname === '/metrics' && req.method === 'GET') { try { const body = await metricsRegistry.metrics(); @@ -186,20 +342,56 @@ export const createApiRouter = ({ return; } + if (requestUrl.pathname === '/analysis/risk-explain' && req.method === 'POST') { + const requestObservability = createRequestObservability(req, requestUrl, 'risk_explain'); + await handleRiskExplainRoute({ + req, + res, + corsHeaders: mergeResponseHeaders(corsHeaders, requestObservability), + observability: requestObservability, + parseJsonBody, + resolveRepo, + validateRiskExplainPayload + }); + return; + } + + if (requestUrl.pathname === '/analysis/risk-delta' && req.method === 'POST') { + const requestObservability = createRequestObservability(req, requestUrl, 'risk_delta'); + await handleRiskDeltaRoute({ + req, + res, + corsHeaders: mergeResponseHeaders(corsHeaders, requestObservability), + observability: requestObservability, + parseJsonBody, + resolveRepo, + validateRiskDeltaPayload + }); + return; + } + + if (requestUrl.pathname === '/analysis/context-pack' && req.method === 'POST') { + const requestObservability = createRequestObservability(req, requestUrl, 'context_pack'); + await handleContextPackRoute({ + req, + res, + corsHeaders: mergeResponseHeaders(corsHeaders, requestObservability), + observability: requestObservability, + parseJsonBody, + resolveRepo, + validateContextPackPayload, + ensureWorkspaceAllowlist + }); + return; + } + if (requestUrl.pathname === '/status/stream' && req.method === 'GET') { const sse = createSseResponder(req, res, { headers: corsHeaders || {} }); let repoPath = ''; try { repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); } catch (err) { - await sse.sendHeaders(); - await sse.sendEvent('error', { - ok: false, - code: err?.code || ERROR_CODES.INVALID_REQUEST, - message: err?.message || 'Invalid repo path.' - }); - await sse.sendEvent('done', { ok: false }); - sse.end(); + await sendStatusStreamRepoResolveError(sse, err); return; } await sse.sendHeaders(); @@ -223,18 +415,21 @@ export const createApiRouter = ({ } if (requestUrl.pathname === '/status' && req.method === 'GET') { - let repoPath = ''; - try { - repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); - } catch (err) { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); - return; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return; + const repoPath = resolvedRepo.repoPath; try { const payload = await status(repoPath); - sendJson(res, 200, { ok: true, status: payload }, corsHeaders || {}); + sendJson(res, 200, { + ok: true, + status: payload, + trustBoundary: buildApiTrustBoundaryStatusView(trustBoundary) + }, corsHeaders || {}); } catch (err) { sendError(res, 500, ERROR_CODES.INTERNAL, 'Failed to collect status.', { error: err?.message || String(err) @@ -272,23 +467,9 @@ export const createApiRouter = ({ req.on('aborted', abortRequest); res.on('close', abortRequest); res.on('error', abortRequest); - let payload = null; - try { - payload = await parseJsonBody(req); - } catch (err) { - const status = err?.code === 'ERR_BODY_TOO_LARGE' ? 413 - : err?.code === 'ERR_UNSUPPORTED_MEDIA_TYPE' ? 415 - : 400; - sendError( - res, - status, - ERROR_CODES.INVALID_REQUEST, - err?.message || 'Invalid request body.', - {}, - corsHeaders || {} - ); - return; - } + const parsedBody = await parseJsonBodyOrSendError(req, res, parseJsonBody, corsHeaders); + if (!parsedBody.ok) return; + const payload = parsedBody.payload; const validation = validateFederatedPayload(payload); if (!validation.ok) { sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid federated search payload.', { @@ -300,15 +481,10 @@ export const createApiRouter = ({ try { workspaceConfig = await ensureWorkspaceAllowlist(payload); } catch (err) { - const forbidden = err?.code === ERROR_CODES.FORBIDDEN - || String(err?.message || '').toLowerCase().includes('not permitted'); - sendError( + sendClassifiedRequestError( res, - forbidden ? 403 : 400, - forbidden ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST, - err?.message || 'Invalid workspace request.', - {}, - corsHeaders || {} + classifyWorkspaceRequestError(err), + corsHeaders ); return; } @@ -358,151 +534,67 @@ export const createApiRouter = ({ } if (requestUrl.pathname === '/search' && req.method === 'GET') { - const controller = new AbortController(); - const abortRequest = () => controller.abort(); - req.on('aborted', abortRequest); - res.on('close', abortRequest); - res.on('error', abortRequest); - const { payload, errors: queryErrors } = buildSearchPayloadFromQuery(requestUrl.searchParams); - if (Array.isArray(queryErrors) && queryErrors.length) { - sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { - errors: queryErrors - }, corsHeaders || {}); - return; - } - const validation = validateSearchPayload(payload); - if (!validation.ok) { - sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { - errors: validation.errors - }, corsHeaders || {}); - return; - } - let repoPath = ''; - try { - repoPath = await resolveRepo(payload?.repoPath || payload?.repo); - } catch (err) { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); - return; - } - const searchParams = buildSearchParams(repoPath, payload || {}, defaultOutput); - if (!searchParams.ok) { - sendError( - res, - 400, - ERROR_CODES.INVALID_REQUEST, - searchParams.message || 'Invalid search payload.', - {}, - corsHeaders || {} - ); - return; - } - try { - const caches = getRepoCaches(repoPath); - await refreshBuildPointer(caches); - const body = await search(repoPath, { - args: searchParams.args, - query: searchParams.query, - emitOutput: false, - exitOnError: false, - indexCache: caches.indexCache, - sqliteCache: caches.sqliteCache, - signal: controller.signal - }); - sendJson(res, 200, { ok: true, result: body }, corsHeaders || {}); - } catch (err) { - if (req.aborted || res.writableEnded || controller.signal.aborted) return; - if (isNoIndexError(err)) { - sendError(res, 409, ERROR_CODES.NO_INDEX, err?.message || 'Index not found.', { - error: err?.message || String(err) - }, corsHeaders || {}); - return; + const prepared = await prepareSearchRequest({ + req, + res, + requestUrl, + routeName: 'search', + corsHeaders, + resolveRepo, + defaultOutput, + readPayload: ({ responseHeaders }) => { + const { payload, errors: queryErrors } = buildSearchPayloadFromQuery(requestUrl.searchParams); + if (Array.isArray(queryErrors) && queryErrors.length) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { + errors: queryErrors + }, responseHeaders); + return { ok: false }; + } + return { ok: true, payload }; } - sendError( - res, - 500, - ERROR_CODES.INTERNAL, - 'Search failed.', - { error: err?.message || String(err) }, - corsHeaders || {} - ); - } + }); + if (!prepared.ok) return; + await sendPreparedJsonSearch({ req, res, prepared }); return; } if (requestUrl.pathname === '/search/stream' && req.method === 'POST') { - const sse = createSseResponder(req, res, { headers: corsHeaders || {} }); - const controller = new AbortController(); - const abortRequest = () => controller.abort(); - req.on('aborted', abortRequest); - res.on('close', abortRequest); - res.on('error', abortRequest); - let raw; - try { - raw = await parseJsonBody(req); - } catch (err) { - const status = err?.code === 'ERR_BODY_TOO_LARGE' ? 413 - : err?.code === 'ERR_UNSUPPORTED_MEDIA_TYPE' ? 415 - : 400; - sendError( - res, - status, - ERROR_CODES.INVALID_REQUEST, - err?.message || 'Invalid request body.', - {}, - corsHeaders || {} - ); - return; - } - const payload = raw; - const validation = validateSearchPayload(payload); - if (!validation.ok) { - sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { - errors: validation.errors - }, corsHeaders || {}); - return; - } - let repoPath = ''; - try { - repoPath = await resolveRepo(payload?.repoPath || payload?.repo); - } catch (err) { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); - return; - } - const searchParams = buildSearchParams(repoPath, payload || {}, defaultOutput); - if (!searchParams.ok) { - sendError( - res, - 400, - ERROR_CODES.INVALID_REQUEST, - searchParams.message || 'Invalid search payload.', - {}, - corsHeaders || {} - ); - return; - } + let sse = null; + const prepared = await prepareSearchRequest({ + req, + res, + requestUrl, + routeName: 'search_stream', + corsHeaders, + parseJsonBody, + resolveRepo, + defaultOutput, + beforeBodyRead: ({ responseHeaders }) => { + sse = createSseResponder(req, res, { headers: responseHeaders }); + } + }); + if (!prepared.ok) return; + const { + requestObservability, + controller, + repoPath, + searchParams + } = prepared; await sse.sendHeaders(); - await sse.sendEvent('start', { ok: true }); - await sse.sendEvent('progress', { ok: true, phase: 'search', message: 'Searching.' }); - const caches = getRepoCaches(repoPath); - await refreshBuildPointer(caches); + await sse.sendEvent('start', attachObservability({ ok: true }, requestObservability)); + await sse.sendEvent('progress', attachObservability({ ok: true, phase: 'search', message: 'Searching.' }, requestObservability)); try { - await sse.sendEvent('progress', { ok: true, phase: 'search', message: 'Running search.' }); - const body = await search(repoPath, { - args: searchParams.args, - query: searchParams.query, - emitOutput: false, - exitOnError: false, - indexCache: caches.indexCache, - sqliteCache: caches.sqliteCache, - signal: controller.signal + await sse.sendEvent('progress', attachObservability({ ok: true, phase: 'search', message: 'Running search.' }, requestObservability)); + const body = await runPreparedSearch({ + requestObservability, + controller, + repoPath, + searchParams, + searchContext: { stream: true } }); if (!sse.isClosed()) { - await sse.sendEvent('result', { ok: true, result: body }); - await sse.sendEvent('done', { ok: true }); + await sse.sendEvent('result', attachObservability({ ok: true, result: body }, requestObservability)); + await sse.sendEvent('done', attachObservability({ ok: true }, requestObservability)); } } catch (err) { if (controller.signal.aborted || sse.isClosed()) { @@ -510,98 +602,30 @@ export const createApiRouter = ({ return; } const isNoIndex = isNoIndexError(err); - await sse.sendEvent('error', { + await sse.sendEvent('error', attachObservability({ ok: false, code: isNoIndex ? ERROR_CODES.NO_INDEX : ERROR_CODES.INTERNAL, message: err?.message || 'Search failed.' - }); - await sse.sendEvent('done', { ok: false }); + }, requestObservability)); + await sse.sendEvent('done', attachObservability({ ok: false }, requestObservability)); } sse.end(); return; } if (requestUrl.pathname === '/search' && req.method === 'POST') { - const controller = new AbortController(); - const abortRequest = () => controller.abort(); - req.on('aborted', abortRequest); - res.on('close', abortRequest); - res.on('error', abortRequest); - let payload = null; - try { - payload = await parseJsonBody(req); - } catch (err) { - const status = err?.code === 'ERR_BODY_TOO_LARGE' ? 413 - : err?.code === 'ERR_UNSUPPORTED_MEDIA_TYPE' ? 415 - : 400; - sendError( - res, - status, - ERROR_CODES.INVALID_REQUEST, - err?.message || 'Invalid request body.', - {}, - corsHeaders || {} - ); - return; - } - const validation = validateSearchPayload(payload); - if (!validation.ok) { - sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid search payload.', { - errors: validation.errors - }, corsHeaders || {}); - return; - } - let repoPath = ''; - try { - repoPath = await resolveRepo(payload?.repoPath || payload?.repo); - } catch (err) { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); - return; - } - const searchParams = buildSearchParams(repoPath, payload || {}, defaultOutput); - if (!searchParams.ok) { - sendError( - res, - 400, - ERROR_CODES.INVALID_REQUEST, - searchParams.message || 'Invalid search payload.', - {}, - corsHeaders || {} - ); - return; - } - try { - const caches = getRepoCaches(repoPath); - await refreshBuildPointer(caches); - const body = await search(repoPath, { - args: searchParams.args, - query: searchParams.query, - emitOutput: false, - exitOnError: false, - indexCache: caches.indexCache, - sqliteCache: caches.sqliteCache, - signal: controller.signal - }); - sendJson(res, 200, { ok: true, result: body }, corsHeaders || {}); - } catch (err) { - if (req.aborted || res.writableEnded) return; - if (isNoIndexError(err)) { - sendError(res, 409, ERROR_CODES.NO_INDEX, err?.message || 'Index not found.', { - error: err?.message || String(err) - }, corsHeaders || {}); - return; - } - sendError( - res, - 500, - ERROR_CODES.INTERNAL, - 'Search failed.', - { error: err?.message || String(err) }, - corsHeaders || {} - ); - } + const prepared = await prepareSearchRequest({ + req, + res, + requestUrl, + routeName: 'search', + corsHeaders, + parseJsonBody, + resolveRepo, + defaultOutput + }); + if (!prepared.ok) return; + await sendPreparedJsonSearch({ req, res, prepared, ignoreControllerAbort: true }); return; } diff --git a/tools/api/router/analysis.js b/tools/api/router/analysis.js new file mode 100644 index 000000000..70c7c2505 --- /dev/null +++ b/tools/api/router/analysis.js @@ -0,0 +1,256 @@ +import { loadUserConfig } from '../../shared/dict-utils.js'; +import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; +import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; +import { buildRiskDeltaPayload } from '../../../src/context-pack/risk-delta.js'; +import { buildRiskExplainPayload } from '../../analysis/explain-risk.js'; +import { projectRiskDeltaRequest, projectRiskExplainRequest } from '../../analysis/risk-request.js'; +import { buildCompositeContextPackPayload } from '../../../src/integrations/tooling/context-pack.js'; +import { buildContextPackRequestInput } from '../../../src/shared/context-pack-request.js'; +import { attachObservability, buildChildObservability } from '../../../src/shared/observability.js'; +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { sendError, sendJson } from '../response.js'; +import { + classifyWorkspaceRequestError, + parseJsonBodyOrSendError, + resolveRepoOrSendError +} from './request-helpers.js'; + +export async function handleRiskExplainRoute({ + req, + res, + corsHeaders, + observability, + parseJsonBody, + resolveRepo, + validateRiskExplainPayload +}) { + const parsedBody = await parseJsonBodyOrSendError(req, res, parseJsonBody, corsHeaders); + if (!parsedBody.ok) return true; + const payload = parsedBody.payload; + const validation = validateRiskExplainPayload(payload); + if (!validation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid risk explain request.', { + errors: validation.errors + }, corsHeaders || {}); + return true; + } + + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + payload.repoPath || payload.repo || '', + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; + const userConfig = loadUserConfig(repoPath); + const indexDir = resolveIndexDir(repoPath, 'code', userConfig); + if (!hasIndexMeta(indexDir)) { + sendError(res, 404, ERROR_CODES.NO_INDEX, 'Code index not found.', { + repoPath, + indexDir + }, corsHeaders || {}); + return true; + } + + const riskRequest = projectRiskExplainRequest(payload); + const { filters, filterValidation } = riskRequest; + if (!filterValidation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid risk filters.', { + errors: filterValidation.errors, + reason: 'invalid_risk_filters' + }, corsHeaders || {}); + return true; + } + + try { + const resultObservability = buildChildObservability(observability, { + surface: 'analysis', + operation: 'risk_explain', + context: { + repoRoot: repoPath, + chunkUid: riskRequest.chunkUid + } + }); + const result = await buildRiskExplainPayload({ + indexDir, + chunkUid: riskRequest.chunkUid, + max: riskRequest.max, + filters, + includePartialFlows: riskRequest.includePartialFlows, + maxPartialFlows: riskRequest.maxPartialFlows + }); + sendJson(res, 200, attachObservability({ ok: true, result }, resultObservability), corsHeaders || {}); + return true; + } catch (err) { + const message = err?.message || 'Failed to build risk explanation.'; + const isUnknownChunk = ( + err?.code === ERROR_CODES.INVALID_REQUEST && err?.reason === 'unknown_chunk_uid' + ) || /Unknown chunkUid/i.test(message); + const status = isUnknownChunk ? 400 : 500; + const code = status === 400 ? ERROR_CODES.INVALID_REQUEST : ERROR_CODES.INTERNAL; + sendError(res, status, code, message, isUnknownChunk ? { reason: 'unknown_chunk_uid' } : {}, corsHeaders || {}); + return true; + } +} + +export async function handleContextPackRoute({ + req, + res, + corsHeaders, + observability, + parseJsonBody, + resolveRepo, + validateContextPackPayload, + ensureWorkspaceAllowlist +}) { + const parsedBody = await parseJsonBodyOrSendError(req, res, parseJsonBody, corsHeaders); + if (!parsedBody.ok) return true; + const payload = parsedBody.payload; + const validation = validateContextPackPayload(payload); + if (!validation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid context-pack request.', { + errors: validation.errors + }, corsHeaders || {}); + return true; + } + + const workspaceRequested = typeof payload.workspacePath === 'string' && payload.workspacePath.trim(); + const requestedRepo = typeof payload.repoPath === 'string' && payload.repoPath.trim() + ? payload.repoPath + : typeof payload.repo === 'string' && payload.repo.trim() + ? payload.repo + : ''; + let repoPath = null; + if (!(workspaceRequested && !requestedRepo)) { + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestedRepo, + corsHeaders + ); + if (!resolvedRepo.ok) return true; + repoPath = resolvedRepo.repoPath; + } + try { + const workspaceConfig = workspaceRequested && typeof ensureWorkspaceAllowlist === 'function' + ? await ensureWorkspaceAllowlist(payload) + : null; + const resultObservability = buildChildObservability(observability, { + surface: 'analysis', + operation: 'context_pack', + context: repoPath + ? { + repoRoot: repoPath + } + : {} + }); + const result = await buildCompositeContextPackPayload( + buildContextPackRequestInput(payload, { + repoRoot: repoPath, + riskFilters: payload.filters || null, + workspaceConfig + }), + { + trustedWorkspaceConfig: Boolean(workspaceConfig) + } + ); + sendJson(res, 200, attachObservability({ ok: true, result }, resultObservability), corsHeaders || {}); + return true; + } catch (err) { + const message = err?.message || 'Failed to build context pack.'; + const workspaceRequestError = classifyWorkspaceRequestError(err); + const status = Number.isFinite(err?.status) ? err.status + : workspaceRequestError.status === 403 ? 403 + : err?.code === 'ERR_CONTEXT_PACK_NO_INDEX' ? 404 + : err?.code === 'ERR_CONTEXT_PACK_INVALID_REQUEST' + || err?.code === 'ERR_CONTEXT_PACK_RISK_FILTER_INVALID' + || err?.code === 'ERR_CONTEXT_PACK_STRICT_EVIDENCE' ? 400 + : 500; + const details = err?.code === 'ERR_CONTEXT_PACK_RISK_FILTER_INVALID' + ? { reason: 'invalid_risk_filters' } + : err?.code === 'ERR_CONTEXT_PACK_STRICT_EVIDENCE' + ? { reason: 'strict_evidence_incomplete', evidence: err?.evidence || null } + : err?.code === ERROR_CODES.FORBIDDEN ? { reason: 'workspace_not_permitted' } : {}; + const code = status === 400 ? ERROR_CODES.INVALID_REQUEST + : status === 403 ? ERROR_CODES.FORBIDDEN + : status === 404 ? ERROR_CODES.NO_INDEX + : ERROR_CODES.INTERNAL; + sendError(res, status, code, message, details, corsHeaders || {}); + return true; + } +} + +export async function handleRiskDeltaRoute({ + req, + res, + corsHeaders, + observability, + parseJsonBody, + resolveRepo, + validateRiskDeltaPayload +}) { + const parsedBody = await parseJsonBodyOrSendError(req, res, parseJsonBody, corsHeaders); + if (!parsedBody.ok) return true; + const payload = parsedBody.payload; + const validation = validateRiskDeltaPayload(payload); + if (!validation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid risk delta request.', { + errors: validation.errors + }, corsHeaders || {}); + return true; + } + + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + payload.repoPath || payload.repo || '', + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; + const riskRequest = projectRiskDeltaRequest(payload); + const { filters, filterValidation } = riskRequest; + if (!filterValidation.ok) { + sendError(res, 400, ERROR_CODES.INVALID_REQUEST, 'Invalid risk filters.', { + errors: filterValidation.errors, + reason: 'invalid_risk_filters' + }, corsHeaders || {}); + return true; + } + + try { + const userConfig = loadUserConfig(repoPath); + const resultObservability = buildChildObservability(observability, { + surface: 'analysis', + operation: 'risk_delta', + context: { + repoRoot: repoPath, + from: riskRequest.fromRef, + to: riskRequest.toRef + } + }); + const result = await buildRiskDeltaPayload({ + repoRoot: repoPath, + userConfig, + from: riskRequest.fromRef, + to: riskRequest.toRef, + seed: riskRequest.seed, + filters, + includePartialFlows: riskRequest.includePartialFlows + }); + sendJson(res, 200, attachObservability({ ok: true, result }, resultObservability), corsHeaders || {}); + return true; + } catch (err) { + const status = err?.code === ERROR_CODES.INVALID_REQUEST ? 400 + : err?.code === ERROR_CODES.NOT_FOUND ? 404 + : 500; + const code = status === 400 ? ERROR_CODES.INVALID_REQUEST + : status === 404 ? ERROR_CODES.NOT_FOUND + : ERROR_CODES.INTERNAL; + sendError(res, status, code, err?.message || 'Failed to build risk delta.', { + ...(err?.reason ? { reason: err.reason } : {}) + }, corsHeaders || {}); + return true; + } +} diff --git a/tools/api/router/cache.js b/tools/api/router/cache.js index dc5b47fdf..3fc1bf8d8 100644 --- a/tools/api/router/cache.js +++ b/tools/api/router/cache.js @@ -1,7 +1,7 @@ import { createRepoCacheManager as createSharedRepoCacheManager, normalizeCacheConfig -} from '../../shared/repo-cache-config.js'; +} from '../../../src/shared/repo-cache-config.js'; export { normalizeCacheConfig }; diff --git a/tools/api/router/index-diffs.js b/tools/api/router/index-diffs.js index b5f895c2d..2fb65ae4c 100644 --- a/tools/api/router/index-diffs.js +++ b/tools/api/router/index-diffs.js @@ -3,21 +3,7 @@ import { listDiffs, showDiff } from '../../../src/index/diffs/compute.js'; import { loadUserConfig } from '../../shared/dict-utils.js'; import { redactAbsolutePaths } from '../redact.js'; import { sendError, sendJson } from '../response.js'; - -const parseStringList = (value) => { - if (Array.isArray(value)) { - return value - .map((entry) => String(entry || '').trim()) - .filter(Boolean); - } - if (typeof value === 'string') { - return value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean); - } - return []; -}; +import { decodeRoutePathSegment, parseStringList, resolveRepoOrSendError } from './request-helpers.js'; const parseStringListFromSearchParams = (searchParams, keys) => { const values = []; @@ -55,6 +41,13 @@ const parseDiffFormat = (raw) => { throw err; }; +const toOptionalPositiveInt = (value) => { + if (value == null) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return null; + return Math.floor(parsed); +}; + /** * Parse diff event shaping/filter options from query parameters. * @@ -99,8 +92,8 @@ const shapeDiffEvents = (events, options) => { if (kindFilter && !kindFilter.has(String(entry?.kind || ''))) return false; return true; }); - const maxEvents = Number.isFinite(Number(options?.maxEvents)) ? Number(options.maxEvents) : null; - const maxBytes = Number.isFinite(Number(options?.maxBytes)) ? Number(options.maxBytes) : null; + const maxEvents = toOptionalPositiveInt(options?.maxEvents); + const maxBytes = toOptionalPositiveInt(options?.maxBytes); const limitedByEvents = maxEvents != null ? filtered.slice(0, maxEvents) : filtered; if (maxBytes == null) return limitedByEvents; const bounded = []; @@ -114,29 +107,6 @@ const shapeDiffEvents = (events, options) => { return bounded; }; -const handleRepoResolveError = (res, err, corsHeaders) => { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); -}; - -/** - * Decode diff id path segments and convert malformed URI encoding into a - * consistent INVALID_REQUEST error. - * - * @param {string} rawValue - * @returns {string} - */ -const decodeDiffId = (rawValue) => { - try { - return decodeURIComponent(rawValue || ''); - } catch { - const err = new Error('Invalid diff id: malformed URI encoding.'); - err.code = ERROR_CODES.INVALID_REQUEST; - throw err; - } -}; - export const handleIndexDiffsRoute = async ({ req, res, @@ -146,13 +116,14 @@ export const handleIndexDiffsRoute = async ({ resolveRepo }) => { if (pathname === '/index/diffs' && req.method === 'GET') { - let repoPath = ''; - try { - repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); - } catch (err) { - handleRepoResolveError(res, err, corsHeaders); - return true; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; try { const userConfig = loadUserConfig(repoPath); @@ -178,20 +149,21 @@ export const handleIndexDiffsRoute = async ({ return false; } - let repoPath = ''; - try { - repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); - } catch (err) { - handleRepoResolveError(res, err, corsHeaders); - return true; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; const suffix = pathname.slice(diffPrefix.length); if (!suffix) return false; const parts = suffix.split('/').filter(Boolean); let diffId = ''; try { - diffId = decodeDiffId(parts[0] || ''); + diffId = decodeRoutePathSegment(parts[0] || '', 'diff id'); } catch (err) { sendError(res, 400, ERROR_CODES.INVALID_REQUEST, err?.message || 'Invalid diff id.', {}, corsHeaders || {}); return true; diff --git a/tools/api/router/index-snapshots.js b/tools/api/router/index-snapshots.js index e2f5d553b..09651bf5b 100644 --- a/tools/api/router/index-snapshots.js +++ b/tools/api/router/index-snapshots.js @@ -3,44 +3,12 @@ import { createPointerSnapshot, listSnapshots, showSnapshot } from '../../../src import { loadUserConfig } from '../../shared/dict-utils.js'; import { redactAbsolutePaths } from '../redact.js'; import { sendError, sendJson } from '../response.js'; - -const parseStringList = (value) => { - if (Array.isArray(value)) { - return value - .map((entry) => String(entry || '').trim()) - .filter(Boolean); - } - if (typeof value === 'string') { - return value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean); - } - return []; -}; - -const handleRepoResolveError = (res, err, corsHeaders) => { - const code = err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST; - const status = err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400; - sendError(res, status, code, err?.message || 'Invalid repo path.', {}, corsHeaders || {}); -}; - -/** - * Decode snapshot id path segments and normalize malformed URI encoding into - * INVALID_REQUEST handling. - * - * @param {string} rawValue - * @returns {string} - */ -const decodeSnapshotId = (rawValue) => { - try { - return decodeURIComponent(rawValue || ''); - } catch { - const err = new Error('Invalid snapshot id: malformed URI encoding.'); - err.code = ERROR_CODES.INVALID_REQUEST; - throw err; - } -}; +import { + decodeRoutePathSegment, + parseJsonBodyOrSendError, + parseStringList, + resolveRepoOrSendError +} from './request-helpers.js'; /** * Parse JSON body and emit a consistent error response on parse failure. @@ -55,27 +23,6 @@ const decodeSnapshotId = (rawValue) => { * @param {object} corsHeaders * @returns {Promise<{ok:boolean,payload:any}>} */ -const parseBodyOrError = async (req, res, parseJsonBody, corsHeaders) => { - try { - return { ok: true, payload: await parseJsonBody(req) }; - } catch (err) { - const status = err?.code === 'ERR_BODY_TOO_LARGE' - ? 413 - : err?.code === 'ERR_UNSUPPORTED_MEDIA_TYPE' - ? 415 - : 400; - sendError( - res, - status, - ERROR_CODES.INVALID_REQUEST, - err?.message || 'Invalid request body.', - {}, - corsHeaders || {} - ); - return { ok: false, payload: null }; - } -}; - export const handleIndexSnapshotsRoute = async ({ req, res, @@ -86,13 +33,14 @@ export const handleIndexSnapshotsRoute = async ({ parseJsonBody }) => { if (pathname === '/index/snapshots' && req.method === 'GET') { - let repoPath = ''; - try { - repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); - } catch (err) { - handleRepoResolveError(res, err, corsHeaders); - return true; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; try { const userConfig = loadUserConfig(repoPath); @@ -113,7 +61,7 @@ export const handleIndexSnapshotsRoute = async ({ } if (pathname === '/index/snapshots' && req.method === 'POST') { - const parsedBody = await parseBodyOrError(req, res, parseJsonBody, corsHeaders); + const parsedBody = await parseJsonBodyOrSendError(req, res, parseJsonBody, corsHeaders); if (!parsedBody.ok) return true; const payload = parsedBody.payload; if (payload == null) { @@ -128,13 +76,14 @@ export const handleIndexSnapshotsRoute = async ({ return true; } - let repoPath = ''; - try { - repoPath = await resolveRepo(payload?.repoPath || payload?.repo || requestUrl.searchParams.get('repo')); - } catch (err) { - handleRepoResolveError(res, err, corsHeaders); - return true; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + payload?.repoPath || payload?.repo || requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; try { const userConfig = loadUserConfig(repoPath); @@ -145,6 +94,7 @@ export const handleIndexSnapshotsRoute = async ({ tags: parseStringList(payload?.tags), modes: parseStringList(payload?.modes), snapshotId: typeof payload?.snapshotId === 'string' ? payload.snapshotId : null, + retentionTier: typeof payload?.retentionTier === 'string' ? payload.retentionTier : null, waitMs: Number.isFinite(Number(payload?.waitMs)) ? Math.max(0, Math.floor(Number(payload.waitMs))) : 0 }); sendJson(res, 200, { @@ -171,7 +121,7 @@ export const handleIndexSnapshotsRoute = async ({ if (pathname.startsWith(snapshotPrefix) && req.method === 'GET') { let snapshotId = ''; try { - snapshotId = decodeSnapshotId(pathname.slice(snapshotPrefix.length)); + snapshotId = decodeRoutePathSegment(pathname.slice(snapshotPrefix.length), 'snapshot id'); } catch (err) { sendError( res, @@ -185,13 +135,14 @@ export const handleIndexSnapshotsRoute = async ({ } if (!snapshotId) return false; - let repoPath = ''; - try { - repoPath = await resolveRepo(requestUrl.searchParams.get('repo')); - } catch (err) { - handleRepoResolveError(res, err, corsHeaders); - return true; - } + const resolvedRepo = await resolveRepoOrSendError( + res, + resolveRepo, + requestUrl.searchParams.get('repo'), + corsHeaders + ); + if (!resolvedRepo.ok) return true; + const repoPath = resolvedRepo.repoPath; try { const userConfig = loadUserConfig(repoPath); diff --git a/tools/api/router/request-helpers.js b/tools/api/router/request-helpers.js new file mode 100644 index 000000000..a15249f34 --- /dev/null +++ b/tools/api/router/request-helpers.js @@ -0,0 +1,108 @@ +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { sendError } from '../response.js'; + +export const parseStringList = (value) => { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +}; + +export const decodeRoutePathSegment = (rawValue, label) => { + try { + return decodeURIComponent(rawValue || ''); + } catch { + const normalizedLabel = String(label || 'path segment').trim() || 'path segment'; + const err = new Error(`Invalid ${normalizedLabel}: malformed URI encoding.`); + err.code = ERROR_CODES.INVALID_REQUEST; + throw err; + } +}; + +export const classifyBodyParseError = (err, fallbackMessage = 'Invalid request body.') => ({ + status: err?.code === 'ERR_BODY_TOO_LARGE' + ? 413 + : err?.code === 'ERR_UNSUPPORTED_MEDIA_TYPE' + ? 415 + : 400, + code: ERROR_CODES.INVALID_REQUEST, + message: err?.message || fallbackMessage +}); + +export const classifyRepoResolveError = (err, fallbackMessage = 'Invalid repo path.') => ({ + status: err?.code === ERROR_CODES.FORBIDDEN ? 403 : 400, + code: err?.code === ERROR_CODES.FORBIDDEN ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST, + message: err?.message || fallbackMessage +}); + +export const classifyWorkspaceRequestError = (err, fallbackMessage = 'Invalid workspace request.') => { + const forbidden = err?.code === ERROR_CODES.FORBIDDEN + || String(err?.message || '').toLowerCase().includes('not permitted'); + return { + status: forbidden ? 403 : 400, + code: forbidden ? ERROR_CODES.FORBIDDEN : ERROR_CODES.INVALID_REQUEST, + message: err?.message || fallbackMessage + }; +}; + +export const sendClassifiedRequestError = ( + res, + classification, + corsHeaders, + details = {} +) => { + sendError( + res, + classification?.status || 500, + classification?.code || ERROR_CODES.INTERNAL, + classification?.message || 'Request failed.', + details, + corsHeaders || {} + ); +}; + +export const parseJsonBodyOrSendError = async ( + req, + res, + parseJsonBody, + corsHeaders, + fallbackMessage = 'Invalid request body.' +) => { + try { + return { ok: true, payload: await parseJsonBody(req) }; + } catch (err) { + sendClassifiedRequestError( + res, + classifyBodyParseError(err, fallbackMessage), + corsHeaders + ); + return { ok: false, payload: null }; + } +}; + +export const resolveRepoOrSendError = async ( + res, + resolveRepo, + repoValue, + corsHeaders, + fallbackMessage = 'Invalid repo path.' +) => { + try { + return { ok: true, repoPath: await resolveRepo(repoValue) }; + } catch (err) { + sendClassifiedRequestError( + res, + classifyRepoResolveError(err, fallbackMessage), + corsHeaders + ); + return { ok: false, repoPath: '' }; + } +}; diff --git a/tools/api/router/search.js b/tools/api/router/search.js index d0a07ac26..479a9cdb3 100644 --- a/tools/api/router/search.js +++ b/tools/api/router/search.js @@ -1,4 +1,4 @@ -import { buildSearchRequestArgs } from '../../shared/search-request.js'; +import { buildSearchRequestArgs } from '../../../src/shared/search-request.js'; export const buildSearchParams = (_repoPath, payload, defaultOutput) => { const result = buildSearchRequestArgs(payload, { diff --git a/tools/api/router/workspace-allowlist.js b/tools/api/router/workspace-allowlist.js new file mode 100644 index 000000000..16ffab15d --- /dev/null +++ b/tools/api/router/workspace-allowlist.js @@ -0,0 +1,76 @@ +import path from 'node:path'; + +import { ERROR_CODES } from '../../../src/shared/error-codes.js'; +import { loadWorkspaceConfig } from '../../../src/workspace/config.js'; +import { resolveFederationCacheRoot } from '../../../src/workspace/manifest.js'; +import { isWithinRoot, toRealPathSync } from '../../shared/dict-utils.js'; + +export const resolveWorkspacePathFromPayload = (payload) => { + const value = payload?.workspacePath; + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return path.resolve(trimmed); +}; + +const createForbiddenError = (message) => { + const err = new Error(message); + err.code = ERROR_CODES.FORBIDDEN; + return err; +}; + +export const createWorkspaceAllowlist = ({ + defaultRepo, + allowedRepoRoots = [], + resolveRepo, + loadWorkspaceConfigFn = loadWorkspaceConfig, + resolveFederationCacheRootFn = resolveFederationCacheRoot +} = {}) => { + const canonicalConfiguredAllowedRoots = [defaultRepo, ...allowedRepoRoots] + .filter((entry) => typeof entry === 'string' && entry.trim()) + .map((entry) => toRealPathSync(path.resolve(entry))); + const canonicalWorkspacePolicyRoots = Array.from(new Set([ + ...canonicalConfiguredAllowedRoots, + // Always include the default federation cache root so explicit repo-root + // allowlists do not accidentally block workspace-path/cache-root workflows. + resolveFederationCacheRootFn(null) + ])); + + const isAllowedWorkspacePath = (workspacePath) => { + if (!canonicalWorkspacePolicyRoots.length) return true; + const workspaceCanonical = toRealPathSync(workspacePath); + return canonicalWorkspacePolicyRoots.some((root) => isWithinRoot(workspaceCanonical, root)); + }; + + const ensureWorkspaceAllowlist = async (payload) => { + const resolvedWorkspacePath = resolveWorkspacePathFromPayload(payload); + if (!resolvedWorkspacePath) { + throw new Error('Federated search requires workspacePath.'); + } + if (!isAllowedWorkspacePath(resolvedWorkspacePath)) { + throw createForbiddenError('Workspace path not permitted by server configuration.'); + } + + const workspaceConfig = loadWorkspaceConfigFn(resolvedWorkspacePath); + if (typeof resolveRepo === 'function') { + for (const repo of workspaceConfig.repos) { + await resolveRepo(repo.repoRootCanonical); + } + } + + const federationCacheRoot = resolveFederationCacheRootFn(workspaceConfig); + if (!isAllowedWorkspacePath(federationCacheRoot)) { + throw createForbiddenError('Workspace cache root not permitted by server configuration.'); + } + if (payload?.workspaceId && payload.workspaceId !== workspaceConfig.repoSetId) { + throw new Error('workspaceId does not match the provided workspacePath.'); + } + return workspaceConfig; + }; + + return { + canonicalWorkspacePolicyRoots, + ensureWorkspaceAllowlist, + isAllowedWorkspacePath + }; +}; diff --git a/tools/api/server.js b/tools/api/server.js index 13c95088e..44644f81b 100644 --- a/tools/api/server.js +++ b/tools/api/server.js @@ -3,12 +3,17 @@ import http from 'node:http'; import path from 'node:path'; import { createCli } from '../../src/shared/cli.js'; import { SERVICE_API_OPTIONS } from '../../src/shared/cli-options.js'; +import { parseCommaList } from '../../src/shared/comma-list.js'; import { resolveRepoRootArg } from '../shared/dict-utils.js'; -import { parseCommaList } from '../shared/text-utils.js'; -import { getMetricsRegistry } from '../../src/shared/metrics.js'; +import { getMetricsRegistry } from '../../src/shared/metrics/core.js'; import { createApiRouter } from './router.js'; import { configureServiceLogger } from '../service/logger.js'; -import { getEnvSecrets } from '../../src/shared/env.js'; +import { getEnvSecrets } from '../../src/shared/env/runtime.js'; +import { + evaluateApiTrustBoundary, + formatApiTrustBoundarySummary, + validateApiTrustBoundary +} from './trust-boundary.js'; const argv = createCli({ scriptName: 'api-server', @@ -23,11 +28,6 @@ const jsonOutput = argv.json === true; const quiet = argv.quiet === true; const metricsRegistry = getMetricsRegistry(); const { logLine } = configureServiceLogger({ repoRoot: defaultRepo, service: 'api' }); -const isLocalHost = (value) => { - if (!value) return false; - const normalized = String(value).trim().toLowerCase(); - return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'; -}; const formatHostForUrl = (value) => { if (!value) return 'localhost'; const normalized = String(value).trim(); @@ -38,18 +38,24 @@ const formatHostForUrl = (value) => { }; const allowUnauthenticated = argv['allow-unauthenticated'] === true; const authToken = String(argv['auth-token'] || envSecrets.apiToken || '').trim(); -const hostIsLocal = isLocalHost(host); -if (!allowUnauthenticated && !hostIsLocal && !authToken) { - console.error( - 'api-server requires PAIROFCLEATS_API_TOKEN when binding to non-localhost. ' - + 'Use --allow-unauthenticated to override.' - ); - process.exit(1); -} -const authRequired = !allowUnauthenticated && (!hostIsLocal || Boolean(authToken)); const corsAllowedOrigins = parseCommaList(argv['cors-allowed-origins']); const corsAllowAny = argv['cors-allow-any'] === true; const allowedRepoRoots = parseCommaList(argv['allowed-repo-roots']); +const trustBoundary = evaluateApiTrustBoundary({ + host, + defaultRepo, + allowedRepoRoots, + allowUnauthenticated, + authToken, + corsAllowAny +}); +const boundaryIssues = validateApiTrustBoundary(trustBoundary); +if (boundaryIssues.length > 0) { + for (const issue of boundaryIssues) { + console.error(issue); + } + process.exit(1); +} const maxBodyBytes = Number.isFinite(Number(argv['max-body-bytes'])) ? Math.max(0, Math.floor(Number(argv['max-body-bytes']))) : null; @@ -70,10 +76,11 @@ const router = createApiRouter({ }, auth: { token: authToken || null, - required: authRequired + required: trustBoundary.auth.required }, allowedRepoRoots, - maxBodyBytes: maxBodyBytes ?? undefined + maxBodyBytes: maxBodyBytes ?? undefined, + trustBoundary }); const server = http.createServer(router.handleRequest); @@ -83,10 +90,14 @@ server.listen({ port, host }, () => { const actualPort = typeof address === 'object' && address ? address.port : port; const baseUrl = `http://${formatHostForUrl(host)}:${actualPort}`; if (jsonOutput) { - console.log(JSON.stringify({ ok: true, host, port: actualPort, repo: defaultRepo, baseUrl })); + console.log(JSON.stringify({ ok: true, host, port: actualPort, repo: defaultRepo, baseUrl, trustBoundary })); } else { log(`[api] listening at ${baseUrl}`); log(`[api] repo root: ${defaultRepo}`); + log(`[api] trust boundary: ${formatApiTrustBoundarySummary(trustBoundary)}`); + if (trustBoundary.repos?.effectiveAllowedRepoRoots?.length) { + log(`[api] allowed repo roots: ${trustBoundary.repos.effectiveAllowedRepoRoots.join(', ')}`); + } } }); diff --git a/tools/api/trust-boundary.js b/tools/api/trust-boundary.js new file mode 100644 index 000000000..8ac1a4645 --- /dev/null +++ b/tools/api/trust-boundary.js @@ -0,0 +1,131 @@ +import path from 'node:path'; +import { resolveFederationCacheRoot } from '../../src/workspace/manifest.js'; +import { toRealPathSync } from '../shared/dict-utils.js'; + +export const isLocalHost = (value) => { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'; +}; + +const normalizeRoots = (values) => Array.from(new Set( + (Array.isArray(values) ? values : []) + .filter((entry) => typeof entry === 'string' && entry.trim()) + .map((entry) => toRealPathSync(path.resolve(entry))) +)); + +export const evaluateApiTrustBoundary = ({ + host, + defaultRepo, + allowedRepoRoots = [], + allowUnauthenticated = false, + authToken = '', + corsAllowAny = false +}) => { + const normalizedHost = String(host || '127.0.0.1').trim() || '127.0.0.1'; + const hostIsLocal = isLocalHost(normalizedHost); + const tokenConfigured = typeof authToken === 'string' && authToken.trim().length > 0; + const authRequired = !allowUnauthenticated && (!hostIsLocal || tokenConfigured); + const normalizedDefaultRepo = defaultRepo ? toRealPathSync(path.resolve(defaultRepo)) : null; + const additionalAllowedRepoRoots = normalizeRoots(allowedRepoRoots); + const effectiveAllowedRepoRoots = Array.from(new Set( + [normalizedDefaultRepo, ...additionalAllowedRepoRoots].filter(Boolean) + )); + const federationCacheRoot = toRealPathSync(resolveFederationCacheRoot(null)); + const workspacePolicyRoots = Array.from(new Set([ + ...effectiveAllowedRepoRoots, + federationCacheRoot + ])); + const repoMode = additionalAllowedRepoRoots.length > 0 ? 'allowlisted' : 'default-repo-only'; + const exposure = hostIsLocal ? 'local-only' : 'non-local'; + const authentication = authRequired ? 'token-required' : 'unauthenticated'; + const summary = [ + exposure, + authentication, + repoMode === 'allowlisted' ? 'repo-allowlisted' : 'default-repo-only' + ].join(' | '); + return { + bind: { + host: normalizedHost, + scope: hostIsLocal ? 'local' : 'non-local' + }, + auth: { + required: authRequired, + tokenConfigured, + allowUnauthenticated: allowUnauthenticated === true, + mode: authRequired ? 'token' : 'none' + }, + repos: { + defaultRepo: normalizedDefaultRepo, + additionalAllowedRepoRoots, + effectiveAllowedRepoRoots, + mode: repoMode + }, + workspaces: { + policyRoots: workspacePolicyRoots, + federationCacheRoot + }, + cors: { + allowAnyOrigin: corsAllowAny === true + }, + effectiveBoundary: { + exposure, + authentication, + summary + } + }; +}; + +export const buildApiTrustBoundaryStatusView = (trustBoundary) => ({ + bind: { + scope: trustBoundary?.bind?.scope || 'unknown' + }, + auth: { + required: trustBoundary?.auth?.required === true, + mode: trustBoundary?.auth?.mode || 'unknown' + }, + repos: { + mode: trustBoundary?.repos?.mode || 'unknown', + allowedRepoRootCount: Array.isArray(trustBoundary?.repos?.effectiveAllowedRepoRoots) + ? trustBoundary.repos.effectiveAllowedRepoRoots.length + : 0 + }, + workspaces: { + policyRootCount: Array.isArray(trustBoundary?.workspaces?.policyRoots) + ? trustBoundary.workspaces.policyRoots.length + : 0 + }, + cors: { + allowAnyOrigin: trustBoundary?.cors?.allowAnyOrigin === true + }, + effectiveBoundary: { + exposure: trustBoundary?.effectiveBoundary?.exposure || 'unknown', + authentication: trustBoundary?.effectiveBoundary?.authentication || 'unknown', + summary: trustBoundary?.effectiveBoundary?.summary || 'unknown' + } +}); + +export const validateApiTrustBoundary = (trustBoundary) => { + const issues = []; + if (!trustBoundary || typeof trustBoundary !== 'object') return issues; + if (trustBoundary.bind?.scope === 'non-local' && trustBoundary.auth?.tokenConfigured !== true) { + issues.push( + 'api-server requires PAIROFCLEATS_API_TOKEN or --auth-token when binding to non-localhost.' + ); + } + if (trustBoundary.bind?.scope === 'non-local' && trustBoundary.auth?.allowUnauthenticated === true) { + issues.push( + 'api-server refuses --allow-unauthenticated when binding to non-localhost.' + ); + } + if (trustBoundary.bind?.scope === 'non-local' && trustBoundary.cors?.allowAnyOrigin === true) { + issues.push( + 'api-server refuses --cors-allow-any when binding to non-localhost.' + ); + } + return issues; +}; + +export const formatApiTrustBoundarySummary = (trustBoundary) => ( + trustBoundary?.effectiveBoundary?.summary || 'unknown' +); diff --git a/tools/api/validation.js b/tools/api/validation.js index f953ad2a6..fbcef7bdc 100644 --- a/tools/api/validation.js +++ b/tools/api/validation.js @@ -3,7 +3,7 @@ import { INTEGER_MIN_ZERO_FLAG_FIELDS, REPEATED_LIST_FIELDS, STRING_FLAG_FIELDS -} from '../shared/search-request.js'; +} from '../../src/shared/search-request.js'; const stringListSchema = { anyOf: [ @@ -153,6 +153,104 @@ const federatedSearchSchema = { } }; +const contextPackSelectSchema = { + anyOf: [ + stringListSchema, + federatedSelectionSchema + ] +}; + +const riskFiltersSchema = { + type: 'object', + additionalProperties: false, + properties: { + rule: stringListSchema, + category: stringListSchema, + severity: stringListSchema, + tag: stringListSchema, + source: stringListSchema, + sink: stringListSchema, + flowId: stringListSchema, + flow_id: stringListSchema, + 'flow-id': stringListSchema, + sourceRule: stringListSchema, + source_rule: stringListSchema, + 'source-rule': stringListSchema, + sinkRule: stringListSchema, + sink_rule: stringListSchema, + 'sink-rule': stringListSchema + } +}; + +const riskExplainSchema = { + type: 'object', + additionalProperties: false, + required: ['chunk'], + properties: { + repoPath: { type: 'string' }, + repo: { type: 'string' }, + chunk: { type: 'string', minLength: 1 }, + max: { type: 'integer', minimum: 1 }, + includePartialFlows: { type: 'boolean' }, + maxPartialFlows: { type: 'integer', minimum: 1 }, + filters: riskFiltersSchema + } +}; + +const riskDeltaSchema = { + type: 'object', + additionalProperties: false, + required: ['seed', 'from', 'to'], + properties: { + repoPath: { type: 'string' }, + repo: { type: 'string' }, + seed: { type: 'string', minLength: 1 }, + from: { type: 'string', minLength: 1 }, + to: { type: 'string', minLength: 1 }, + includePartialFlows: { type: 'boolean' }, + filters: riskFiltersSchema + } +}; + +const contextPackSchema = { + type: 'object', + additionalProperties: false, + required: ['seed', 'hops'], + properties: { + repoPath: { type: 'string' }, + repo: { type: 'string' }, + workspacePath: { type: 'string', minLength: 1 }, + workspaceId: { type: 'string', minLength: 1 }, + select: contextPackSelectSchema, + includeDisabled: { type: 'boolean' }, + maxFederatedRepos: { type: 'integer', minimum: 1 }, + seed: { type: 'string', minLength: 1 }, + hops: { type: 'integer', minimum: 0 }, + includeGraph: { type: 'boolean' }, + includeTypes: { type: 'boolean' }, + includeRisk: { type: 'boolean' }, + includeRiskPartialFlows: { type: 'boolean' }, + strictRisk: { type: 'boolean' }, + strictEvidence: { type: 'boolean' }, + includeImports: { type: 'boolean' }, + includeUsages: { type: 'boolean' }, + includeCallersCallees: { type: 'boolean' }, + includePaths: { type: 'boolean' }, + maxBytes: { type: 'integer', minimum: 0 }, + maxTokens: { type: 'integer', minimum: 0 }, + maxTypeEntries: { type: 'integer', minimum: 0 }, + maxDepth: { type: 'integer', minimum: 0 }, + maxFanoutPerNode: { type: 'integer', minimum: 0 }, + maxNodes: { type: 'integer', minimum: 0 }, + maxEdges: { type: 'integer', minimum: 0 }, + maxPaths: { type: 'integer', minimum: 0 }, + maxCandidates: { type: 'integer', minimum: 0 }, + maxWorkUnits: { type: 'integer', minimum: 0 }, + maxWallClockMs: { type: 'integer', minimum: 0 }, + filters: riskFiltersSchema + } +}; + const formatValidationErrors = (errors = []) => errors.map((err) => { const path = err.instancePath || '#'; if (err.keyword === 'additionalProperties') { @@ -183,3 +281,33 @@ export const createFederatedSearchValidator = () => { return { ok: false, errors: formatValidationErrors(validateFederatedSearch.errors || []) }; }; }; + +export const createRiskExplainValidator = () => { + const ajv = createAjv({ allErrors: false, strict: false }); + const validateRiskExplain = compileSchema(ajv, riskExplainSchema); + return (payload) => { + const valid = validateRiskExplain(payload); + if (valid) return { ok: true }; + return { ok: false, errors: formatValidationErrors(validateRiskExplain.errors || []) }; + }; +}; + +export const createRiskDeltaValidator = () => { + const ajv = createAjv({ allErrors: false, strict: false }); + const validateRiskDelta = compileSchema(ajv, riskDeltaSchema); + return (payload) => { + const valid = validateRiskDelta(payload); + if (valid) return { ok: true }; + return { ok: false, errors: formatValidationErrors(validateRiskDelta.errors || []) }; + }; +}; + +export const createContextPackValidator = () => { + const ajv = createAjv({ allErrors: false, strict: false }); + const validateContextPack = compileSchema(ajv, contextPackSchema); + return (payload) => { + const valid = validateContextPack(payload); + if (valid) return { ok: true }; + return { ok: false, errors: formatValidationErrors(validateContextPack.errors || []) }; + }; +}; diff --git a/tools/bench/ab-sweep.js b/tools/bench/ab-sweep.js index ae78584aa..a1f35262a 100644 --- a/tools/bench/ab-sweep.js +++ b/tools/bench/ab-sweep.js @@ -2,8 +2,10 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { exitLikeCommandResult } from '../shared/cli-utils.js'; +import { spawnSubprocessSync } from '../../src/shared/subprocess/runner.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { parseTrailingJson } from './output.js'; const ROOT = process.cwd(); const BENCH_RUNNER = path.join(ROOT, 'tools', 'bench', 'bench-runner.js'); @@ -109,23 +111,6 @@ const parseArgs = () => { return out; }; -const parseJson = (text) => { - const raw = String(text || '').trim(); - if (!raw) return null; - if (raw.startsWith('{') || raw.startsWith('[')) { - try { - return JSON.parse(raw); - } catch {} - } - const match = raw.match(/\{[\s\S]*\}\s*$/); - if (!match) return null; - try { - return JSON.parse(match[0]); - } catch { - return null; - } -}; - /** * Build a cartesian product of knob values. Empty knobs are treated as [null] * so partial sweeps only touch explicitly requested dimensions. @@ -206,13 +191,23 @@ const runVariant = ({ argv, variant, runIndex, runRoot }) => { env.PAIROFCLEATS_TEST_CONFIG = JSON.stringify(testConfig); } const startedAt = Date.now(); - const result = spawnSync(process.execPath, args, { cwd: ROOT, env, encoding: 'utf8' }); + const result = spawnSubprocessSync(process.execPath, args, { + cwd: ROOT, + env, + outputEncoding: 'utf8', + captureStdout: true, + captureStderr: true, + outputMode: 'string', + rejectOnNonZeroExit: false, + killTree: true, + detached: process.platform !== 'win32' + }); const durationMs = Date.now() - startedAt; - const exitCode = Number.isInteger(result.status) ? Number(result.status) : null; + const exitCode = Number.isInteger(result.exitCode) ? Number(result.exitCode) : null; const signal = typeof result.signal === 'string' && result.signal.trim().length > 0 ? result.signal.trim() : null; - const report = parseJson(result.stdout) + const report = parseTrailingJson(result.stdout) || ((exitCode === 0 && !signal) ? null : { summary: { error: 1, timeout: 0 }, results: [] }); return { runId, @@ -300,8 +295,7 @@ const main = async () => { }; if (argv.json) { const outPath = path.isAbsolute(argv.json) ? argv.json : path.join(ROOT, argv.json); - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); + await writeJsonFileResolved(outPath, output, { trailingNewline: true }); } process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); }; diff --git a/tools/bench/artifact-io/artifact-io-throughput.js b/tools/bench/artifact-io/artifact-io-throughput.js index 8c1f44c6e..6eccc174b 100644 --- a/tools/bench/artifact-io/artifact-io-throughput.js +++ b/tools/bench/artifact-io/artifact-io-throughput.js @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesShardedAsync } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesShardedAsync } from '../../../src/shared/json-stream/jsonl-sharded.js'; import { readJsonLinesArray } from '../../../src/shared/artifact-io/json.js'; const parseArgs = (argv) => { diff --git a/tools/bench/artifact-io/jsonl-offset-index.js b/tools/bench/artifact-io/jsonl-offset-index.js index 94b297e62..1607fcbed 100644 --- a/tools/bench/artifact-io/jsonl-offset-index.js +++ b/tools/bench/artifact-io/jsonl-offset-index.js @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { readJsonLinesArray } from '../../../src/shared/artifact-io/json.js'; import { readJsonlRowAt } from '../../../src/shared/artifact-io/offsets.js'; diff --git a/tools/bench/artifact-io/streaming-vs-materialize.js b/tools/bench/artifact-io/streaming-vs-materialize.js index 7b91fe767..7497264d5 100644 --- a/tools/bench/artifact-io/streaming-vs-materialize.js +++ b/tools/bench/artifact-io/streaming-vs-materialize.js @@ -1,8 +1,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { readJsonLinesArray, readJsonLinesIterator } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { readJsonLinesArray, readJsonLinesIterator } from '../../../src/shared/artifact-io/json.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; const parseArgs = (argv) => { const args = { rows: 50000 }; diff --git a/tools/bench/bench-diff.js b/tools/bench/bench-diff.js index 3d7805111..6d88813c3 100644 --- a/tools/bench/bench-diff.js +++ b/tools/bench/bench-diff.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; +import { buildBenchRunDiff } from './language/diff.js'; const parseArgs = () => { const out = { before: null, after: null, json: false }; @@ -70,6 +71,39 @@ const main = async () => { const afterPath = path.resolve(argv.after); const before = JSON.parse(await fs.readFile(beforePath, 'utf8')); const after = JSON.parse(await fs.readFile(afterPath, 'utf8')); + if (Array.isArray(before?.tasks) && before?.run && Array.isArray(after?.tasks) && after?.run) { + const diff = buildBenchRunDiff({ before, after }); + if (argv.json) { + process.stdout.write(`${JSON.stringify(diff, null, 2)}\n`); + return; + } + console.log(`bench-language diff (${diff.byLanguage.length} languages, ${diff.byRepo.length} repos)`); + for (const row of diff.byLanguage) { + console.log( + `${row.language}: buildIndex ${row.buildIndexMs?.before ?? 'n/a'} -> ${row.buildIndexMs?.after ?? 'n/a'} ` + + `| crashes ${row.crashCount?.before ?? 'n/a'} -> ${row.crashCount?.after ?? 'n/a'} ` + + `| timeouts ${row.timeoutCount?.before ?? 'n/a'} -> ${row.timeoutCount?.after ?? 'n/a'} ` + + `| degradations ${row.degradationCount?.before ?? 'n/a'} -> ${row.degradationCount?.after ?? 'n/a'}` + ); + } + const topOwnershipRegressions = Array.isArray(diff?.ownership?.topRegressions) + ? diff.ownership.topRegressions + : []; + if (topOwnershipRegressions.length) { + console.log('ownership hotspot regressions:'); + for (const row of topOwnershipRegressions.slice(0, 5)) { + console.log( + `${row.family}: guardrails ${row.breachedGuardrails?.before ?? 'n/a'} -> ${row.breachedGuardrails?.after ?? 'n/a'} ` + + `| buildIndex ${row.buildIndexMsAvg?.before ?? 'n/a'} -> ${row.buildIndexMsAvg?.after ?? 'n/a'} ` + + `| sqliteRss ${row.sqliteAvgMb?.before ?? 'n/a'} -> ${row.sqliteAvgMb?.after ?? 'n/a'} ` + + `| intra ${row.intraRunHitRate?.before ?? 'n/a'} -> ${row.intraRunHitRate?.after ?? 'n/a'} ` + + `| cross ${row.crossRunHitRate?.before ?? 'n/a'} -> ${row.crossRunHitRate?.after ?? 'n/a'} ` + + `| dominant ${row.dominantPhase?.before ?? 'n/a'} -> ${row.dominantPhase?.after ?? 'n/a'}` + ); + } + } + return; + } const indexResults = (report) => { const out = new Map(); diff --git a/tools/bench/bench-runner.js b/tools/bench/bench-runner.js index 67d7793b9..415ee0e76 100644 --- a/tools/bench/bench-runner.js +++ b/tools/bench/bench-runner.js @@ -1,11 +1,12 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; -import { getEnvConfig } from '../../src/shared/env.js'; +import { getEnvConfig } from '../../src/shared/env/runtime.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { spawnSubprocessSync } from '../../src/shared/subprocess/runner.js'; +import { parseTrailingJson } from './output.js'; import { resolveBenchSuite } from './suites/sweet16.js'; const MAX_UTILIZATION_SAMPLES = 2048; @@ -115,7 +116,11 @@ const parseKeyValueMetrics = (line) => { while (match) { const key = match[1]; const raw = match[2]; - const numeric = Number(raw.replace(/[^0-9.+-]/g, '')); + const cleaned = raw.replace(/[),;]+$/g, ''); + const numeric = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:%|ms|s|sec|secs|seconds|b|kb|mb|gb|kib|mib|gib|\/s)?$/i + .test(cleaned) + ? Number(cleaned.replace(/[^0-9.+-]/g, '')) + : null; metrics[key] = Number.isFinite(numeric) ? numeric : raw; match = re.exec(line); } @@ -141,18 +146,24 @@ const parseBenchOutput = (output) => { .map((line) => line.trim()) .filter(Boolean); const benchLines = lines.filter((line) => line.startsWith('[bench]')); - const classify = (label) => ( - benchLines.find((line) => ( - /^\[bench\]\s+(?:[^\s]+\s+)*?(baseline|current|delta)\b/i.test(line) - && line.toLowerCase().includes(` ${label}`) - )) || null - ); - const baselineLine = benchLines.find((line) => /^\[bench\]\s+baseline\b/i.test(line)) - || classify('baseline'); - const currentLine = benchLines.find((line) => /^\[bench\]\s+current\b/i.test(line)) - || classify('current'); - const deltaLine = benchLines.find((line) => /^\[bench\]\s+delta\b/i.test(line)) - || classify('delta'); + const resolveLabel = (line) => { + const body = line.replace(/^\[bench\]\s+/i, ''); + for (const token of body.split(/\s+/g)) { + const normalized = token.replace(/[:;,]+$/g, '').toLowerCase(); + if (normalized === 'baseline' || normalized === 'current' || normalized === 'delta') { + return normalized; + } + if (token.includes('=') || token === '|' || token.startsWith('{') || token.startsWith('[')) { + return null; + } + } + return null; + }; + const records = benchLines.map((line) => ({ line, label: resolveLabel(line) })); + const findLabel = (label) => records.find((entry) => entry.label === label)?.line || null; + const baselineLine = findLabel('baseline'); + const currentLine = findLabel('current'); + const deltaLine = findLabel('delta'); return { baseline: baselineLine ? { line: baselineLine, metrics: parseKeyValueMetrics(baselineLine) } @@ -166,30 +177,6 @@ const parseBenchOutput = (output) => { }; }; -/** - * Parse trailing JSON payloads emitted by benchmark scripts that may also print - * human-readable logs earlier in stdout. - * - * @param {string} text - * @returns {any|null} - */ -const parseTrailingJson = (text) => { - const raw = String(text || '').trim(); - if (!raw) return null; - if (raw.startsWith('{') || raw.startsWith('[')) { - try { - return JSON.parse(raw); - } catch {} - } - const match = raw.match(/\{[\s\S]*\}\s*$/); - if (!match) return null; - try { - return JSON.parse(match[0]); - } catch { - return null; - } -}; - const detectStorageTier = (value) => { const target = String(value || ''); if (!target) return 'unknown'; @@ -224,6 +211,26 @@ const toFiniteNumber = (value) => { return Number.isFinite(num) ? num : null; }; +const resolveDeltaDurationMs = (entry) => { + const metrics = entry?.parsed?.delta?.metrics; + if (!metrics || typeof metrics !== 'object') return null; + for (const key of ['ms', 'durationMs', 'totalMs', 'parseMs', 'loadMs']) { + const value = toFiniteNumber(metrics[key]); + if (Number.isFinite(value)) return value; + } + const duration = toFiniteNumber(metrics.duration); + return Number.isFinite(duration) ? duration : null; +}; + +const resolveSkipReason = (result) => { + const lines = `${result?.stdout || ''}\n${result?.stderr || ''}` + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + const reason = lines.find((line) => /\bskipp(?:ed|ing)\b/i.test(line)); + return reason || null; +}; + const toPercent = (value, scale = 100) => { const num = toFiniteNumber(value); if (!Number.isFinite(num)) return null; @@ -384,17 +391,39 @@ const resolveStageOverlapRow = ({ json, script, durationMs }) => { const runOne = ({ script, args, timeoutMs, tokens }) => { const absScript = path.isAbsolute(script) ? script : path.join(process.cwd(), script); const start = Date.now(); - const result = spawnSync( - process.execPath, - [absScript, ...(args || []).map((value) => substituteTokens(value, tokens))], - { encoding: 'utf8', timeout: timeoutMs > 0 ? timeoutMs : undefined } - ); + let result = null; + let timedOut = false; + try { + result = spawnSubprocessSync( + process.execPath, + [absScript, ...(args || []).map((value) => substituteTokens(value, tokens))], + { + outputEncoding: 'utf8', + timeoutMs: timeoutMs > 0 ? timeoutMs : undefined, + captureStdout: true, + captureStderr: true, + outputMode: 'string', + rejectOnNonZeroExit: false, + killTree: true, + detached: process.platform !== 'win32' + } + ); + } catch (error) { + timedOut = error?.name === 'SubprocessTimeoutError' + || error?.code === 'SUBPROCESS_TIMEOUT' + || String(error?.result?.errorCode || '').toUpperCase() === 'ETIMEDOUT'; + result = error?.result || { + exitCode: null, + signal: null, + stdout: '', + stderr: error?.message || String(error || '') + }; + } const durationMs = Date.now() - start; - const stdout = result.stdout || ''; - const stderr = result.stderr || ''; + const stdout = typeof result?.stdout === 'string' ? result.stdout : ''; + const stderr = typeof result?.stderr === 'string' ? result.stderr : ''; const combined = `${stdout}\n${stderr}`; - const timedOut = Boolean(result.error && result.error.code === 'ETIMEDOUT'); - const exitCode = typeof result.status === 'number' ? result.status : null; + const exitCode = typeof result?.exitCode === 'number' ? result.exitCode : null; const parsed = parseBenchOutput(combined); const json = parseTrailingJson(stdout); return { @@ -462,6 +491,13 @@ const main = async () => { timeoutMs: argv.timeoutMs, tokens }); + result.id = entry.id || path.basename(entry.script); + result.args = Array.isArray(entry.args) + ? entry.args.map((value) => substituteTokens(value, tokens)) + : []; + result.expect = entry.expect && typeof entry.expect === 'object' + ? { ...entry.expect } + : null; const errors = []; const expect = entry.expect && typeof entry.expect === 'object' ? entry.expect : null; @@ -487,6 +523,7 @@ const main = async () => { const looksSkipped = combined.includes('skipping') || combined.includes('skipped'); if (allowSkip && looksSkipped) { result.skipped = true; + result.skipReason = resolveSkipReason(result); } else { result.ok = false; } @@ -497,10 +534,10 @@ const main = async () => { } const summary = results.reduce((acc, entry) => { - const status = entry.timedOut ? 'timeout' : (entry.ok ? 'ok' : 'error'); + const status = entry.timedOut ? 'timeout' : (entry.skipped ? 'skipped' : (entry.ok ? 'ok' : 'error')); acc[status] = (acc[status] || 0) + 1; return acc; - }, { ok: 0, error: 0, timeout: 0 }); + }, { ok: 0, error: 0, timeout: 0, skipped: 0 }); const artifactDurations = []; const stageOverlapRows = []; const utilizationSamples = []; @@ -611,12 +648,20 @@ const main = async () => { if (artifactStallDurationMs && artifactStallDurationMs.p95 >= 30000) { triageHints.push('Artifact write tails are high (p95 >= 30s); inspect shard sizing, write queue pressure, and IO caps.'); } + const regressionSignals = []; for (const entry of results) { - const deltaDuration = Number(entry?.parsed?.delta?.metrics?.duration); + const deltaDuration = resolveDeltaDurationMs(entry); if (Number.isFinite(deltaDuration) && deltaDuration > 0) { - triageHints.push(`Regression signal in ${entry.script}: positive delta duration=${deltaDuration}.`); + regressionSignals.push({ + id: entry.id || null, + script: entry.script, + metric: 'durationMs', + deltaMs: deltaDuration + }); + triageHints.push(`Regression signal in ${entry.script}: positive delta duration=${deltaDuration}ms.`); } } + summary.regressionSignals = regressionSignals; summary.artifactStallDurationMs = artifactStallDurationMs; summary.stageOverlap = stageOverlap; summary.perCoreUtilization = perCoreUtilization; @@ -659,9 +704,16 @@ const main = async () => { }, summary, results: results.map((entry) => ({ + id: entry.id || null, script: entry.script, absScript: entry.absScript, + args: entry.args || [], + expect: entry.expect || null, ok: entry.ok, + skipped: entry.skipped === true, + skipReason: entry.skipReason || null, + parsedOk: entry.parsedOk === true, + errors: Array.isArray(entry.errors) ? entry.errors : [], timedOut: entry.timedOut, exitCode: entry.exitCode, durationMs: entry.durationMs, @@ -674,8 +726,7 @@ const main = async () => { if (argv.json) { const outPath = path.isAbsolute(argv.json) ? argv.json : path.join(process.cwd(), argv.json); - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeJsonFileResolved(outPath, report, { trailingNewline: true }); } if (!argv.quiet) { diff --git a/tools/bench/cache-hit-rate.js b/tools/bench/cache-hit-rate.js index 6d8f11f98..165c71481 100644 --- a/tools/bench/cache-hit-rate.js +++ b/tools/bench/cache-hit-rate.js @@ -1,46 +1,44 @@ #!/usr/bin/env node import { performance } from 'node:perf_hooks'; -import { buildLocalCacheKey } from '../../src/shared/cache-key.js'; +import { buildLocalCacheKey, createLocalCacheKeyBuilder } from '../../src/shared/cache-key.js'; import { createBoundedWriterQueue } from '../build/embeddings/writer-queue.js'; +import { parseSimpleBenchArgs } from './shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; +const args = parseSimpleBenchArgs(); +const parseNumberOption = (value, fallback) => { + if (value === undefined || value === null) return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +}; +const parseBooleanOption = (value, fallback) => { + if (value === undefined || value === null) return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; }; -const args = parseArgs(); -const ops = Number(args.ops) || 200000; -const keys = Number(args.keys) || 20000; -const hitRate = Math.min(1, Math.max(0, Number(args.hitRate) || 0.85)); -const iterations = Number(args.iterations) || 1; +const ops = parseNumberOption(args.ops, 200000) || 200000; +const keys = parseNumberOption(args.keys, 20000) || 20000; +const hitRate = Math.min(1, Math.max(0, parseNumberOption(args.hitRate, 0.85))); +const iterations = parseNumberOption(args.iterations, 1) || 1; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() : 'compare'; -const includeWriter = args.writer !== undefined ? Boolean(args.writer) : true; -const writerOps = Number(args.writerOps) || 5000; -const writerDelayMs = Number(args.writerDelayMs) || 0; -const writerMaxPending = Number(args.writerMaxPending) || 2; +const includeWriter = parseBooleanOption(args.writer, true); +const writerOps = parseNumberOption(args.writerOps, 5000) || 5000; +const writerDelayMs = parseNumberOption(args.writerDelayMs, 0) || 0; +const writerMaxPending = parseNumberOption(args.writerMaxPending, 2) || 2; const hitThreshold = Math.round(hitRate * 100); +const currentKeyBuilder = createLocalCacheKeyBuilder({ namespace: 'bench-cache' }); -const buildKeyBaseline = (id) => `key:${id}`; -const buildKeyCurrent = (id) => buildLocalCacheKey({ +const buildKeyBaseline = (id) => buildLocalCacheKey({ namespace: 'bench-cache', payload: { id } }).key; +const buildKeyCurrent = (id) => currentKeyBuilder.keyForProperty('id', id); const runBench = (label, buildKey) => { const cache = new Map(); diff --git a/tools/bench/dict-seg.js b/tools/bench/dict-seg.js index 498a4e848..07bc09d1a 100644 --- a/tools/bench/dict-seg.js +++ b/tools/bench/dict-seg.js @@ -3,7 +3,7 @@ import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createCli } from '../../src/shared/cli.js'; -import { isAbsolutePathNative } from '../../src/shared/files.js'; +import { isAbsolutePathNative } from '../../src/shared/file-paths.js'; import { splitWordsWithDict } from '../../src/shared/tokenize.js'; const argv = createCli({ diff --git a/tools/bench/embeddings/embedding-batch-throughput.js b/tools/bench/embeddings/embedding-batch-throughput.js index 67292c1d9..04e781e05 100644 --- a/tools/bench/embeddings/embedding-batch-throughput.js +++ b/tools/bench/embeddings/embedding-batch-throughput.js @@ -3,26 +3,9 @@ import { performance } from 'node:perf_hooks'; import { createEmbedder } from '../../../src/index/embedding.js'; import { createToolDisplay } from '../../shared/cli-display.js'; import { runBatched } from '../../build/embeddings/embed.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const display = createToolDisplay({ argv: args, stream: process.stderr }); const providerList = String(args.providers || 'stub') .split(',') diff --git a/tools/bench/embeddings/model-bakeoff.js b/tools/bench/embeddings/model-bakeoff.js index 47515f701..da9a939b3 100644 --- a/tools/bench/embeddings/model-bakeoff.js +++ b/tools/bench/embeddings/model-bakeoff.js @@ -5,11 +5,14 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { createCli } from '../../../src/shared/cli.js'; -import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; -import { getEnvConfig } from '../../../src/shared/env.js'; +import { normalizeLegacyCacheRootPath, resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; +import { getEnvConfig } from '../../../src/shared/env/runtime.js'; import { resolveEmbeddingInputFormatting } from '../../../src/shared/embedding-input-format.js'; -import { hasChunkMetaArtifactsSync } from '../../../src/shared/index-artifact-helpers.js'; -import { spawnSubprocess, spawnSubprocessSync } from '../../../src/shared/subprocess.js'; +import { hasChunkMetaArtifactsSync } from '../../../src/shared/artifact-io/chunk-meta-presence.js'; +import { readJsonFileSyncSafe } from '../../../src/shared/file-read.js'; +import { writeJsonFileResolved } from '../../../src/shared/json-file.js'; +import { sleep } from '../../../src/shared/sleep.js'; +import { spawnSubprocess, spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; import { resolveBakeoffFastPathDefaults, resolveBakeoffBuildPlan, @@ -18,14 +21,12 @@ import { resolveBakeoffStage4Modes } from './model-bakeoff-lib.js'; import { + bootstrapRuntime, getCacheRoot, getDictConfig, getModelConfig, getRepoId, isWithinRoot, - getRuntimeConfig, - resolveRepoConfig, - resolveRuntimeEnv, resolveToolRoot, toRealPathSync } from '../../shared/dict-utils.js'; @@ -104,11 +105,9 @@ const positionalArgs = Array.isArray(argv._) const positionalModelsArg = positionalArgs[0] || ''; const positionalDatasetArg = positionalArgs[1] || ''; -const { repoRoot: root, userConfig } = resolveRepoConfig(argv.repo); +const { repoRoot: root, userConfig, runtimeEnv: baseEnv } = bootstrapRuntime(argv.repo); const toolRoot = resolveToolRoot(); const envConfig = getEnvConfig(); -const runtimeConfig = getRuntimeConfig(root, userConfig); -const baseEnv = resolveRuntimeEnv(runtimeConfig, process.env); const modelConfig = getModelConfig(root, userConfig); const dictConfig = getDictConfig(root, userConfig); const sharedModelsDir = envConfig.modelsDir || modelConfig.dir; @@ -195,11 +194,8 @@ const annOverride = argv['no-ann'] === true ? false : (argv.ann === true ? true : null); -const cacheRootBase = argv['cache-root'] - ? path.resolve(argv['cache-root']) - : (envConfig.cacheRoot - ? path.resolve(envConfig.cacheRoot) - : getCacheRoot()); +const cacheRootBaseInput = argv['cache-root'] || envConfig.cacheRoot || getCacheRoot(); +const cacheRootBase = normalizeLegacyCacheRootPath(cacheRootBaseInput) || path.resolve(cacheRootBaseInput); const checkpointOutPath = argv.checkpoint ? path.resolve(argv.checkpoint) : (argv.out @@ -224,10 +220,20 @@ const modelCacheRoot = (modelId) => ( const toFixedMs = (value) => Math.round(Number(value) || 0); const streamChildOutputToStderr = argv.json === true; -const waitMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const isIndexLockContentionMessage = (value) => ( /index lock (held|unavailable)/i.test(String(value || '')) ); + +const throwIfSubprocessFailed = (result, label) => { + if (result.exitCode === 0 && !result.signal) return; + const stderr = String(result.stderr || '').trim(); + const suffix = stderr ? `\n${stderr}` : ''; + const reason = result.signal + ? `signal=${result.signal}` + : `exit=${result.exitCode ?? 'unknown'}`; + throw new Error(`${label} failed (${reason})${suffix}`); +}; + const runNode = async (args, env, label) => { const result = await spawnSubprocess(process.execPath, args, { cwd: root, @@ -245,14 +251,7 @@ const runNode = async (args, env, label) => { : null, rejectOnNonZeroExit: false }); - if (result.exitCode !== 0 || result.signal) { - const stderr = String(result.stderr || '').trim(); - const suffix = stderr ? `\n${stderr}` : ''; - const reason = result.signal - ? `signal=${result.signal}` - : `exit=${result.exitCode ?? 'unknown'}`; - throw new Error(`${label} failed (${reason})${suffix}`); - } + throwIfSubprocessFailed(result, label); return result; }; @@ -286,7 +285,7 @@ const runNodeWithLockRetry = async ( `[bakeoff] ${label}: index lock contention, retrying (${attempt}/${maxAttempts}) in ${delayMs}ms`, { kind: 'status', stage: 'bakeoff' } ); - await waitMs(delayMs); + await sleep(delayMs); } } throw new Error(`${label} failed after retries.`); @@ -303,14 +302,7 @@ const runJsonNode = (args, env, label) => { outputMode: 'string', rejectOnNonZeroExit: false }); - if (result.exitCode !== 0 || result.signal) { - const stderr = String(result.stderr || '').trim(); - const suffix = stderr ? `\n${stderr}` : ''; - const reason = result.signal - ? `signal=${result.signal}` - : `exit=${result.exitCode ?? 'unknown'}`; - throw new Error(`${label} failed (${reason})${suffix}`); - } + throwIfSubprocessFailed(result, label); const stdout = String(result.stdout || '{}').trim() || '{}'; try { return JSON.parse(stdout); @@ -397,19 +389,15 @@ const resolveModelCurrentBuildRoot = (modelCacheRootPath) => { const repoCacheRoot = path.join(versionedRoot, 'repos', repoId); const repoCacheCanonical = toRealPathSync(repoCacheRoot); const currentPath = path.join(repoCacheRoot, 'builds', 'current.json'); - if (!fs.existsSync(currentPath)) return null; - try { - const parsed = JSON.parse(fs.readFileSync(currentPath, 'utf8')) || {}; - return resolveBakeoffCurrentBuildRoot({ - repoCacheRoot, - currentState: parsed, - existsSync: fs.existsSync, - toCanonicalPath: toRealPathSync, - isWithinRoot - }); - } catch { - return null; - } + const parsed = readJsonFileSyncSafe(currentPath, null); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return resolveBakeoffCurrentBuildRoot({ + repoCacheRoot, + currentState: parsed, + existsSync: fs.existsSync, + toCanonicalPath: toRealPathSync, + isWithinRoot + }); }; /** @@ -633,8 +621,7 @@ const writeOutputPayload = async ({ currentPhase, phaseStartedAt }); - await fsPromises.mkdir(path.dirname(checkpointOutPath), { recursive: true }); - await fsPromises.writeFile(checkpointOutPath, JSON.stringify(payload, null, 2), 'utf8'); + await writeJsonFileResolved(checkpointOutPath, payload); return payload; }; diff --git a/tools/bench/graph-caps-harness.js b/tools/bench/graph-caps-harness.js index ec587ecc9..07d399565 100644 --- a/tools/bench/graph-caps-harness.js +++ b/tools/bench/graph-caps-harness.js @@ -1,10 +1,10 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { createCli } from '../../src/shared/cli.js'; +import { isDirectExecution } from '../../src/shared/direct-execution.js'; import { buildGraphNeighborhood } from '../../src/graph/neighborhood.js'; -import { loadGraphRelations } from '../../src/shared/artifact-io.js'; +import { loadGraphRelations } from '../../src/shared/artifact-io/loaders.js'; const resolveGraphStats = (graphRelations) => { const stats = {}; @@ -111,7 +111,7 @@ export async function runGraphCapsHarnessCli(rawArgs = process.argv.slice(2)) { return result; } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +if (isDirectExecution(import.meta.url)) { runGraphCapsHarnessCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/graph/context-pack-latency.js b/tools/bench/graph/context-pack-latency.js index 2e02a2ed3..d045e5e03 100644 --- a/tools/bench/graph/context-pack-latency.js +++ b/tools/bench/graph/context-pack-latency.js @@ -1,76 +1,21 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createCli } from '../../../src/shared/cli.js'; -import { parseSeedRef } from '../../../src/shared/seed-ref.js'; -import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; -import { - MAX_JSON_BYTES, - loadChunkMeta, - loadPiecesManifest, - readCompatibilityKey -} from '../../../src/shared/artifact-io.js'; -import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; -import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; -import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; -import { loadUserConfig } from '../../shared/dict-utils.js'; -import { buildGraphIndexCacheKey, createGraphStore } from '../../../src/graph/store.js'; +import { isDirectExecution } from '../../../src/shared/direct-execution.js'; +import { buildGraphIndexCacheKey } from '../../../src/graph/store.js'; import { assembleCompositeContextPack, buildChunkIndex, clearContextPackCaches } from '../../../src/context-pack/assemble.js'; - -const durationMs = (startNs, endNs = process.hrtime.bigint()) => Number((endNs - startNs) / 1000000n); - -const resolveDefaultSeed = (chunkMeta) => { - const first = Array.isArray(chunkMeta) ? chunkMeta[0] : null; - if (!first?.chunkUid) return null; - return { type: 'chunk', chunkUid: first.chunkUid }; -}; - -const summarize = (values) => { - if (!values.length) return { min: 0, max: 0, avg: 0 }; - const sorted = values.slice().sort((a, b) => a - b); - const sum = values.reduce((acc, value) => acc + value, 0); - const percentile = (p) => { - const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); - return sorted[idx]; - }; - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: sum / values.length, - p50: percentile(0.5), - p95: percentile(0.95) - }; -}; - -const runIterations = ({ - iterations, - buildPayload -}) => { - const timings = []; - const rssValues = []; - const started = process.hrtime.bigint(); - for (let i = 0; i < iterations; i += 1) { - const payload = buildPayload(); - const elapsed = Number(payload?.stats?.timing?.elapsedMs || 0); - const rss = Number(payload?.stats?.memory?.peak?.rss || 0); - timings.push(elapsed); - rssValues.push(rss); - } - const durationMs = Number((process.hrtime.bigint() - started) / 1000000n); - const throughput = durationMs > 0 ? iterations / (durationMs / 1000) : 0; - return { - iterations, - timingMs: summarize(timings), - rssBytes: summarize(rssValues), - totalMs: durationMs, - throughput - }; -}; +import { + clearGraphTraversalCaches, + durationMs, + GRAPH_BENCH_GRAPHS, + loadGraphBenchInputs, + normalizeCompareMode, + parseGraphBenchStandardCli, + printBaselineCurrentSummary, + runPayloadIterations +} from './shared.js'; export async function runContextPackLatencyBench({ indexDir, @@ -81,42 +26,32 @@ export async function runContextPackLatencyBench({ caps = {}, mode = 'compare' }) { - const timings = {}; - - const manifestStart = process.hrtime.bigint(); - const manifest = loadPiecesManifest(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - timings.manifestMs = durationMs(manifestStart); - - const graphStore = createGraphStore({ indexDir, manifest, strict: true, maxBytes: MAX_JSON_BYTES }); - const includeCsr = graphStore.hasArtifact('graph_relations_csr'); - - const chunkMetaStart = process.hrtime.bigint(); - const chunkMeta = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, manifest, strict: true }); - timings.chunkMetaMs = durationMs(chunkMetaStart); - - const graphRelationsStart = process.hrtime.bigint(); - const graphRelations = await graphStore.loadGraph(); - timings.graphRelationsMs = durationMs(graphRelationsStart); - - const compatStart = process.hrtime.bigint(); - const { key: indexCompatKey } = readCompatibilityKey(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - timings.compatKeyMs = durationMs(compatStart); - - const signatureStart = process.hrtime.bigint(); - const indexSignature = await buildIndexSignature(indexDir); - timings.indexSignatureMs = durationMs(signatureStart); + const { + timings, + graphStore, + includeCsr, + chunkMeta, + graphRelations, + indexCompatKey, + indexSignature, + resolvedSeed + } = await loadGraphBenchInputs({ + indexDir, + seed, + missingSeedMessage: 'Unable to resolve seed for context-pack bench.' + }); const graphCacheKey = buildGraphIndexCacheKey({ indexSignature, repoRoot, - graphs: ['callGraph', 'usageGraph', 'importGraph'], + graphs: GRAPH_BENCH_GRAPHS, includeCsr }); const graphIndexStart = process.hrtime.bigint(); const graphIndex = await graphStore.loadGraphIndex({ repoRoot, cacheKey: graphCacheKey, - graphs: ['callGraph', 'usageGraph', 'importGraph'], + graphs: GRAPH_BENCH_GRAPHS, includeCsr }); timings.graphIndexColdMs = durationMs(graphIndexStart); @@ -125,7 +60,7 @@ export async function runContextPackLatencyBench({ await graphStore.loadGraphIndex({ repoRoot, cacheKey: graphCacheKey, - graphs: ['callGraph', 'usageGraph', 'importGraph'], + graphs: GRAPH_BENCH_GRAPHS, includeCsr }); timings.graphIndexWarmMs = durationMs(graphIndexWarmStart); @@ -134,11 +69,6 @@ export async function runContextPackLatencyBench({ const chunkIndex = buildChunkIndex(chunkMeta, { repoRoot }); timings.chunkIndexMs = durationMs(chunkIndexStart); - const resolvedSeed = seed || resolveDefaultSeed(chunkMeta); - if (!resolvedSeed) { - throw new Error('Unable to resolve seed for context-pack bench.'); - } - const assembleArgs = { seed: resolvedSeed, chunkMeta, @@ -156,15 +86,15 @@ export async function runContextPackLatencyBench({ indexSignature: indexSignature || null }; - const normalizedMode = mode === 'baseline' || mode === 'current' ? mode : 'compare'; + const normalizedMode = normalizeCompareMode(mode); const results = {}; if (normalizedMode === 'baseline' || normalizedMode === 'compare') { - results.baseline = runIterations({ + results.baseline = runPayloadIterations({ iterations, buildPayload: () => { clearContextPackCaches(); - if (graphIndex && graphIndex._traversalCache) graphIndex._traversalCache.clear(); + clearGraphTraversalCaches(graphIndex); return assembleCompositeContextPack({ ...assembleArgs, graphRelations, @@ -176,7 +106,7 @@ export async function runContextPackLatencyBench({ } if (normalizedMode === 'current' || normalizedMode === 'compare') { - results.current = runIterations({ + results.current = runPayloadIterations({ iterations, buildPayload: () => assembleCompositeContextPack({ ...assembleArgs, @@ -216,37 +146,16 @@ export async function runContextPackLatencyBench({ } export async function runContextPackLatencyBenchCli(rawArgs = process.argv.slice(2)) { - const cli = createCli({ - scriptName: 'context-pack-latency', - argv: ['node', 'context-pack-latency', ...rawArgs], - options: { - index: { type: 'string' }, - repo: { type: 'string' }, - seed: { type: 'string' }, - iterations: { type: 'number', default: 5 }, - depth: { type: 'number', default: 2 }, - mode: { type: 'string', default: 'compare' } - } + const { + repoRoot, + indexDir, + seed, + iterations, + depth, + mode + } = parseGraphBenchStandardCli(rawArgs, { + scriptName: 'context-pack-latency' }); - const argv = cli.parse(); - - const repoRoot = argv.repo ? path.resolve(argv.repo) : process.cwd(); - const userConfig = loadUserConfig(repoRoot); - let indexDir = argv.index ? path.resolve(argv.index) : null; - if (!indexDir) { - const resolved = resolveIndexDir(repoRoot, 'code', userConfig); - if (resolved && hasIndexMeta(resolved)) { - indexDir = resolved; - } - } - if (!indexDir || !fs.existsSync(indexDir)) { - throw new Error('Missing --index and no built index found for repo.'); - } - - const seed = argv.seed ? parseSeedRef(argv.seed, repoRoot) : null; - const iterations = normalizeOptionalNumber(argv.iterations) || 5; - const depth = normalizeOptionalNumber(argv.depth) || 2; - const mode = argv.mode || 'compare'; const result = await runContextPackLatencyBench({ indexDir, @@ -256,31 +165,13 @@ export async function runContextPackLatencyBenchCli(rawArgs = process.argv.slice depth, mode }); - const formatSummary = (label, summary) => { - const avg = summary.timingMs.avg.toFixed(2); - const p95 = summary.timingMs.p95.toFixed(2); - const total = summary.totalMs.toFixed(1); - const throughput = summary.throughput.toFixed(2); - console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); - }; - - if (result.baseline) formatSummary('baseline', result.baseline); - if (result.current) formatSummary('current', result.current); - if (result.baseline && result.current) { - const deltaMs = result.current.totalMs - result.baseline.totalMs; - const deltaPct = result.baseline.totalMs ? (deltaMs / result.baseline.totalMs) * 100 : 0; - const deltaThroughput = result.current.throughput - result.baseline.throughput; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} throughput=${deltaThroughput.toFixed(2)} it/s ` + - `pct=${deltaPct.toFixed(1)} duration=${result.current.totalMs.toFixed(1)}ms` - ); - } + printBaselineCurrentSummary(result); console.log(JSON.stringify({ ok: true, result }, null, 2)); return result; } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +if (isDirectExecution(import.meta.url)) { runContextPackLatencyBenchCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/graph/neighborhood-cache.js b/tools/bench/graph/neighborhood-cache.js index 3f586b2b2..4b0b22dc9 100644 --- a/tools/bench/graph/neighborhood-cache.js +++ b/tools/bench/graph/neighborhood-cache.js @@ -1,43 +1,14 @@ #!/usr/bin/env node import { createCli } from '../../../src/shared/cli.js'; -import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; +import { isDirectExecution } from '../../../src/shared/direct-execution.js'; import { buildGraphNeighborhood } from '../../../src/graph/neighborhood.js'; import { buildGraphIndex } from '../../../src/graph/store.js'; - -const summarize = (values) => { - if (!values.length) return { min: 0, max: 0, avg: 0 }; - const sorted = values.slice().sort((a, b) => a - b); - const sum = values.reduce((acc, value) => acc + value, 0); - const percentile = (p) => { - const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); - return sorted[idx]; - }; - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: sum / values.length, - p50: percentile(0.5), - p95: percentile(0.95) - }; -}; - -const runIterations = ({ iterations, buildPayload }) => { - const timings = []; - const started = process.hrtime.bigint(); - for (let i = 0; i < iterations; i += 1) { - const payload = buildPayload(); - const elapsed = Number(payload?.stats?.timing?.elapsedMs || 0); - timings.push(elapsed); - } - const durationMs = Number((process.hrtime.bigint() - started) / 1000000n); - const throughput = durationMs > 0 ? iterations / (durationMs / 1000) : 0; - return { - iterations, - timingMs: summarize(timings), - totalMs: durationMs, - throughput - }; -}; +import { + normalizeCompareMode, + printBaselineCurrentSummary, + readNumberArg, + runPayloadIterations +} from './shared.js'; const buildSyntheticGraph = ({ nodes, fanout }) => { const graphNodes = []; @@ -81,12 +52,13 @@ export async function runNeighborhoodBench({ const graphRelations = buildSyntheticGraph({ nodes, fanout }); const graphIndex = buildGraphIndex({ graphRelations }); const seed = { type: 'chunk', chunkUid: 'chunk-0' }; - const normalizedMode = mode === 'baseline' || mode === 'current' ? mode : 'compare'; + const normalizedMode = normalizeCompareMode(mode); const results = {}; if (normalizedMode === 'baseline' || normalizedMode === 'compare') { - results.baseline = runIterations({ + results.baseline = runPayloadIterations({ iterations, + includeRss: false, buildPayload: () => buildGraphNeighborhood({ seed, graphRelations, @@ -97,8 +69,9 @@ export async function runNeighborhoodBench({ } if (normalizedMode === 'current' || normalizedMode === 'compare') { - results.current = runIterations({ + results.current = runPayloadIterations({ iterations, + includeRss: false, buildPayload: () => buildGraphNeighborhood({ seed, graphRelations, @@ -124,10 +97,10 @@ export async function runNeighborhoodBenchCli(rawArgs = process.argv.slice(2)) { } }); const argv = cli.parse(); - const nodes = normalizeOptionalNumber(argv.nodes) || 2000; - const fanout = normalizeOptionalNumber(argv.fanout) || 6; - const depth = normalizeOptionalNumber(argv.depth) || 2; - const iterations = normalizeOptionalNumber(argv.iterations) || 5; + const nodes = readNumberArg(argv.nodes, 2000); + const fanout = readNumberArg(argv.fanout, 6); + const depth = readNumberArg(argv.depth, 2); + const iterations = readNumberArg(argv.iterations, 5); const mode = argv.mode || 'compare'; const result = await runNeighborhoodBench({ @@ -138,31 +111,13 @@ export async function runNeighborhoodBenchCli(rawArgs = process.argv.slice(2)) { mode }); - const formatSummary = (label, summary) => { - const avg = summary.timingMs.avg.toFixed(2); - const p95 = summary.timingMs.p95.toFixed(2); - const total = summary.totalMs.toFixed(1); - const throughput = summary.throughput.toFixed(2); - console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); - }; - - if (result.baseline) formatSummary('baseline', result.baseline); - if (result.current) formatSummary('current', result.current); - if (result.baseline && result.current) { - const deltaMs = result.current.totalMs - result.baseline.totalMs; - const deltaPct = result.baseline.totalMs ? (deltaMs / result.baseline.totalMs) * 100 : 0; - const deltaThroughput = result.current.throughput - result.baseline.throughput; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} throughput=${deltaThroughput.toFixed(2)} it/s ` + - `pct=${deltaPct.toFixed(1)} duration=${result.current.totalMs.toFixed(1)}ms` - ); - } + printBaselineCurrentSummary(result); console.log(JSON.stringify({ ok: true, result }, null, 2)); return result; } -if (process.argv[1] && process.argv[1].endsWith('neighborhood-cache.js')) { +if (isDirectExecution(import.meta.url)) { runNeighborhoodBenchCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/graph/neighborhood-index-dir.js b/tools/bench/graph/neighborhood-index-dir.js index 25be4e85d..f92f011e6 100644 --- a/tools/bench/graph/neighborhood-index-dir.js +++ b/tools/bench/graph/neighborhood-index-dir.js @@ -1,96 +1,19 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createCli } from '../../../src/shared/cli.js'; -import { parseSeedRef } from '../../../src/shared/seed-ref.js'; -import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; -import { - MAX_JSON_BYTES, - loadChunkMeta, - loadPiecesManifest, - readCompatibilityKey -} from '../../../src/shared/artifact-io.js'; -import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; -import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; -import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; -import { loadUserConfig } from '../../shared/dict-utils.js'; -import { buildGraphIndexCacheKey, createGraphStore } from '../../../src/graph/store.js'; +import { isDirectExecution } from '../../../src/shared/direct-execution.js'; +import { buildGraphIndexCacheKey } from '../../../src/graph/store.js'; import { buildGraphNeighborhood } from '../../../src/graph/neighborhood.js'; import { buildImpactAnalysis } from '../../../src/graph/impact.js'; - -const durationMs = (startNs, endNs = process.hrtime.bigint()) => Number(endNs - startNs) / 1_000_000; - -const resolveDefaultSeed = (chunkMeta) => { - const first = Array.isArray(chunkMeta) ? chunkMeta[0] : null; - if (!first?.chunkUid) return null; - return { type: 'chunk', chunkUid: first.chunkUid }; -}; - -const summarize = (values) => { - if (!values.length) return { min: 0, max: 0, avg: 0 }; - const sorted = values.slice().sort((a, b) => a - b); - const sum = values.reduce((acc, value) => acc + value, 0); - const percentile = (p) => { - const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); - return sorted[idx]; - }; - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: sum / values.length, - p50: percentile(0.5), - p95: percentile(0.95) - }; -}; - -const clearTraversalCaches = (graphIndex) => { - if (!graphIndex || typeof graphIndex !== 'object') return; - if (graphIndex._traversalCache) graphIndex._traversalCache.clear(); - if (graphIndex._traversalTelemetry && typeof graphIndex._traversalTelemetry === 'object') { - graphIndex._traversalTelemetry.hits = 0; - graphIndex._traversalTelemetry.misses = 0; - graphIndex._traversalTelemetry.evictions = 0; - } - delete graphIndex._csrReverse; - delete graphIndex._csrReverseByGraph; -}; - -const runIterations = ({ - iterations, - buildPayload -}) => { - const timings = []; - const rssValues = []; - const started = process.hrtime.bigint(); - for (let i = 0; i < iterations; i += 1) { - const payload = buildPayload(); - const elapsed = Number(payload?.stats?.timing?.elapsedMs || 0); - const rss = Number(payload?.stats?.memory?.peak?.rss || 0); - timings.push(elapsed); - rssValues.push(rss); - } - const duration = durationMs(started); - const throughput = duration > 0 ? iterations / (duration / 1000) : 0; - return { - iterations, - timingMs: summarize(timings), - rssBytes: summarize(rssValues), - totalMs: duration, - throughput - }; -}; - -const runWarmIterations = ({ - iterations, - primePayload, - buildPayload -}) => { - if (typeof primePayload === 'function') { - primePayload(); - } - return runIterations({ iterations, buildPayload }); -}; +import { + clearGraphTraversalCaches, + durationMs, + GRAPH_BENCH_GRAPHS, + loadGraphBenchInputs, + normalizeCompareMode, + parseGraphBenchStandardCli, + printNestedBenchSummaries, + runPayloadIterations, + runWarmPayloadIterations +} from './shared.js'; export async function runNeighborhoodIndexDirBench({ indexDir, @@ -101,46 +24,29 @@ export async function runNeighborhoodIndexDirBench({ caps = {}, mode = 'compare' }) { - const timings = {}; - - const manifestStart = process.hrtime.bigint(); - const manifest = loadPiecesManifest(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - timings.manifestMs = durationMs(manifestStart); - - const graphStore = createGraphStore({ indexDir, manifest, strict: true, maxBytes: MAX_JSON_BYTES }); - - const chunkMetaStart = process.hrtime.bigint(); - const chunkMeta = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, manifest, strict: true }); - timings.chunkMetaMs = durationMs(chunkMetaStart); - - const graphRelationsStart = process.hrtime.bigint(); - const graphRelations = await graphStore.loadGraph(); - timings.graphRelationsMs = durationMs(graphRelationsStart); - - const compatStart = process.hrtime.bigint(); - const { key: indexCompatKey } = readCompatibilityKey(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); - timings.compatKeyMs = durationMs(compatStart); - - const signatureStart = process.hrtime.bigint(); - const indexSignature = await buildIndexSignature(indexDir); - timings.indexSignatureMs = durationMs(signatureStart); - - const resolvedSeed = seed || resolveDefaultSeed(chunkMeta); - if (!resolvedSeed) { - throw new Error('Unable to resolve seed for neighborhood index-dir bench.'); - } + const { + timings, + graphStore, + graphRelations, + indexCompatKey, + indexSignature, + resolvedSeed + } = await loadGraphBenchInputs({ + indexDir, + seed, + missingSeedMessage: 'Unable to resolve seed for neighborhood index-dir bench.' + }); - const graphs = ['callGraph', 'usageGraph', 'importGraph']; const cacheKeyLegacy = buildGraphIndexCacheKey({ indexSignature, repoRoot, - graphs, + graphs: GRAPH_BENCH_GRAPHS, includeCsr: false }); const cacheKeyCsr = buildGraphIndexCacheKey({ indexSignature, repoRoot, - graphs, + graphs: GRAPH_BENCH_GRAPHS, includeCsr: true }); @@ -148,7 +54,7 @@ export async function runNeighborhoodIndexDirBench({ const graphIndexLegacy = await graphStore.loadGraphIndex({ repoRoot, cacheKey: cacheKeyLegacy, - graphs, + graphs: GRAPH_BENCH_GRAPHS, includeCsr: false, indexSignature }); @@ -158,13 +64,13 @@ export async function runNeighborhoodIndexDirBench({ const graphIndexCsr = await graphStore.loadGraphIndex({ repoRoot, cacheKey: cacheKeyCsr, - graphs, + graphs: GRAPH_BENCH_GRAPHS, includeCsr: true, indexSignature }); timings.graphIndexCsrColdMs = durationMs(graphCsrStart); - const normalizedMode = mode === 'baseline' || mode === 'current' ? mode : 'compare'; + const normalizedMode = normalizeCompareMode(mode); const preferredGraphIndex = graphIndexCsr?.graphRelationsCsr ? graphIndexCsr : graphIndexLegacy; const neighborhoodArgs = (includePaths, graphIndex) => (graphIndex @@ -228,15 +134,15 @@ export async function runNeighborhoodIndexDirBench({ }; if (normalizedMode === 'baseline' || normalizedMode === 'compare') { - results.neighborhood.includePathsFalse.baseline = runIterations({ + results.neighborhood.includePathsFalse.baseline = runPayloadIterations({ iterations, buildPayload: () => buildGraphNeighborhood(neighborhoodArgs(false, null)) }); - results.neighborhood.includePathsTrue.baseline = runIterations({ + results.neighborhood.includePathsTrue.baseline = runPayloadIterations({ iterations, buildPayload: () => buildGraphNeighborhood(neighborhoodArgs(true, null)) }); - results.impact.baseline = runIterations({ + results.impact.baseline = runPayloadIterations({ iterations, buildPayload: () => buildImpactAnalysis(impactArgs(null)) }); @@ -244,49 +150,49 @@ export async function runNeighborhoodIndexDirBench({ if (normalizedMode === 'current' || normalizedMode === 'compare') { // Cold: clear traversal caches before each iteration. - results.neighborhood.includePathsFalse.currentCold = runIterations({ + results.neighborhood.includePathsFalse.currentCold = runPayloadIterations({ iterations, buildPayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); return buildGraphNeighborhood(neighborhoodArgs(false, preferredGraphIndex)); } }); - results.neighborhood.includePathsTrue.currentCold = runIterations({ + results.neighborhood.includePathsTrue.currentCold = runPayloadIterations({ iterations, buildPayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); return buildGraphNeighborhood(neighborhoodArgs(true, preferredGraphIndex)); } }); - results.impact.currentCold = runIterations({ + results.impact.currentCold = runPayloadIterations({ iterations, buildPayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); return buildImpactAnalysis(impactArgs(preferredGraphIndex)); } }); // Warm: prime once, then measure cache-hit iterations. - results.neighborhood.includePathsFalse.currentWarm = runWarmIterations({ + results.neighborhood.includePathsFalse.currentWarm = runWarmPayloadIterations({ iterations, primePayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); buildGraphNeighborhood(neighborhoodArgs(false, preferredGraphIndex)); }, buildPayload: () => buildGraphNeighborhood(neighborhoodArgs(false, preferredGraphIndex)) }); - results.neighborhood.includePathsTrue.currentWarm = runWarmIterations({ + results.neighborhood.includePathsTrue.currentWarm = runWarmPayloadIterations({ iterations, primePayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); buildGraphNeighborhood(neighborhoodArgs(true, preferredGraphIndex)); }, buildPayload: () => buildGraphNeighborhood(neighborhoodArgs(true, preferredGraphIndex)) }); - results.impact.currentWarm = runWarmIterations({ + results.impact.currentWarm = runWarmPayloadIterations({ iterations, primePayload: () => { - clearTraversalCaches(preferredGraphIndex); + clearGraphTraversalCaches(preferredGraphIndex, { resetTelemetry: true, clearCsrReverse: true }); buildImpactAnalysis(impactArgs(preferredGraphIndex)); }, buildPayload: () => buildImpactAnalysis(impactArgs(preferredGraphIndex)) @@ -321,35 +227,16 @@ export async function runNeighborhoodIndexDirBench({ } export async function runNeighborhoodIndexDirBenchCli(rawArgs = process.argv.slice(2)) { - const cli = createCli({ - scriptName: 'graph-neighborhood-index-dir', - argv: ['node', 'graph-neighborhood-index-dir', ...rawArgs], - options: { - index: { type: 'string' }, - repo: { type: 'string' }, - seed: { type: 'string' }, - iterations: { type: 'number', default: 5 }, - depth: { type: 'number', default: 2 }, - mode: { type: 'string', default: 'compare' } - } + const { + repoRoot, + indexDir, + seed, + iterations, + depth, + mode + } = parseGraphBenchStandardCli(rawArgs, { + scriptName: 'graph-neighborhood-index-dir' }); - const argv = cli.parse(); - - const repoRoot = argv.repo ? path.resolve(argv.repo) : process.cwd(); - const userConfig = loadUserConfig(repoRoot); - let indexDir = argv.index ? path.resolve(argv.index) : null; - if (!indexDir) { - const resolved = resolveIndexDir(repoRoot, 'code', userConfig); - if (resolved && hasIndexMeta(resolved)) indexDir = resolved; - } - if (!indexDir || !fs.existsSync(indexDir)) { - throw new Error('Missing --index and no built index found for repo.'); - } - - const seed = argv.seed ? parseSeedRef(argv.seed, repoRoot) : null; - const iterations = normalizeOptionalNumber(argv.iterations) || 5; - const depth = normalizeOptionalNumber(argv.depth) || 2; - const mode = argv.mode || 'compare'; const result = await runNeighborhoodIndexDirBench({ indexDir, @@ -360,34 +247,13 @@ export async function runNeighborhoodIndexDirBenchCli(rawArgs = process.argv.sli mode }); - const reportCase = (label, summary) => { - if (!summary) return; - const avg = summary.timingMs.avg.toFixed(2); - const p95 = summary.timingMs.p95.toFixed(2); - const total = summary.totalMs.toFixed(1); - const throughput = summary.throughput.toFixed(2); - console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); - }; - - const flattenCases = (obj, prefix) => { - if (!obj || typeof obj !== 'object') return; - for (const [key, value] of Object.entries(obj)) { - const label = prefix ? `${prefix}.${key}` : key; - if (value && typeof value === 'object' && 'timingMs' in value) { - reportCase(label, value); - continue; - } - flattenCases(value, label); - } - }; - - flattenCases(result.neighborhood, 'neighborhood'); - flattenCases(result.impact, 'impact'); + printNestedBenchSummaries(result.neighborhood, 'neighborhood'); + printNestedBenchSummaries(result.impact, 'impact'); console.log(JSON.stringify({ ok: true, result }, null, 2)); return result; } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +if (isDirectExecution(import.meta.url)) { runNeighborhoodIndexDirBenchCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/graph/render-sort.js b/tools/bench/graph/render-sort.js index 120a6c925..7287fcc8f 100644 --- a/tools/bench/graph/render-sort.js +++ b/tools/bench/graph/render-sort.js @@ -1,44 +1,13 @@ #!/usr/bin/env node import { createCli } from '../../../src/shared/cli.js'; -import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; +import { isDirectExecution } from '../../../src/shared/direct-execution.js'; import { renderGraphContextPack } from '../../../src/retrieval/output/graph-context-pack.js'; - -const summarize = (values) => { - if (!values.length) return { min: 0, max: 0, avg: 0 }; - const sorted = values.slice().sort((a, b) => a - b); - const sum = values.reduce((acc, value) => acc + value, 0); - const percentile = (p) => { - const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); - return sorted[idx]; - }; - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: sum / values.length, - p50: percentile(0.5), - p95: percentile(0.95) - }; -}; - -const runIterations = ({ iterations, render }) => { - const timings = []; - let durationMs = 0; - for (let i = 0; i < iterations; i += 1) { - const started = process.hrtime.bigint(); - const result = render(); - void result; - const elapsed = Number((process.hrtime.bigint() - started) / 1000000n); - timings.push(elapsed); - durationMs += elapsed; - } - const throughput = durationMs > 0 ? iterations / (durationMs / 1000) : 0; - return { - iterations, - timingMs: summarize(timings), - totalMs: durationMs, - throughput - }; -}; +import { + normalizeCompareMode, + printBaselineCurrentSummary, + readNumberArg, + runTimedIterations +} from './shared.js'; const buildSyntheticPack = (size) => { const nodes = []; @@ -67,20 +36,20 @@ const buildSyntheticPack = (size) => { export async function runRenderSortBench({ size, iterations, mode }) { const pack = buildSyntheticPack(size); - const normalizedMode = mode === 'baseline' || mode === 'current' ? mode : 'compare'; + const normalizedMode = normalizeCompareMode(mode); const results = {}; if (normalizedMode === 'baseline' || normalizedMode === 'compare') { - results.baseline = runIterations({ + results.baseline = runTimedIterations({ iterations, - render: () => renderGraphContextPack({ ...pack, stats: { sorted: false } }) + run: () => renderGraphContextPack({ ...pack, stats: { sorted: false } }) }); } if (normalizedMode === 'current' || normalizedMode === 'compare') { - results.current = runIterations({ + results.current = runTimedIterations({ iterations, - render: () => renderGraphContextPack({ ...pack, stats: { sorted: true } }) + run: () => renderGraphContextPack({ ...pack, stats: { sorted: true } }) }); } @@ -98,37 +67,19 @@ export async function runRenderSortBenchCli(rawArgs = process.argv.slice(2)) { } }); const argv = cli.parse(); - const size = normalizeOptionalNumber(argv.size) || 2000; - const iterations = normalizeOptionalNumber(argv.iterations) || 5; + const size = readNumberArg(argv.size, 2000); + const iterations = readNumberArg(argv.iterations, 5); const mode = argv.mode || 'compare'; const result = await runRenderSortBench({ size, iterations, mode }); - const formatSummary = (label, summary) => { - const avg = summary.timingMs.avg.toFixed(2); - const p95 = summary.timingMs.p95.toFixed(2); - const total = summary.totalMs.toFixed(1); - const throughput = summary.throughput.toFixed(2); - console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); - }; - - if (result.baseline) formatSummary('baseline', result.baseline); - if (result.current) formatSummary('current', result.current); - if (result.baseline && result.current) { - const deltaMs = result.current.totalMs - result.baseline.totalMs; - const deltaPct = result.baseline.totalMs ? (deltaMs / result.baseline.totalMs) * 100 : 0; - const deltaThroughput = result.current.throughput - result.baseline.throughput; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} throughput=${deltaThroughput.toFixed(2)} it/s ` + - `pct=${deltaPct.toFixed(1)} duration=${result.current.totalMs.toFixed(1)}ms` - ); - } + printBaselineCurrentSummary(result); console.log(JSON.stringify({ ok: true, result }, null, 2)); return result; } -if (process.argv[1] && process.argv[1].endsWith('render-sort.js')) { +if (isDirectExecution(import.meta.url)) { runRenderSortBenchCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/graph/shared.js b/tools/bench/graph/shared.js new file mode 100644 index 000000000..5fac3ee09 --- /dev/null +++ b/tools/bench/graph/shared.js @@ -0,0 +1,272 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createCli } from '../../../src/shared/cli.js'; +import { parseSeedRef } from '../../../src/shared/seed-ref.js'; +import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; +import { MAX_JSON_BYTES } from '../../../src/shared/artifact-io/constants.js'; +import { loadChunkMeta } from '../../../src/shared/artifact-io/loaders.js'; +import { loadPiecesManifest, readCompatibilityKey } from '../../../src/shared/artifact-io/manifest.js'; +import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; +import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; +import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; +import { createGraphStore } from '../../../src/graph/store.js'; +import { loadUserConfig } from '../../shared/dict-utils.js'; + +export const GRAPH_BENCH_GRAPHS = ['callGraph', 'usageGraph', 'importGraph']; + +export const durationMs = (startNs, endNs = process.hrtime.bigint()) => Number(endNs - startNs) / 1_000_000; + +export const normalizeCompareMode = (mode) => (mode === 'baseline' || mode === 'current' ? mode : 'compare'); + +export const summarizeValues = (values) => { + if (!values.length) return { min: 0, max: 0, avg: 0 }; + const sorted = values.slice().sort((a, b) => a - b); + const sum = values.reduce((acc, value) => acc + value, 0); + const percentile = (p) => { + const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); + return sorted[idx]; + }; + return { + min: sorted[0], + max: sorted[sorted.length - 1], + avg: sum / values.length, + p50: percentile(0.5), + p95: percentile(0.95) + }; +}; + +export const runPayloadIterations = ({ + iterations, + buildPayload, + includeRss = true +}) => { + const timings = []; + const rssValues = []; + const started = process.hrtime.bigint(); + for (let i = 0; i < iterations; i += 1) { + const payload = buildPayload(); + timings.push(Number(payload?.stats?.timing?.elapsedMs || 0)); + if (includeRss) rssValues.push(Number(payload?.stats?.memory?.peak?.rss || 0)); + } + const totalMs = durationMs(started); + const throughput = totalMs > 0 ? iterations / (totalMs / 1000) : 0; + return { + iterations, + timingMs: summarizeValues(timings), + ...(includeRss ? { rssBytes: summarizeValues(rssValues) } : {}), + totalMs, + throughput + }; +}; + +export const runWarmPayloadIterations = ({ + iterations, + primePayload, + buildPayload +}) => { + if (typeof primePayload === 'function') primePayload(); + return runPayloadIterations({ iterations, buildPayload }); +}; + +export const runTimedIterations = ({ iterations, run, totalMode = 'sum' }) => { + const timings = []; + const started = process.hrtime.bigint(); + let totalMs = 0; + for (let i = 0; i < iterations; i += 1) { + const iterationStart = process.hrtime.bigint(); + const result = run(); + void result; + const elapsed = durationMs(iterationStart); + timings.push(elapsed); + totalMs += elapsed; + } + if (totalMode === 'wall') totalMs = durationMs(started); + const throughput = totalMs > 0 ? iterations / (totalMs / 1000) : 0; + return { + iterations, + timingMs: summarizeValues(timings), + totalMs, + throughput + }; +}; + +export const runAsyncTimedIterations = async ({ iterations, run }) => { + const timings = []; + let totalMs = 0; + for (let i = 0; i < iterations; i += 1) { + const started = process.hrtime.bigint(); + const result = await run(); + void result; + const elapsed = durationMs(started); + timings.push(elapsed); + totalMs += elapsed; + } + const throughput = totalMs > 0 ? iterations / (totalMs / 1000) : 0; + return { + iterations, + timingMs: summarizeValues(timings), + totalMs, + throughput + }; +}; + +export const resolveDefaultChunkSeed = (chunkMeta) => { + const first = Array.isArray(chunkMeta) ? chunkMeta[0] : null; + if (!first?.chunkUid) return null; + return { type: 'chunk', chunkUid: first.chunkUid }; +}; + +export const loadGraphBenchInputs = async ({ + indexDir, + seed, + missingSeedMessage +}) => { + const timings = {}; + + const manifestStart = process.hrtime.bigint(); + const manifest = loadPiecesManifest(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); + timings.manifestMs = durationMs(manifestStart); + + const graphStore = createGraphStore({ indexDir, manifest, strict: true, maxBytes: MAX_JSON_BYTES }); + const includeCsr = graphStore.hasArtifact('graph_relations_csr'); + + const chunkMetaStart = process.hrtime.bigint(); + const chunkMeta = await loadChunkMeta(indexDir, { maxBytes: MAX_JSON_BYTES, manifest, strict: true }); + timings.chunkMetaMs = durationMs(chunkMetaStart); + + const graphRelationsStart = process.hrtime.bigint(); + const graphRelations = await graphStore.loadGraph(); + timings.graphRelationsMs = durationMs(graphRelationsStart); + + const compatStart = process.hrtime.bigint(); + const { key: indexCompatKey } = readCompatibilityKey(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); + timings.compatKeyMs = durationMs(compatStart); + + const signatureStart = process.hrtime.bigint(); + const indexSignature = await buildIndexSignature(indexDir); + timings.indexSignatureMs = durationMs(signatureStart); + + const resolvedSeed = seed || resolveDefaultChunkSeed(chunkMeta); + if (!resolvedSeed) { + throw new Error(missingSeedMessage); + } + + return { + timings, + manifest, + graphStore, + includeCsr, + chunkMeta, + graphRelations, + indexCompatKey, + indexSignature, + resolvedSeed + }; +}; + +export const clearGraphTraversalCaches = (graphIndex, { + resetTelemetry = false, + clearCsrReverse = false +} = {}) => { + if (!graphIndex || typeof graphIndex !== 'object') return; + if (graphIndex._traversalCache) graphIndex._traversalCache.clear(); + if (resetTelemetry && graphIndex._traversalTelemetry && typeof graphIndex._traversalTelemetry === 'object') { + graphIndex._traversalTelemetry.hits = 0; + graphIndex._traversalTelemetry.misses = 0; + graphIndex._traversalTelemetry.evictions = 0; + } + if (clearCsrReverse) { + delete graphIndex._csrReverse; + delete graphIndex._csrReverseByGraph; + } +}; + +export const formatBenchSummary = (label, summary) => { + const avg = summary.timingMs.avg.toFixed(2); + const p95 = summary.timingMs.p95.toFixed(2); + const total = summary.totalMs.toFixed(1); + const throughput = summary.throughput.toFixed(2); + console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); +}; + +export const printBenchComparison = (baseline, current) => { + const deltaMs = current.totalMs - baseline.totalMs; + const deltaPct = baseline.totalMs ? (deltaMs / baseline.totalMs) * 100 : 0; + const deltaThroughput = current.throughput - baseline.throughput; + console.log( + `[bench] delta ms=${deltaMs.toFixed(1)} throughput=${deltaThroughput.toFixed(2)} it/s ` + + `pct=${deltaPct.toFixed(1)} duration=${current.totalMs.toFixed(1)}ms` + ); +}; + +export const printBaselineCurrentSummary = (result) => { + if (result.baseline) formatBenchSummary('baseline', result.baseline); + if (result.current) formatBenchSummary('current', result.current); + if (result.baseline && result.current) printBenchComparison(result.baseline, result.current); +}; + +export const printNestedBenchSummaries = (obj, prefix) => { + if (!obj || typeof obj !== 'object') return; + for (const [key, value] of Object.entries(obj)) { + const label = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && 'timingMs' in value) { + formatBenchSummary(label, value); + continue; + } + printNestedBenchSummaries(value, label); + } +}; + +export const parseGraphIndexBenchCli = (rawArgs, { + scriptName, + options = {} +}) => { + const cli = createCli({ + scriptName, + argv: ['node', scriptName, ...rawArgs], + options: { + index: { type: 'string' }, + repo: { type: 'string' }, + ...options + } + }); + const argv = cli.parse(); + const repoRoot = argv.repo ? path.resolve(argv.repo) : process.cwd(); + const userConfig = loadUserConfig(repoRoot); + let indexDir = argv.index ? path.resolve(argv.index) : null; + if (!indexDir) { + const resolved = resolveIndexDir(repoRoot, 'code', userConfig); + if (resolved && hasIndexMeta(resolved)) indexDir = resolved; + } + if (!indexDir || !fs.existsSync(indexDir)) { + throw new Error('Missing --index and no built index found for repo.'); + } + return { argv, repoRoot, indexDir }; +}; + +export const readNumberArg = (value, fallback) => normalizeOptionalNumber(value) || fallback; + +export const parseGraphBenchStandardCli = (rawArgs, { + scriptName +}) => { + const { argv, repoRoot, indexDir } = parseGraphIndexBenchCli(rawArgs, { + scriptName, + options: { + seed: { type: 'string' }, + iterations: { type: 'number', default: 5 }, + depth: { type: 'number', default: 2 }, + mode: { type: 'string', default: 'compare' } + } + }); + + return { + argv, + repoRoot, + indexDir, + seed: argv.seed ? parseSeedRef(argv.seed, repoRoot) : null, + iterations: readNumberArg(argv.iterations, 5), + depth: readNumberArg(argv.depth, 2), + mode: argv.mode || 'compare' + }; +}; diff --git a/tools/bench/graph/store-lazy-load.js b/tools/bench/graph/store-lazy-load.js index 8d15da877..2196a6a20 100644 --- a/tools/bench/graph/store-lazy-load.js +++ b/tools/bench/graph/store-lazy-load.js @@ -1,62 +1,27 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { createCli } from '../../../src/shared/cli.js'; -import { normalizeOptionalNumber } from '../../../src/shared/limits.js'; -import { loadPiecesManifest, MAX_JSON_BYTES } from '../../../src/shared/artifact-io.js'; +import { isDirectExecution } from '../../../src/shared/direct-execution.js'; +import { MAX_JSON_BYTES } from '../../../src/shared/artifact-io/constants.js'; +import { loadPiecesManifest } from '../../../src/shared/artifact-io/manifest.js'; import { buildIndexSignature } from '../../../src/retrieval/index-cache.js'; import { createGraphStore, buildGraphIndexCacheKey } from '../../../src/graph/store.js'; -import { resolveIndexDir } from '../../../src/retrieval/cli-index.js'; -import { hasIndexMeta } from '../../../src/retrieval/cli/index-loader.js'; -import { loadUserConfig } from '../../shared/dict-utils.js'; - -const summarize = (values) => { - if (!values.length) return { min: 0, max: 0, avg: 0 }; - const sorted = values.slice().sort((a, b) => a - b); - const sum = values.reduce((acc, value) => acc + value, 0); - const percentile = (p) => { - const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * p))); - return sorted[idx]; - }; - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: sum / values.length, - p50: percentile(0.5), - p95: percentile(0.95) - }; -}; - -const runIterations = async ({ iterations, loadIndex }) => { - const timings = []; - let durationMs = 0; - for (let i = 0; i < iterations; i += 1) { - const started = process.hrtime.bigint(); - const result = await loadIndex(); - void result; - const elapsed = Number((process.hrtime.bigint() - started) / 1000000n); - timings.push(elapsed); - durationMs += elapsed; - } - const throughput = durationMs > 0 ? iterations / (durationMs / 1000) : 0; - return { - iterations, - timingMs: summarize(timings), - totalMs: durationMs, - throughput - }; -}; +import { + GRAPH_BENCH_GRAPHS, + normalizeCompareMode, + parseGraphIndexBenchCli, + printBaselineCurrentSummary, + readNumberArg, + runAsyncTimedIterations +} from './shared.js'; export async function runGraphStoreBench({ indexDir, repoRoot, iterations, mode }) { const manifest = loadPiecesManifest(indexDir, { maxBytes: MAX_JSON_BYTES, strict: true }); const indexSignature = await buildIndexSignature(indexDir); const graphStore = createGraphStore({ indexDir, manifest, strict: true, maxBytes: MAX_JSON_BYTES }); const includeCsr = graphStore.hasArtifact('graph_relations_csr'); - const graphsAll = ['callGraph', 'usageGraph', 'importGraph']; const graphCacheKeyAll = buildGraphIndexCacheKey({ indexSignature, repoRoot, - graphs: graphsAll, + graphs: GRAPH_BENCH_GRAPHS, includeCsr }); const graphCacheKeySubset = buildGraphIndexCacheKey({ @@ -65,25 +30,25 @@ export async function runGraphStoreBench({ indexDir, repoRoot, iterations, mode graphs: ['callGraph'], includeCsr }); - const normalizedMode = mode === 'baseline' || mode === 'current' ? mode : 'compare'; + const normalizedMode = normalizeCompareMode(mode); const results = {}; if (normalizedMode === 'baseline' || normalizedMode === 'compare') { - results.baseline = await runIterations({ + results.baseline = await runAsyncTimedIterations({ iterations, - loadIndex: () => graphStore.loadGraphIndex({ + run: () => graphStore.loadGraphIndex({ repoRoot, cacheKey: graphCacheKeyAll, - graphs: graphsAll, + graphs: GRAPH_BENCH_GRAPHS, includeCsr }) }); } if (normalizedMode === 'current' || normalizedMode === 'compare') { - results.current = await runIterations({ + results.current = await runAsyncTimedIterations({ iterations, - loadIndex: () => graphStore.loadGraphIndex({ + run: () => graphStore.loadGraphIndex({ repoRoot, cacheKey: graphCacheKeySubset, graphs: ['callGraph'], @@ -96,57 +61,25 @@ export async function runGraphStoreBench({ indexDir, repoRoot, iterations, mode } export async function runGraphStoreBenchCli(rawArgs = process.argv.slice(2)) { - const cli = createCli({ + const { argv, repoRoot, indexDir } = parseGraphIndexBenchCli(rawArgs, { scriptName: 'graph-store-lazy-load', - argv: ['node', 'graph-store-lazy-load', ...rawArgs], options: { - index: { type: 'string' }, - repo: { type: 'string' }, iterations: { type: 'number', default: 3 }, mode: { type: 'string', default: 'compare' } } }); - const argv = cli.parse(); - const repoRoot = argv.repo ? path.resolve(argv.repo) : process.cwd(); - const userConfig = loadUserConfig(repoRoot); - let indexDir = argv.index ? path.resolve(argv.index) : null; - if (!indexDir) { - const resolved = resolveIndexDir(repoRoot, 'code', userConfig); - if (resolved && hasIndexMeta(resolved)) indexDir = resolved; - } - if (!indexDir || !fs.existsSync(indexDir)) { - throw new Error('Missing --index and no built index found for repo.'); - } - const iterations = normalizeOptionalNumber(argv.iterations) || 3; + const iterations = readNumberArg(argv.iterations, 3); const mode = argv.mode || 'compare'; const result = await runGraphStoreBench({ indexDir, repoRoot, iterations, mode }); - const formatSummary = (label, summary) => { - const avg = summary.timingMs.avg.toFixed(2); - const p95 = summary.timingMs.p95.toFixed(2); - const total = summary.totalMs.toFixed(1); - const throughput = summary.throughput.toFixed(2); - console.log(`[bench] ${label} total=${total}ms avg=${avg}ms p95=${p95}ms throughput=${throughput} it/s`); - }; - - if (result.baseline) formatSummary('baseline', result.baseline); - if (result.current) formatSummary('current', result.current); - if (result.baseline && result.current) { - const deltaMs = result.current.totalMs - result.baseline.totalMs; - const deltaPct = result.baseline.totalMs ? (deltaMs / result.baseline.totalMs) * 100 : 0; - const deltaThroughput = result.current.throughput - result.baseline.throughput; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} throughput=${deltaThroughput.toFixed(2)} it/s ` + - `pct=${deltaPct.toFixed(1)} duration=${result.current.totalMs.toFixed(1)}ms` - ); - } + printBaselineCurrentSummary(result); console.log(JSON.stringify({ ok: true, result }, null, 2)); return result; } -if (process.argv[1] && process.argv[1].endsWith('store-lazy-load.js')) { +if (isDirectExecution(import.meta.url)) { runGraphStoreBenchCli().catch((err) => { console.error(err?.message || err); process.exit(1); diff --git a/tools/bench/index/artifact-io-read.js b/tools/bench/index/artifact-io-read.js index 68ad7561b..988aa37c8 100644 --- a/tools/bench/index/artifact-io-read.js +++ b/tools/bench/index/artifact-io-read.js @@ -2,29 +2,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; -import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { toPosix } from '../../../src/shared/files.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { loadJsonArrayArtifact } from '../../../src/shared/artifact-io/loaders.js'; +import { toPosix } from '../../../src/shared/file-paths.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const rows = Number(args.rows) || 200000; const maxBytes = Number(args.maxBytes) || 256 * 1024; const iterations = Number(args.iterations) || 3; diff --git a/tools/bench/index/artifact-writer-sharding.js b/tools/bench/index/artifact-writer-sharding.js index 8f8716bd2..948ed5411 100644 --- a/tools/bench/index/artifact-writer-sharding.js +++ b/tools/bench/index/artifact-writer-sharding.js @@ -2,27 +2,15 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonArrayFile, writeJsonLinesSharded } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonArrayFile } from '../../../src/shared/json-stream/json-writers.js'; +import { parseSimpleBenchArgs } from '../shared.js'; +import { + printThroughputResult, + runComparedThroughputBenchmarks +} from './throughput-compare.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const rows = Number(args.rows) || 200000; const payloadBytes = Number(args.payloadBytes) || 128; const maxBytes = Number(args.maxBytes) || 4 * 1024 * 1024; @@ -74,48 +62,15 @@ const runCurrent = async (items) => { }; }; -const formatThroughput = (durationMs) => ( - durationMs > 0 ? (rows / (durationMs / 1000)) : 0 -); - const printResult = (result) => { - const throughput = formatThroughput(result.durationMs); const extras = result.parts != null ? ` parts=${result.parts}` : ''; - console.log( - `[bench] ${result.label} rows=${rows} ms=${result.durationMs.toFixed(1)} ` + - `throughput=${throughput.toFixed(1)}/s bytes=${result.bytes}${extras}` - ); - return throughput; -}; - -const printDelta = (baseline, current, baselineThroughput, currentThroughput) => { - const deltaMs = current.durationMs - baseline.durationMs; - const deltaPct = baseline.durationMs > 0 ? (deltaMs / baseline.durationMs) * 100 : 0; - const deltaThroughput = currentThroughput - baselineThroughput; - const deltaBytes = current.bytes - baseline.bytes; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} (${deltaPct.toFixed(1)}%) ` + - `throughput=${currentThroughput.toFixed(1)}/s Δ=${deltaThroughput.toFixed(1)}/s ` + - `bytes=${current.bytes} Δ=${deltaBytes}` - ); + return printThroughputResult(result, { itemLabel: 'rows', items: rows, extras }); }; const items = buildRows(); -let baseline = null; -let current = null; -let baselineThroughput = 0; -let currentThroughput = 0; - -if (mode !== 'current') { - baseline = await runBaseline(items); - baselineThroughput = printResult(baseline); -} - -if (mode !== 'baseline') { - current = await runCurrent(items); - currentThroughput = printResult(current); -} - -if (baseline && current) { - printDelta(baseline, current, baselineThroughput, currentThroughput); -} +await runComparedThroughputBenchmarks({ + mode, + runBaseline: () => runBaseline(items), + runCurrent: () => runCurrent(items), + printResult +}); diff --git a/tools/bench/index/build-state-shared.js b/tools/bench/index/build-state-shared.js new file mode 100644 index 000000000..327c7dd1b --- /dev/null +++ b/tools/bench/index/build-state-shared.js @@ -0,0 +1,18 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { initBuildState } from '../../../src/index/build/build-state.js'; + +export const prepareBuildStateBenchRun = async ({ benchRoot, label }) => { + const runRoot = path.join(benchRoot, label); + await fs.rm(runRoot, { recursive: true, force: true }); + await fs.mkdir(runRoot, { recursive: true }); + await initBuildState({ + buildRoot: runRoot, + buildId: `bench-${label}`, + stage: 'bench', + toolVersion: 'bench', + signatureVersion: 1 + }); + return runRoot; +}; diff --git a/tools/bench/index/build-state-sidecar.js b/tools/bench/index/build-state-sidecar.js index f11087876..157963b2b 100644 --- a/tools/bench/index/build-state-sidecar.js +++ b/tools/bench/index/build-state-sidecar.js @@ -3,30 +3,17 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { - initBuildState, updateBuildState, flushBuildState } from '../../../src/index/build/build-state.js'; +import { parseSimpleBenchArgs } from '../shared.js'; +import { prepareBuildStateBenchRun } from './build-state-shared.js'; +import { + printThroughputResult, + runComparedThroughputBenchmarks +} from './throughput-compare.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const updates = Number(args.updates) || 300; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() @@ -48,16 +35,7 @@ const sumStateBytes = async (runRoot) => { }; const runOnce = async (label, { flushEach }) => { - const runRoot = path.join(benchRoot, label); - await fs.rm(runRoot, { recursive: true, force: true }); - await fs.mkdir(runRoot, { recursive: true }); - await initBuildState({ - buildRoot: runRoot, - buildId: `bench-${label}`, - stage: 'bench', - toolVersion: 'bench', - signatureVersion: 1 - }); + const runRoot = await prepareBuildStateBenchRun({ benchRoot, label }); const start = performance.now(); for (let i = 0; i < updates; i += 1) { @@ -75,46 +53,12 @@ const runOnce = async (label, { flushEach }) => { return { label, durationMs, bytes }; }; -const formatThroughput = (durationMs) => ( - durationMs > 0 ? (updates / (durationMs / 1000)) : 0 -); - -const printResult = (result) => { - const throughput = formatThroughput(result.durationMs); - console.log( - `[bench] ${result.label} updates=${updates} ms=${result.durationMs.toFixed(1)} ` + - `throughput=${throughput.toFixed(1)}/s bytes=${result.bytes}` - ); - return throughput; -}; - -const printDelta = (baseline, current, baselineThroughput, currentThroughput) => { - const deltaMs = current.durationMs - baseline.durationMs; - const deltaPct = baseline.durationMs > 0 ? (deltaMs / baseline.durationMs) * 100 : 0; - const deltaThroughput = currentThroughput - baselineThroughput; - const deltaBytes = current.bytes - baseline.bytes; - console.log( - `[bench] delta ms=${deltaMs.toFixed(1)} (${deltaPct.toFixed(1)}%) ` + - `throughput=${currentThroughput.toFixed(1)}/s Δ=${deltaThroughput.toFixed(1)}/s ` + - `bytes=${current.bytes} Δ=${deltaBytes}` - ); -}; - -let baseline = null; -let current = null; -let baselineThroughput = 0; -let currentThroughput = 0; - -if (mode !== 'current') { - baseline = await runOnce('baseline', { flushEach: true }); - baselineThroughput = printResult(baseline); -} - -if (mode !== 'baseline') { - current = await runOnce('current', { flushEach: false }); - currentThroughput = printResult(current); -} - -if (baseline && current) { - printDelta(baseline, current, baselineThroughput, currentThroughput); -} +await runComparedThroughputBenchmarks({ + mode, + runBaseline: () => runOnce('baseline', { flushEach: true }), + runCurrent: () => runOnce('current', { flushEach: false }), + printResult: (result) => printThroughputResult(result, { + itemLabel: 'updates', + items: updates + }) +}); diff --git a/tools/bench/index/build-state-write.js b/tools/bench/index/build-state-write.js index f49495d20..2a1ed6519 100644 --- a/tools/bench/index/build-state-write.js +++ b/tools/bench/index/build-state-write.js @@ -3,28 +3,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { - initBuildState, updateBuildState, flushBuildState } from '../../../src/index/build/build-state.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; +import { parseSimpleBenchArgs } from '../shared.js'; +import { prepareBuildStateBenchRun } from './build-state-shared.js'; const percentile = (values, pct) => { if (!values.length) return 0; @@ -33,7 +16,7 @@ const percentile = (values, pct) => { return sorted[idx]; }; -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const updates = Number(args.updates) || 200; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() @@ -45,16 +28,7 @@ const jsonOnly = Boolean(args.json); const outPath = args.out ? path.resolve(String(args.out)) : null; const runOnce = async (label, { flushEach }) => { - const runRoot = path.join(benchRoot, label); - await fs.rm(runRoot, { recursive: true, force: true }); - await fs.mkdir(runRoot, { recursive: true }); - await initBuildState({ - buildRoot: runRoot, - buildId: `bench-${label}`, - stage: 'bench', - toolVersion: 'bench', - signatureVersion: 1 - }); + const runRoot = await prepareBuildStateBenchRun({ benchRoot, label }); const timings = []; const updatedAtValues = new Set(); diff --git a/tools/bench/index/chargram-postings.js b/tools/bench/index/chargram-postings.js index 639e2af47..f7b504aad 100644 --- a/tools/bench/index/chargram-postings.js +++ b/tools/bench/index/chargram-postings.js @@ -1,40 +1,22 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { buildPostings } from '../../../src/index/build/postings.js'; -import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +import { + formatHeapDeltaMb, + parseBenchArgs, + prepareBenchRoot, + resolveCompareMode, + runPostingsBenchOnce +} from './shared-postings-bench.js'; + +const args = parseBenchArgs(); const vocabSize = Number(args.vocab) || 250000; const docs = Number(args.docs) || 50000; const postingsPerToken = Number(args.postings) || 3; const spillThreshold = Number(args.spill) || 100000; +const samples = Math.max(1, Math.floor(Number(args.samples) || 3)); const enableRollingHash = args.rolling === true || args['rolling-hash'] === true; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); -const benchRoot = path.join(process.cwd(), '.benchCache', 'chargram-postings'); -await fs.mkdir(benchRoot, { recursive: true }); +const benchRoot = await prepareBenchRoot('chargram-postings'); const MASK_64 = (1n << 64n) - 1n; const MIX_CONST = 0x9e3779b97f4a7c15n; @@ -52,66 +34,34 @@ const buildChargramKey = (i) => { return i.toString(36).padStart(4, '0'); }; -const buildTriPost = () => { - const map = new Map(); - for (let i = 0; i < vocabSize; i += 1) { - const token = buildChargramKey(i); - const postings = new Array(postingsPerToken); - const base = (i * 131) % Math.max(docs, 1); - for (let j = 0; j < postingsPerToken; j += 1) { - postings[j] = base + j; +const runOnce = (label, spillMaxUnique, sampleIndex = 0) => runPostingsBenchOnce({ + benchRoot, + label: samples > 1 ? `${label}-sample-${sampleIndex}` : label, + spillMaxUnique, + vocabSize, + docs, + postingsPerToken, + tokenForIndex: buildChargramKey, + resultExtra: () => ({ + algorithm: enableRollingHash ? 'rolling-hash' : 'substring' + }) +}); + +const runBest = async (label, spillMaxUnique) => { + let best = null; + for (let sample = 0; sample < samples; sample += 1) { + const result = await runOnce(label, spillMaxUnique, sample); + if (!best || result.durationMs < best.durationMs) { + best = { + ...result, + label, + sample + }; } - map.set(token, postings); } - return map; -}; - -const buildChunkMeta = () => ( - Array.from({ length: docs }, () => ({ tokenCount: 0 })) -); - -const runOnce = async (label, spillMaxUnique) => { - const buildRoot = path.join(benchRoot, label); - await fs.rm(buildRoot, { recursive: true, force: true }); - await fs.mkdir(buildRoot, { recursive: true }); - const triPost = buildTriPost(); - const postingsConfig = normalizePostingsConfig({ - enableChargrams: true, - enablePhraseNgrams: false, - chargramSpillMaxUnique: spillMaxUnique - }); - const chunks = buildChunkMeta(); - const docLengths = new Array(docs).fill(0); - const heapBefore = process.memoryUsage().heapUsed; - const start = performance.now(); - const result = await buildPostings({ - chunks, - df: new Map(), - tokenPostings: new Map(), - docLengths, - fieldPostings: {}, - fieldDocLengths: {}, - phrasePost: new Map(), - triPost, - postingsConfig, - postingsGuard: null, - buildRoot, - modelId: 'bench', - useStubEmbeddings: true, - log: () => {}, - workerPool: null, - quantizePool: null, - embeddingsEnabled: false - }); - const durationMs = performance.now() - start; - const heapAfter = process.memoryUsage().heapUsed; return { - label, - algorithm: enableRollingHash ? 'rolling-hash' : 'substring', - durationMs, - heapDelta: heapAfter - heapBefore, - vocab: result.chargramVocab.length, - stats: result.chargramStats || null + ...best, + samples }; }; @@ -130,8 +80,9 @@ const printResult = (result, baseline = null) => { const parts = [ `algo=${result.algorithm}`, `ms=${result.durationMs.toFixed(1)}`, - `heapΔ=${(result.heapDelta / (1024 * 1024)).toFixed(1)}MB`, - `vocab=${result.vocab}` + `heapΔ=${formatHeapDeltaMb(result.heapDelta)}MB`, + `vocab=${result.vocab}`, + `sample=${(result.sample ?? 0) + 1}/${result.samples || 1}` ]; if (baseline) { const delta = result.durationMs - baseline.durationMs; @@ -145,12 +96,12 @@ let baseline = null; let current = null; if (mode !== 'current') { - baseline = await runOnce('baseline', 0); + baseline = await runBest('baseline', 0); printResult(baseline); } if (mode !== 'baseline') { - current = await runOnce('current', spillThreshold); + current = await runBest('current', spillThreshold); printResult(current, baseline); } @@ -167,6 +118,7 @@ const summary = { docs, postingsPerToken, spillThreshold, + samples, baseline, current }; diff --git a/tools/bench/index/chunk-meta-stream.js b/tools/bench/index/chunk-meta-stream.js index 065bc5df0..bd3ea0ce5 100644 --- a/tools/bench/index/chunk-meta-stream.js +++ b/tools/bench/index/chunk-meta-stream.js @@ -2,37 +2,19 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesFile, writeJsonLinesFileAsync } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile, writeJsonLinesFileAsync } from '../../../src/shared/json-stream/jsonl-write.js'; import { sha1File } from '../../../src/shared/hash.js'; import { createChunkMetaIterator } from '../../../src/index/build/artifacts/writers/chunk-meta.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; +import { + createSeededRng, + parseSimpleBenchArgs, + resolveCompareMode +} from '../shared.js'; +import { + createPeakTracker, + formatBenchResult, + runCompareBench +} from './streaming-bench-reporting.js'; const randomText = (rng, length) => { const chars = []; @@ -43,33 +25,18 @@ const randomText = (rng, length) => { return chars.join(''); }; -const createPeakTracker = () => { - let peak = process.memoryUsage().heapUsed; - return { - sample() { - const used = process.memoryUsage().heapUsed; - if (used > peak) peak = used; - }, - getPeak() { - return peak; - } - }; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const chunkCount = Number(args.chunks) || 40000; const tokensPerChunk = Number(args.tokens) || 120; const maxJsonBytes = Number(args.maxJsonBytes) || 1400; const seed = Number(args.seed) || 707; const sampleEvery = Number(args.sampleEvery) || 500; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); const benchRoot = path.join(process.cwd(), '.benchCache', 'chunk-meta-stream'); await fs.mkdir(benchRoot, { recursive: true }); -const rng = createRng(seed); +const rng = createSeededRng(seed); const files = Array.from({ length: Math.max(1, Math.floor(chunkCount / 20)) }, (_, i) => `src/file-${i}.ts`); const fileIdByPath = new Map(files.map((file, index) => [file, index])); @@ -204,36 +171,19 @@ const formatTrim = (stats) => { return `trimmedEntries=${stats.trimmedEntries || 0} trimmedMetaV2=${stats.trimmedMetaV2 || 0} trimmedFields=${fields}`; }; -const formatResult = (result, baseline = null) => { - const peakMb = result.peakHeap / (1024 * 1024); - const parts = [ +const formatResult = (result, baseline = null) => formatBenchResult({ + result, + fields: [ `chunks=${chunkCount}`, - `maxJsonBytes=${maxJsonBytes}`, - `ms=${result.durationMs.toFixed(1)}`, - `heapPeak=${peakMb.toFixed(1)}MB`, - `hash=${result.hash.slice(0, 8)}`, - formatTrim(result.trimStats) - ]; - if (baseline) { - const delta = result.durationMs - baseline.durationMs; - const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; - const memDelta = result.peakHeap - baseline.peakHeap; - parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); - parts.push(`heapΔ=${(memDelta / (1024 * 1024)).toFixed(1)}MB`); - } - return parts; -}; + `maxJsonBytes=${maxJsonBytes}` + ], + extraFields: [formatTrim(result.trimStats)], + baseline +}); -let baseline = null; -if (mode !== 'current') { - baseline = await runBaseline(); - console.log(`[bench] baseline ${formatResult(baseline).join(' ')}`); -} -if (mode !== 'baseline') { - const current = await runStreaming(); - console.log(`[bench] stream ${formatResult(current, baseline).join(' ')}`); - if (baseline) { - const match = baseline.hash === current.hash; - console.log(`[bench] hash-compare match=${match}`); - } -} +await runCompareBench({ + mode, + runBaseline, + runCurrent: runStreaming, + formatResult +}); diff --git a/tools/bench/index/discovery-reuse.js b/tools/bench/index/discovery-reuse.js index fde8cd264..74479e28a 100644 --- a/tools/bench/index/discovery-reuse.js +++ b/tools/bench/index/discovery-reuse.js @@ -1,26 +1,9 @@ #!/usr/bin/env node import { performance } from 'node:perf_hooks'; import { buildFileMeta } from '../../../src/index/build/artifacts/file-meta.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const fileCount = Number(args.files) || 10000; const chunksPerFile = Number(args.chunksPerFile) || 2; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) diff --git a/tools/bench/index/file-meta-compare.js b/tools/bench/index/file-meta-compare.js index c3abdcd3c..08dd51741 100644 --- a/tools/bench/index/file-meta-compare.js +++ b/tools/bench/index/file-meta-compare.js @@ -1,46 +1,9 @@ import { performance } from 'node:perf_hooks'; import { buildFileMetaColumnar } from '../../../src/index/build/artifacts/file-meta.js'; +import { inflateColumnarRows } from '../../../src/shared/artifact-io/columnar-rows.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const inflateColumnarRows = (payload) => { - if (!payload || payload.format !== 'columnar') return null; - const columns = Array.isArray(payload.columns) ? payload.columns : null; - const length = Number.isFinite(payload.length) ? payload.length : 0; - const arrays = payload.arrays && typeof payload.arrays === 'object' ? payload.arrays : null; - if (!columns || !arrays || !length) return null; - const tables = payload.tables && typeof payload.tables === 'object' ? payload.tables : null; - const rows = new Array(length); - for (let i = 0; i < length; i += 1) { - const row = {}; - for (const column of columns) { - const values = arrays[column]; - const value = values ? values[i] : null; - const table = tables ? tables[column] : null; - row[column] = table && Number.isInteger(value) ? (table[value] ?? null) : value; - } - rows[i] = row; - } - return rows; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const files = Number(args.files) || 50000; const iterations = Number(args.iterations) || 5; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) diff --git a/tools/bench/index/file-meta-streaming-load.js b/tools/bench/index/file-meta-streaming-load.js index d35f62f98..d0ed037ae 100644 --- a/tools/bench/index/file-meta-streaming-load.js +++ b/tools/bench/index/file-meta-streaming-load.js @@ -1,8 +1,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { loadFileMetaRows, loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { loadFileMetaRows, loadJsonArrayArtifact } from '../../../src/shared/artifact-io/loaders.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; const parseArgs = (argv) => { const args = { rows: 50000, indexDir: null }; diff --git a/tools/bench/index/filter-index-build.js b/tools/bench/index/filter-index-build.js index fccbb63fc..b24ff54e4 100644 --- a/tools/bench/index/filter-index-build.js +++ b/tools/bench/index/filter-index-build.js @@ -10,31 +10,14 @@ import { isRoaringAvailable, shouldUseBitmap } from '../../../src/retrieval/bitmap.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; +import { parseSimpleBenchArgs } from '../shared.js'; if (!isRoaringAvailable()) { console.log('[bench] roaring-wasm not available; skipping filter-index build bitmap bench'); process.exit(0); } -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const fileCount = Math.max(1, Number(args.files) || 2000); const chunksPerFile = Math.max(1, Number(args.chunksPerFile) || 32); const bitmapMinSize = Math.max(1, Number(args.minSize) || 256); @@ -129,12 +112,19 @@ const printCurrent = (result, baseline = null) => { `ms=${result.durationMs.toFixed(1)}`, `fileBitmaps=${result.fileBitmaps}` ]; + let delta = null; + let pct = null; if (baseline) { - const delta = result.durationMs - baseline.durationMs; - const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; + delta = result.durationMs - baseline.durationMs; + pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); } console.log(`[bench] current ${parts.join(' ')}`); + if (baseline) { + console.log( + `[bench] delta ms=${delta.toFixed(1)} (${pct?.toFixed(1)}%) fileBitmaps=${result.fileBitmaps - baseline.fileBitmaps}` + ); + } }; let baseline = null; @@ -148,4 +138,3 @@ if (mode !== 'baseline') { } releaseFilterIndexMemory(index); - diff --git a/tools/bench/index/graph-relations.js b/tools/bench/index/graph-relations.js index 6f7ba79da..1e5b5c5c7 100644 --- a/tools/bench/index/graph-relations.js +++ b/tools/bench/index/graph-relations.js @@ -2,28 +2,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesSharded, writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesSharded } from '../../../src/shared/json-stream/jsonl-sharded.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { createGraphRelationsIterator, measureGraphRelations } from '../../../src/index/build/artifacts/helpers.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const nodesPerGraph = Number(args.nodes) || 20000; const edgesPerNode = Number(args.edges) || 3; const maxBytes = Number(args.maxBytes) || 2 * 1024 * 1024; diff --git a/tools/bench/index/import-graph-incremental.js b/tools/bench/index/import-graph-incremental.js index bd76e7a11..61a40aebc 100644 --- a/tools/bench/index/import-graph-incremental.js +++ b/tools/bench/index/import-graph-incremental.js @@ -4,47 +4,21 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { sha1 } from '../../../src/shared/hash.js'; import { resolveImportLinks } from '../../../src/index/build/import-resolution.js'; +import { + createSeededRng, + parseSimpleBenchArgs, + pickRandom, + resolveCompareMode +} from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; - -const pick = (rng, list) => list[Math.floor(rng() * list.length)]; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const fileCount = Number(args.files) || 2000; const importsPerFile = Number(args.imports) || 6; const dirtyRate = Math.min(1, Math.max(0, Number(args.dirtyRate) || 0.1)); const seed = Number(args.seed) || 1337; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); -const rng = createRng(seed); +const rng = createSeededRng(seed); const benchRoot = path.join(process.cwd(), '.benchCache', 'import-graph-incremental'); await fs.mkdir(benchRoot, { recursive: true }); @@ -57,7 +31,7 @@ const baseRelations = new Map(); for (const rel of files) { const list = []; for (let i = 0; i < importsPerFile; i += 1) { - const target = pick(rng, files); + const target = pickRandom(rng, files); const relDir = path.posix.dirname(rel); let spec = path.posix.relative(relDir, target); if (!spec.startsWith('.')) spec = `./${spec}`; diff --git a/tools/bench/index/import-resolution-graph.js b/tools/bench/index/import-resolution-graph.js index 94f08d81b..a758a2236 100644 --- a/tools/bench/index/import-resolution-graph.js +++ b/tools/bench/index/import-resolution-graph.js @@ -2,48 +2,22 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { resolveImportLinks } from '../../../src/index/build/import-resolution.js'; +import { + createSeededRng, + parseSimpleBenchArgs, + pickRandom, + resolveCompareMode +} from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; - -const pick = (rng, list) => list[Math.floor(rng() * list.length)]; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const fileCount = Number(args.files) || 2000; const importsPerFile = Number(args.imports) || 6; const externalRate = Number(args.externalRate) || 0.25; const unresolvedRate = Number(args.unresolvedRate) || 0.05; const seed = Number(args.seed) || 1337; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); -const rng = createRng(seed); +const rng = createSeededRng(seed); const benchRoot = path.join(process.cwd(), '.benchCache', 'import-resolution-graph'); const root = path.join(benchRoot, 'repo'); @@ -64,7 +38,7 @@ for (const rel of files) { : `pkg-${Math.floor(rng() * 200)}`; list.push(pkg); } else { - const target = pick(rng, files); + const target = pickRandom(rng, files); const relDir = path.posix.dirname(rel); let spec = path.posix.relative(relDir, target); if (!spec.startsWith('.')) spec = `./${spec}`; diff --git a/tools/bench/index/index-state-write.js b/tools/bench/index/index-state-write.js index 845a5e35d..a72b60a12 100644 --- a/tools/bench/index/index-state-write.js +++ b/tools/bench/index/index-state-write.js @@ -4,27 +4,10 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { sha1 } from '../../../src/shared/hash.js'; import { stableStringifyForSignature } from '../../../src/shared/stable-json.js'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const updates = Number(args.updates) || 50; const files = Number(args.files) || 5000; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) @@ -48,6 +31,24 @@ const makeIndexState = (updatedAt) => { }; }; +const computeStableHash = (indexState) => { + const stableState = { ...indexState }; + delete stableState.generatedAt; + delete stableState.updatedAt; + return sha1(stableStringifyForSignature(stableState)); +}; + +const readStableIndexStateHash = (indexStatePath) => { + try { + const parsed = JSON.parse(fs.readFileSync(indexStatePath, 'utf8')); + const fields = parsed?.fields && typeof parsed.fields === 'object' ? parsed.fields : parsed; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return null; + return computeStableHash(fields); + } catch { + return null; + } +}; + const writeBaseline = async (runRoot) => { const indexStatePath = path.join(runRoot, 'index_state.json'); let bytes = 0; @@ -76,16 +77,13 @@ const writeCurrent = async (runRoot) => { const start = performance.now(); for (let i = 0; i < updates; i += 1) { const indexState = makeIndexState(new Date().toISOString()); - const stableState = { ...indexState }; - delete stableState.generatedAt; - delete stableState.updatedAt; - const stableHash = sha1(stableStringifyForSignature(stableState)); + const stableHash = computeStableHash(indexState); let canSkip = false; if (fs.existsSync(metaPath) && fs.existsSync(indexStatePath)) { try { const metaRaw = JSON.parse(fs.readFileSync(metaPath, 'utf8')); const meta = metaRaw?.fields && typeof metaRaw.fields === 'object' ? metaRaw.fields : metaRaw; - if (meta?.stableHash === stableHash) { + if (meta?.stableHash === stableHash && readStableIndexStateHash(indexStatePath) === stableHash) { canSkip = true; } } catch {} diff --git a/tools/bench/index/jsonl-compression-pipeline.js b/tools/bench/index/jsonl-compression-pipeline.js index ef9f5e89a..dca56183e 100644 --- a/tools/bench/index/jsonl-compression-pipeline.js +++ b/tools/bench/index/jsonl-compression-pipeline.js @@ -2,27 +2,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const rows = Number(args.rows) || 200000; const payloadBytes = Number(args.payloadBytes) || 128; const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) diff --git a/tools/bench/index/jsonl-offset-index.js b/tools/bench/index/jsonl-offset-index.js index 5e1248862..4cdb93e7b 100644 --- a/tools/bench/index/jsonl-offset-index.js +++ b/tools/bench/index/jsonl-offset-index.js @@ -3,39 +3,14 @@ import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesFile } from '../../../src/shared/json-stream.js'; -import { readJsonLinesArray } from '../../../src/shared/artifact-io.js'; +import { writeJsonLinesFile } from '../../../src/shared/json-stream/jsonl-write.js'; import { readJsonlRowAt } from '../../../src/shared/artifact-io/offsets.js'; -import { readJsonFile } from '../../../src/shared/artifact-io/json.js'; +import { readJsonFile, readJsonLinesArray } from '../../../src/shared/artifact-io/json.js'; import { readShardFiles } from '../../../src/shared/artifact-io/fs.js'; -import { toPosix } from '../../../src/shared/files.js'; +import { toPosix } from '../../../src/shared/file-paths.js'; +import { parseSimpleBenchArgs, percentile } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const percentile = (values, pct) => { - if (!values.length) return 0; - const sorted = values.slice().sort((a, b) => a - b); - const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * pct))); - return sorted[idx]; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const rows = Number(args.rows) || 100000; const lookups = Number(args.lookups) || 200; const indexDir = args.index ? path.resolve(String(args.index)) : null; diff --git a/tools/bench/index/minhash-packed.js b/tools/bench/index/minhash-packed.js index 89079d43c..85e4b71ad 100644 --- a/tools/bench/index/minhash-packed.js +++ b/tools/bench/index/minhash-packed.js @@ -1,50 +1,25 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { loadMinhashSignatures } from '../../../src/shared/artifact-io/loaders.js'; +import { + createSeededRng, + parseSimpleBenchArgs, + resolveCompareMode +} from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const count = Number(args.count) || 10000; const dims = Number(args.dims) || 64; const seed = Number(args.seed) || 2024; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); const benchRoot = args.root ? path.resolve(String(args.root)) : path.join(process.cwd(), '.benchCache', 'minhash-packed'); const buildSignatures = () => { - const rng = createRng(seed); + const rng = createSeededRng(seed); const signatures = new Array(count); for (let i = 0; i < count; i += 1) { const sig = new Array(dims); diff --git a/tools/bench/index/ordering-ledger.js b/tools/bench/index/ordering-ledger.js index e1c858c23..71eeaee5e 100644 --- a/tools/bench/index/ordering-ledger.js +++ b/tools/bench/index/ordering-ledger.js @@ -2,39 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { loadChunkMeta, loadGraphRelations, loadJsonArrayArtifact } from '../../../src/shared/artifact-io.js'; +import { loadChunkMeta, loadGraphRelations, loadJsonArrayArtifact } from '../../../src/shared/artifact-io/loaders.js'; import { createOrderingHasher, stableOrderWithComparator } from '../../../src/shared/order.js'; import { compareChunkMetaRows, createGraphRelationsIterator } from '../../../src/index/build/artifacts/helpers.js'; import { createFileRelationsIterator } from '../../../src/index/build/artifacts/writers/file-relations.js'; import { createRepoMapIterator } from '../../../src/index/build/artifacts/writers/repo-map.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; +import { createSeededRng, parseSimpleBenchArgs } from '../shared.js'; const resolveIndexDir = (root, args) => { if (args.index) { @@ -82,7 +55,7 @@ const buildSyntheticArtifacts = ({ edgesPerNode, seed }) => { - const rng = createRng(seed); + const rng = createSeededRng(seed); const chunkMeta = Array.from({ length: chunkCount }, (_, index) => { const fileIndex = index % fileCount; return { @@ -273,7 +246,7 @@ const printDelta = (baseline, current) => { ); }; -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const root = process.cwd(); const seed = Number(args.seed) || 1337; const chunkCount = Number(args.chunks) || 100000; diff --git a/tools/bench/index/postings-guard.js b/tools/bench/index/postings-guard.js index c7724fda7..33655c6e5 100644 --- a/tools/bench/index/postings-guard.js +++ b/tools/bench/index/postings-guard.js @@ -1,103 +1,33 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { buildPostings } from '../../../src/index/build/postings.js'; -import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; +import { + calculateThroughput, + formatHeapDeltaMb, + parseBenchArgs, + prepareBenchRoot, + resolveCompareMode, + runPostingsBenchOnce +} from './shared-postings-bench.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseBenchArgs(); const vocabSize = Number(args.vocab) || 250000; const docs = Number(args.docs) || 50000; const postingsPerToken = Number(args.postings) || 3; const spillThreshold = Number(args.spill) || 100000; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; - -const benchRoot = path.join(process.cwd(), '.benchCache', 'postings-guard'); -await fs.mkdir(benchRoot, { recursive: true }); +const mode = resolveCompareMode(args.mode); -const buildTriPost = () => { - const map = new Map(); - for (let i = 0; i < vocabSize; i += 1) { - const token = `cg-${i.toString(36)}`; - const postings = new Array(postingsPerToken); - const base = (i * 131) % Math.max(docs, 1); - for (let j = 0; j < postingsPerToken; j += 1) { - postings[j] = base + j; - } - map.set(token, postings); - } - return map; -}; - -const buildChunkMeta = () => ( - Array.from({ length: docs }, () => ({ tokenCount: 0 })) -); +const benchRoot = await prepareBenchRoot('postings-guard'); -const runOnce = async (label, spillMaxUnique) => { - const buildRoot = path.join(benchRoot, label); - await fs.rm(buildRoot, { recursive: true, force: true }); - await fs.mkdir(buildRoot, { recursive: true }); - const triPost = buildTriPost(); - const postingsConfig = normalizePostingsConfig({ - enableChargrams: true, - enablePhraseNgrams: false, - chargramSpillMaxUnique: spillMaxUnique - }); - const chunks = buildChunkMeta(); - const docLengths = new Array(docs).fill(0); - const heapBefore = process.memoryUsage().heapUsed; - const start = performance.now(); - const result = await buildPostings({ - chunks, - df: new Map(), - tokenPostings: new Map(), - docLengths, - fieldPostings: {}, - fieldDocLengths: {}, - phrasePost: new Map(), - triPost, - postingsConfig, - postingsGuard: null, - buildRoot, - modelId: 'bench', - useStubEmbeddings: true, - log: () => {}, - workerPool: null, - quantizePool: null, - embeddingsEnabled: false - }); - const durationMs = performance.now() - start; - const heapAfter = process.memoryUsage().heapUsed; - return { - label, - durationMs, - heapDelta: heapAfter - heapBefore, - vocab: result.chargramVocab.length, - stats: result.chargramStats || null - }; -}; +const buildGuardKey = (i) => `cg-${i.toString(36)}`; -const throughput = (durationMs) => (durationMs > 0 ? vocabSize / (durationMs / 1000) : 0); +const runOnce = (label, spillMaxUnique) => runPostingsBenchOnce({ + benchRoot, + label, + spillMaxUnique, + vocabSize, + docs, + postingsPerToken, + tokenForIndex: buildGuardKey +}); const formatStats = (stats) => { if (!stats) return 'spill=unknown'; @@ -105,10 +35,10 @@ const formatStats = (stats) => { }; const printResult = (result) => { - const tp = throughput(result.durationMs); + const tp = calculateThroughput(vocabSize, result.durationMs); console.log( `[bench] ${result.label} vocab=${result.vocab} ms=${result.durationMs.toFixed(1)} ` + - `throughput=${tp.toFixed(1)}/s heapΔ=${(result.heapDelta / (1024 * 1024)).toFixed(1)}MB ` + + `throughput=${tp.toFixed(1)}/s heapΔ=${formatHeapDeltaMb(result.heapDelta)}MB ` + `${formatStats(result.stats)}` ); return tp; @@ -121,7 +51,7 @@ const printDelta = (baseline, current, baseTp, curTp) => { console.log( `[bench] delta ms=${deltaMs.toFixed(1)} (${deltaPct.toFixed(1)}%) ` + `throughput=${curTp.toFixed(1)}/s Δ=${deltaTp.toFixed(1)}/s ` + - `heapΔ=${((current.heapDelta - baseline.heapDelta) / (1024 * 1024)).toFixed(1)}MB` + `heapΔ=${formatHeapDeltaMb(current.heapDelta - baseline.heapDelta)}MB` ); }; diff --git a/tools/bench/index/postings-packed.js b/tools/bench/index/postings-packed.js index deaad88df..d69eb9d07 100644 --- a/tools/bench/index/postings-packed.js +++ b/tools/bench/index/postings-packed.js @@ -6,34 +6,11 @@ import { encodePackedOffsets, decodePackedOffsets } from '../../../src/shared/packed-postings.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; +import { + createSeededRng, + parseSimpleBenchArgs, + resolveCompareMode +} from '../shared.js'; const encodeVarint = (value, out) => { let v = value >>> 0; @@ -104,8 +81,8 @@ const decodeIdPostings = (buffer, offsets, count) => { return lists; }; -const buildIdPostings = ({ vocabSize, docs, postingsPerToken, seed, label }) => { - const rng = createRng(seed); +const buildSyntheticPostings = ({ vocabSize, docs, postingsPerToken, seed, label }, makePostingEntry) => { + const rng = createSeededRng(seed); const vocab = new Array(vocabSize); const postings = new Array(vocabSize); for (let i = 0; i < vocabSize; i += 1) { @@ -116,32 +93,21 @@ const buildIdPostings = ({ vocabSize, docs, postingsPerToken, seed, label }) => for (let j = 0; j < count; j += 1) { cursor += 1 + Math.floor(rng() * 12); if (cursor >= docs) break; - list.push(cursor); + list.push(makePostingEntry(cursor, rng)); } postings[i] = list; } return { vocab, postings }; }; -const buildTfPostings = ({ vocabSize, docs, postingsPerToken, seed, label }) => { - const rng = createRng(seed); - const vocab = new Array(vocabSize); - const postings = new Array(vocabSize); - for (let i = 0; i < vocabSize; i += 1) { - vocab[i] = `${label}-${i.toString(36)}`; - const list = []; - let cursor = Math.floor(rng() * docs); - const count = Math.max(1, Math.floor(postingsPerToken * (0.6 + rng()))); - for (let j = 0; j < count; j += 1) { - cursor += 1 + Math.floor(rng() * 12); - if (cursor >= docs) break; - const tf = 1 + Math.floor(rng() * 3); - list.push([cursor, tf]); - } - postings[i] = list; - } - return { vocab, postings }; -}; +const buildIdPostings = (options) => buildSyntheticPostings(options, (cursor) => cursor); + +const buildTfPostings = ({ vocabSize, docs, postingsPerToken, seed, label }) => ( + buildSyntheticPostings({ vocabSize, docs, postingsPerToken, seed, label }, (cursor, rng) => [ + cursor, + 1 + Math.floor(rng() * 3) + ]) +); const runTimed = (fn, iterations) => { const start = performance.now(); @@ -151,7 +117,7 @@ const runTimed = (fn, iterations) => { return performance.now() - start; }; -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const docs = Number(args.docs) || 100000; const tokenVocab = Number(args.tokens) || 20000; const phraseVocab = Number(args.phrases) || 6000; @@ -159,9 +125,7 @@ const chargramVocab = Number(args.chargrams) || 12000; const postingsPerToken = Number(args.postings) || 8; const iterations = Number(args.iterations) || 8; const seed = Number(args.seed) || 2024; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); const workloads = [ { label: 'token', vocabSize: tokenVocab, seedOffset: 1, type: 'tf' }, diff --git a/tools/bench/index/postings-real.js b/tools/bench/index/postings-real.js index 57fbdff54..2dfaf59e1 100644 --- a/tools/bench/index/postings-real.js +++ b/tools/bench/index/postings-real.js @@ -2,31 +2,15 @@ import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { performance } from 'node:perf_hooks'; import { getRepoId } from '../../shared/dict-utils.js'; -import { loadChunkMeta, MAX_JSON_BYTES } from '../../../src/shared/artifact-io.js'; +import { MAX_JSON_BYTES } from '../../../src/shared/artifact-io/constants.js'; +import { loadChunkMeta } from '../../../src/shared/artifact-io/loaders.js'; import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; import { stableStringifyForSignature } from '../../../src/shared/stable-json.js'; import { sha1 } from '../../../src/shared/hash.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; +import { spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; +import { parseSimpleBenchArgs } from '../shared.js'; const readJsonFields = async (filePath) => { const raw = await fs.readFile(filePath, 'utf8'); @@ -44,18 +28,24 @@ const safeRm = async (dir) => { }; const runNodeScript = ({ scriptPath, args, env, cwd }) => { - const result = spawnSync(process.execPath, [scriptPath, ...args], { + const result = spawnSubprocessSync(process.execPath, [scriptPath, ...args], { cwd, env, - encoding: 'utf8' + outputEncoding: 'utf8', + captureStdout: true, + captureStderr: true, + outputMode: 'string', + rejectOnNonZeroExit: false, + killTree: true, + detached: process.platform !== 'win32' }); - if (result.status === 0) return; + if (result.exitCode === 0) return; if (result.stdout) process.stderr.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); - throw new Error(`Script failed: ${path.basename(scriptPath)} (${result.status ?? 'unknown'})`); + throw new Error(`Script failed: ${path.basename(scriptPath)} (${result.exitCode ?? 'unknown'})`); }; -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() : 'compare'; diff --git a/tools/bench/index/relations-build.js b/tools/bench/index/relations-build.js index a18ddbb47..e5a990b82 100644 --- a/tools/bench/index/relations-build.js +++ b/tools/bench/index/relations-build.js @@ -2,30 +2,17 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonObjectFile } from '../../../src/shared/json-stream.js'; +import { writeJsonObjectFile } from '../../../src/shared/json-stream/json-writers.js'; import { buildRelationGraphs } from '../../../src/index/build/graphs.js'; -import { enqueueGraphRelationsArtifacts } from '../../../src/index/build/artifacts/graph-relations.js'; -import { readJsonFile } from '../../../src/shared/artifact-io.js'; +import { readJsonFile } from '../../../src/shared/artifact-io/json.js'; +import { parseSimpleBenchArgs } from '../shared.js'; +import { + buildRelationBenchChunks, + buildRelationBenchFileRelations, + writeRelationBenchGraphArtifacts +} from './relations-fixture.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const chunkCount = Math.max(10, Number(args.chunks) || 10000); const edgesPerChunk = Math.max(0, Number(args.edges) || 2); const maxBytes = Math.max(1024 * 32, Number(args.maxBytes) || 256 * 1024); @@ -37,40 +24,8 @@ const benchRoot = path.join(process.cwd(), '.benchCache', 'relations-build'); await fs.rm(benchRoot, { recursive: true, force: true }); await fs.mkdir(benchRoot, { recursive: true }); -const buildChunks = () => { - const chunks = new Array(chunkCount); - for (let i = 0; i < chunkCount; i += 1) { - const file = `src/file-${String(i % 250).padStart(3, '0')}.js`; - const uid = `u${i}`; - const callDetails = []; - for (let j = 1; j <= edgesPerChunk; j += 1) { - callDetails.push({ targetChunkUid: `u${(i + j) % chunkCount}` }); - } - chunks[i] = { - file, - ext: '.js', - name: `sym${i}`, - kind: 'FunctionDeclaration', - chunkUid: uid, - metaV2: { - chunkUid: uid, - lang: 'javascript', - effective: { languageId: 'javascript' }, - symbol: { symbolId: `sym-${i}` } - }, - codeRelations: { callDetails } - }; - } - return chunks; -}; - -const buildFileRelations = () => new Map([ - ['src/file-000.js', { importLinks: ['src/file-001.js'] }], - ['src/file-001.js', { importLinks: ['src/file-000.js'] }] -]); - -const chunks = buildChunks(); -const fileRelations = buildFileRelations(); +const chunks = buildRelationBenchChunks({ chunkCount, edgesPerChunk }); +const fileRelations = buildRelationBenchFileRelations(); const runBaseline = async () => { const outDir = path.join(benchRoot, 'baseline'); @@ -91,30 +46,12 @@ const runCurrent = async () => { await fs.rm(outDir, { recursive: true, force: true }); await fs.mkdir(outDir, { recursive: true }); - const toPosix = (value) => value.split(path.sep).join('/'); - const formatArtifactLabel = (filePath) => toPosix(path.relative(outDir, filePath)); - const removeArtifact = async (targetPath) => { - await fs.rm(targetPath, { recursive: true, force: true }).catch(() => {}); - }; - const start = performance.now(); - await enqueueGraphRelationsArtifacts({ - graphRelations: null, + await writeRelationBenchGraphArtifacts({ + outDir, chunks, fileRelations, - callSites: null, - caps: null, - outDir, - maxJsonBytes: maxBytes, - byteBudget: null, - log: null, - enqueueWrite: () => { - throw new Error('enqueueWrite should not be called by streaming graph_relations build'); - }, - addPieceFile: () => {}, - formatArtifactLabel, - removeArtifact, - stageCheckpoints: null + maxJsonBytes: maxBytes }); const durationMs = performance.now() - start; const metaRaw = readJsonFile(path.join(outDir, 'graph_relations.meta.json'), { maxBytes: 1024 * 1024 }); @@ -132,12 +69,17 @@ const printCurrent = (result, baseline = null) => { `ms=${result.durationMs.toFixed(1)}`, `bytes=${result.bytes}` ]; + let delta = null; + let pct = null; if (baseline) { - const delta = result.durationMs - baseline.durationMs; - const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; + delta = result.durationMs - baseline.durationMs; + pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); } console.log(`[bench] current ${parts.join(' ')}`); + if (baseline) { + console.log(`[bench] delta ms=${delta.toFixed(1)} (${pct?.toFixed(1)}%) bytes=${result.bytes - baseline.bytes}`); + } }; let baseline = null; @@ -149,4 +91,3 @@ if (mode !== 'baseline') { const current = await runCurrent(); printCurrent(current, baseline); } - diff --git a/tools/bench/index/relations-fixture.js b/tools/bench/index/relations-fixture.js new file mode 100644 index 000000000..7efb06a0e --- /dev/null +++ b/tools/bench/index/relations-fixture.js @@ -0,0 +1,81 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { enqueueGraphRelationsArtifacts } from '../../../src/index/build/artifacts/graph-relations.js'; + +export const buildRelationBenchChunks = ({ + chunkCount = 10000, + edgesPerChunk = 2, + fileModulo = 250, + edgeCountForChunk = null +} = {}) => { + const chunks = new Array(chunkCount); + for (let i = 0; i < chunkCount; i += 1) { + const file = `src/file-${String(i % fileModulo).padStart(3, '0')}.js`; + const uid = `u${i}`; + const callDetails = []; + const edgeCount = typeof edgeCountForChunk === 'function' + ? edgeCountForChunk(i) + : edgesPerChunk; + for (let j = 1; j <= edgeCount; j += 1) { + callDetails.push({ targetChunkUid: `u${(i + j) % chunkCount}` }); + } + chunks[i] = { + file, + ext: '.js', + name: `sym${i}`, + kind: 'FunctionDeclaration', + chunkUid: uid, + metaV2: { + chunkUid: uid, + lang: 'javascript', + effective: { languageId: 'javascript' }, + symbol: { symbolId: `sym-${i}` } + }, + codeRelations: { callDetails } + }; + } + return chunks; +}; + +export const buildRelationBenchFileRelations = () => new Map([ + ['src/file-000.js', { importLinks: ['src/file-001.js'] }], + ['src/file-001.js', { importLinks: ['src/file-000.js'] }] +]); + +export const formatRelationBenchArtifactLabel = (outDir, filePath) => ( + path.relative(outDir, filePath).split(path.sep).join('/') +); + +export const removeRelationBenchArtifact = async (targetPath) => { + await fs.rm(targetPath, { recursive: true, force: true }).catch(() => {}); +}; + +export const writeRelationBenchGraphArtifacts = async ({ + outDir, + chunks, + fileRelations, + maxJsonBytes, + onPiece = null +}) => { + const formatArtifactLabel = (filePath) => formatRelationBenchArtifactLabel(outDir, filePath); + await enqueueGraphRelationsArtifacts({ + graphRelations: null, + chunks, + fileRelations, + callSites: null, + caps: null, + outDir, + maxJsonBytes, + byteBudget: null, + log: null, + enqueueWrite: () => { + throw new Error('enqueueWrite should not be called by streaming graph_relations build'); + }, + addPieceFile: (entry, filePath) => { + if (typeof onPiece === 'function') onPiece(entry, filePath, formatArtifactLabel(filePath)); + }, + formatArtifactLabel, + removeArtifact: removeRelationBenchArtifact, + stageCheckpoints: null + }); +}; diff --git a/tools/bench/index/repo-map-compress.js b/tools/bench/index/repo-map-compress.js index 29763f45c..c8f3910f6 100644 --- a/tools/bench/index/repo-map-compress.js +++ b/tools/bench/index/repo-map-compress.js @@ -3,26 +3,9 @@ import { performance } from 'node:perf_hooks'; import { stableOrder } from '../../../src/shared/order.js'; import { orderRepoMapEntries } from '../../../src/shared/order.js'; import { createRepoMapIterator } from '../../../src/index/build/artifacts/writers/repo-map.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const fileCount = Math.max(1, Number(args.files) || 1500); const symbolsPerFile = Math.max(1, Number(args.symbols) || 40); const dupFactor = Math.max(1, Number(args.dup) || 2); @@ -141,12 +124,19 @@ const printCurrent = (result, baseline = null) => { `ms=${result.durationMs.toFixed(1)}`, `rowsPerSec=${Math.round(result.rowsPerSec)}` ]; + let delta = null; + let pct = null; if (baseline) { - const delta = result.durationMs - baseline.durationMs; - const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; + delta = result.durationMs - baseline.durationMs; + pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); } console.log(`[bench] current ${parts.join(' ')}`); + if (baseline) { + console.log( + `[bench] delta rows=${result.count} ms=${delta.toFixed(1)} (${pct?.toFixed(1)}%) rowsPerSec=${Math.round(result.rowsPerSec - baseline.rowsPerSec)}` + ); + } }; let baseline = null; @@ -160,4 +150,3 @@ if (mode !== 'baseline') { const current = runIterator(currentIterator); printCurrent(current, baseline); } - diff --git a/tools/bench/index/scheduler-build.js b/tools/bench/index/scheduler-build.js index d40510790..da6beca37 100644 --- a/tools/bench/index/scheduler-build.js +++ b/tools/bench/index/scheduler-build.js @@ -1,38 +1,15 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { performance } from 'node:perf_hooks'; import { loadUserConfig, getIndexDir } from '../../shared/dict-utils.js'; -import { loadChunkMeta, MAX_JSON_BYTES } from '../../../src/shared/artifact-io.js'; +import { MAX_JSON_BYTES } from '../../../src/shared/artifact-io/constants.js'; +import { loadChunkMeta } from '../../../src/shared/artifact-io/loaders.js'; import { exitLikeChild } from '../../../src/tui/wrapper-exit.js'; +import { spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; +import { parseSimpleBenchArgs } from '../shared.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const percentile = (values, pct) => { - if (!values.length) return 0; - const sorted = values.slice().sort((a, b) => a - b); - const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * pct))); - return sorted[idx]; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() : 'compare'; @@ -62,10 +39,17 @@ const runOnce = async (label, schedulerEnabled) => { '--quiet' ]; const start = performance.now(); - const result = spawnSync(process.execPath, args, { env, cwd: repoRoot, stdio: 'inherit' }); - if (result.status !== 0) { + const result = spawnSubprocessSync(process.execPath, args, { + env, + cwd: repoRoot, + stdio: 'inherit', + rejectOnNonZeroExit: false, + killTree: true, + detached: process.platform !== 'win32' + }); + if (result.exitCode !== 0) { console.error(`[bench] build_index failed for ${label}`); - exitLikeChild({ status: result.status, signal: result.signal }); + exitLikeChild({ status: result.exitCode, signal: result.signal }); } const totalMs = performance.now() - start; const userConfig = loadUserConfig(repoRoot); diff --git a/tools/bench/index/scheduler-io-starvation.js b/tools/bench/index/scheduler-io-starvation.js index 2851c470b..ba169c837 100644 --- a/tools/bench/index/scheduler-io-starvation.js +++ b/tools/bench/index/scheduler-io-starvation.js @@ -1,36 +1,12 @@ #!/usr/bin/env node import { performance } from 'node:perf_hooks'; import PQueue from 'p-queue'; -import { createBuildScheduler } from '../../../src/shared/concurrency.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; +import { createBuildScheduler } from '../../../src/shared/concurrency/scheduler-core.js'; +import { parseSimpleBenchArgs, percentile } from '../shared.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const percentile = (values, pct) => { - if (!values.length) return 0; - const sorted = values.slice().sort((a, b) => a - b); - const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * pct))); - return sorted[idx]; -}; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) ? String(args.mode).toLowerCase() : 'compare'; diff --git a/tools/bench/index/scheduler-store-format.js b/tools/bench/index/scheduler-store-format.js index 22deb6f9e..c18beeeec 100644 --- a/tools/bench/index/scheduler-store-format.js +++ b/tools/bench/index/scheduler-store-format.js @@ -1,37 +1,15 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { performance } from 'node:perf_hooks'; -import { loadChunkMeta, MAX_JSON_BYTES, readJsonFile } from '../../../src/shared/artifact-io.js'; +import { MAX_JSON_BYTES } from '../../../src/shared/artifact-io/constants.js'; +import { readJsonFile } from '../../../src/shared/artifact-io/json.js'; +import { loadChunkMeta } from '../../../src/shared/artifact-io/loaders.js'; import { resolveVersionedCacheRoot } from '../../../src/shared/cache-roots.js'; import { mergeConfig } from '../../../src/shared/config.js'; import { getRepoId } from '../../shared/dict-utils.js'; - -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const percentile = (values, pct) => { - if (!values.length) return 0; - const sorted = values.slice().sort((a, b) => a - b); - const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * pct))); - return sorted[idx]; -}; +import { spawnSubprocessSync } from '../../../src/shared/subprocess/runner.js'; +import { parseSimpleBenchArgs, percentile } from '../shared.js'; const average = (values) => ( values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 @@ -57,7 +35,7 @@ const parseMode = (value) => { return 'compare'; }; -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const mode = parseMode(args.mode); const runs = Math.max(1, Number(args.runs) || 3); const repoRoot = args.repo @@ -142,13 +120,16 @@ const runVariantOnce = async (key, runNumber, cacheRoot) => { ]; const startedAt = performance.now(); - const result = spawnSync(process.execPath, buildArgs, { + const result = spawnSubprocessSync(process.execPath, buildArgs, { env, cwd: repoRoot, - stdio: 'inherit' + stdio: 'inherit', + rejectOnNonZeroExit: false, + killTree: true, + detached: process.platform !== 'win32' }); - if (result.status !== 0) { - throw new Error(`build_index failed for ${key} run ${runNumber} (exit=${result.status})`); + if (result.exitCode !== 0) { + throw new Error(`build_index failed for ${key} run ${runNumber} (exit=${result.exitCode})`); } const totalMs = performance.now() - startedAt; const chunkCount = await getCodeChunkCount(cacheRoot); diff --git a/tools/bench/index/shared-postings-bench.js b/tools/bench/index/shared-postings-bench.js new file mode 100644 index 000000000..c9d4a2eaa --- /dev/null +++ b/tools/bench/index/shared-postings-bench.js @@ -0,0 +1,108 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { buildPostings } from '../../../src/index/build/postings.js'; +import { normalizePostingsConfig } from '../../../src/shared/postings-config.js'; +export { parseBenchArgs, resolveCompareMode } from '../shared.js'; + +export const prepareBenchRoot = async (name, cwd = process.cwd()) => { + const benchRoot = path.join(cwd, '.benchCache', name); + await fs.mkdir(benchRoot, { recursive: true }); + return benchRoot; +}; + +export const buildTriPostFixture = ({ + vocabSize, + docs, + postingsPerToken, + tokenForIndex +}) => { + const map = new Map(); + const safeDocs = Math.max(docs, 1); + for (let i = 0; i < vocabSize; i += 1) { + const token = tokenForIndex(i); + const postings = new Array(postingsPerToken); + const base = (i * 131) % safeDocs; + for (let j = 0; j < postingsPerToken; j += 1) { + postings[j] = base + j; + } + map.set(token, postings); + } + return map; +}; + +export const buildChunkMetaFixture = (docs) => ( + Array.from({ length: docs }, () => ({ tokenCount: 0 })) +); + +export const runPostingsBenchOnce = async ({ + benchRoot, + label, + spillMaxUnique, + vocabSize, + docs, + postingsPerToken, + tokenForIndex, + postingsConfigOverrides = {}, + resultExtra = null +}) => { + const buildRoot = path.join(benchRoot, label); + await fs.rm(buildRoot, { recursive: true, force: true }); + await fs.mkdir(buildRoot, { recursive: true }); + const triPost = buildTriPostFixture({ + vocabSize, + docs, + postingsPerToken, + tokenForIndex + }); + const postingsConfig = normalizePostingsConfig({ + enableChargrams: true, + enablePhraseNgrams: false, + chargramSpillMaxUnique: spillMaxUnique, + ...postingsConfigOverrides + }); + const chunks = buildChunkMetaFixture(docs); + const docLengths = new Array(docs).fill(0); + const heapBefore = process.memoryUsage().heapUsed; + const start = performance.now(); + const result = await buildPostings({ + chunks, + df: new Map(), + tokenPostings: new Map(), + docLengths, + fieldPostings: {}, + fieldDocLengths: {}, + phrasePost: new Map(), + triPost, + postingsConfig, + postingsGuard: null, + buildRoot, + modelId: 'bench', + useStubEmbeddings: true, + log: () => {}, + workerPool: null, + quantizePool: null, + embeddingsEnabled: false + }); + const durationMs = performance.now() - start; + const heapAfter = process.memoryUsage().heapUsed; + const extra = typeof resultExtra === 'function' + ? resultExtra({ result, label, spillMaxUnique }) + : {}; + return { + label, + ...extra, + durationMs, + heapDelta: heapAfter - heapBefore, + vocab: result.chargramVocab.length, + stats: result.chargramStats || null + }; +}; + +export const calculateThroughput = (vocabSize, durationMs) => ( + durationMs > 0 ? vocabSize / (durationMs / 1000) : 0 +); + +export const formatHeapDeltaMb = (bytes) => ( + (bytes / (1024 * 1024)).toFixed(1) +); diff --git a/tools/bench/index/streaming-bench-reporting.js b/tools/bench/index/streaming-bench-reporting.js new file mode 100644 index 000000000..3cd5a893f --- /dev/null +++ b/tools/bench/index/streaming-bench-reporting.js @@ -0,0 +1,52 @@ +export const createPeakTracker = () => { + let peak = process.memoryUsage().heapUsed; + return { + sample() { + const used = process.memoryUsage().heapUsed; + if (used > peak) peak = used; + }, + getPeak() { + return peak; + } + }; +}; + +export const formatBenchResult = ({ result, fields = [], extraFields = [], baseline = null }) => { + const peakMb = result.peakHeap / (1024 * 1024); + const parts = [ + ...fields, + `ms=${result.durationMs.toFixed(1)}`, + `heapPeak=${peakMb.toFixed(1)}MB`, + `hash=${result.hash.slice(0, 8)}`, + ...extraFields + ]; + if (baseline) { + const delta = result.durationMs - baseline.durationMs; + const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; + const memDelta = result.peakHeap - baseline.peakHeap; + parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); + parts.push(`heapΔ=${(memDelta / (1024 * 1024)).toFixed(1)}MB`); + } + return parts; +}; + +export const runCompareBench = async ({ + mode, + runBaseline, + runCurrent, + formatResult +}) => { + let baseline = null; + if (mode !== 'current') { + baseline = await runBaseline(); + console.log(`[bench] ${baseline.label} ${formatResult(baseline).join(' ')}`); + } + if (mode !== 'baseline') { + const current = await runCurrent(); + console.log(`[bench] ${current.label} ${formatResult(current, baseline).join(' ')}`); + if (baseline) { + const match = baseline.hash === current.hash; + console.log(`[bench] hash-compare match=${match}`); + } + } +}; diff --git a/tools/bench/index/symbol-artifacts.js b/tools/bench/index/symbol-artifacts.js index 50da3bac9..92dbe56ce 100644 --- a/tools/bench/index/symbol-artifacts.js +++ b/tools/bench/index/symbol-artifacts.js @@ -2,46 +2,25 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { writeJsonLinesFile, writeJsonLinesFileAsync } from '../../../src/shared/json-stream.js'; +import { writeJsonLinesFile, writeJsonLinesFileAsync } from '../../../src/shared/json-stream/jsonl-write.js'; import { sha1File } from '../../../src/shared/hash.js'; +import { + createSeededRng, + parseSimpleBenchArgs, + pickRandom, + resolveCompareMode +} from '../shared.js'; +import { + createPeakTracker, + formatBenchResult, + runCompareBench +} from './streaming-bench-reporting.js'; -const parseArgs = () => { - const out = {}; - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - out[key] = next; - i += 1; - } else { - out[key] = true; - } - } - return out; -}; - -const createRng = (seed) => { - let t = seed >>> 0; - return () => { - t += 0x6d2b79f5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; - }; -}; - -const pick = (rng, list) => list[Math.floor(rng() * list.length)]; - -const args = parseArgs(); +const args = parseSimpleBenchArgs(); const rowCount = Number(args.rows) || 50000; const seed = Number(args.seed) || 4242; const sampleEvery = Number(args.sampleEvery) || 500; -const mode = ['baseline', 'current', 'compare'].includes(String(args.mode).toLowerCase()) - ? String(args.mode).toLowerCase() - : 'compare'; +const mode = resolveCompareMode(args.mode); const benchRoot = path.join(process.cwd(), '.benchCache', 'symbol-artifacts'); await fs.mkdir(benchRoot, { recursive: true }); @@ -60,37 +39,24 @@ const createRow = (index, rng) => { v: 1, symbolId: symbolBase, scopedId: `scoped-${symbolBase}`, - scheme: pick(rng, schemes), + scheme: pickRandom(rng, schemes), symbolKey: `key-${symbolBase}`, signatureKey: `sig-${symbolBase}`, chunkUid: `chunk-${index}`, virtualPath: file, segmentUid: `seg-${index}`, file, - lang: pick(rng, langs), - kind: pick(rng, kinds), - kindGroup: pick(rng, kindGroups), + lang: pickRandom(rng, langs), + kind: pickRandom(rng, kinds), + kindGroup: pickRandom(rng, kindGroups), name, qualifiedName: `ns.${name}`, signature: `fn(${index})` }; }; -const createPeakTracker = () => { - let peak = process.memoryUsage().heapUsed; - return { - sample() { - const used = process.memoryUsage().heapUsed; - if (used > peak) peak = used; - }, - getPeak() { - return peak; - } - }; -}; - const buildRows = (count, seedValue, tracker) => { - const rng = createRng(seedValue); + const rng = createSeededRng(seedValue); const rows = new Array(count); for (let i = 0; i < count; i += 1) { rows[i] = createRow(i, rng); @@ -101,7 +67,7 @@ const buildRows = (count, seedValue, tracker) => { }; const buildRowStream = (count, seedValue, tracker) => { - const rng = createRng(seedValue); + const rng = createSeededRng(seedValue); return (async function* iterator() { for (let i = 0; i < count; i += 1) { if (tracker && i % sampleEvery === 0) tracker.sample(); @@ -147,34 +113,15 @@ const runStreaming = async () => { }; }; -const formatResult = (result, baseline = null) => { - const peakMb = result.peakHeap / (1024 * 1024); - const parts = [ - `rows=${rowCount}`, - `ms=${result.durationMs.toFixed(1)}`, - `heapPeak=${peakMb.toFixed(1)}MB`, - `hash=${result.hash.slice(0, 8)}` - ]; - if (baseline) { - const delta = result.durationMs - baseline.durationMs; - const pct = baseline.durationMs > 0 ? (delta / baseline.durationMs) * 100 : null; - const memDelta = result.peakHeap - baseline.peakHeap; - parts.push(`delta=${delta.toFixed(1)}ms (${pct?.toFixed(1)}%)`); - parts.push(`heapΔ=${(memDelta / (1024 * 1024)).toFixed(1)}MB`); - } - return parts; -}; +const formatResult = (result, baseline = null) => formatBenchResult({ + result, + fields: [`rows=${rowCount}`], + baseline +}); -let baseline = null; -if (mode !== 'current') { - baseline = await runBaseline(); - console.log(`[bench] baseline ${formatResult(baseline).join(' ')}`); -} -if (mode !== 'baseline') { - const current = await runStreaming(); - console.log(`[bench] stream ${formatResult(current, baseline).join(' ')}`); - if (baseline) { - const match = baseline.hash === current.hash; - console.log(`[bench] hash-compare match=${match}`); - } -} +await runCompareBench({ + mode, + runBaseline, + runCurrent: runStreaming, + formatResult +}); diff --git a/tools/bench/index/throughput-compare.js b/tools/bench/index/throughput-compare.js new file mode 100644 index 000000000..ed6ef68c5 --- /dev/null +++ b/tools/bench/index/throughput-compare.js @@ -0,0 +1,56 @@ +export const formatThroughput = ({ items, durationMs }) => ( + durationMs > 0 ? (items / (durationMs / 1000)) : 0 +); + +export const printThroughputResult = (result, { + itemLabel, + items, + extras = '' +} = {}) => { + const throughput = formatThroughput({ items, durationMs: result.durationMs }); + console.log( + `[bench] ${result.label} ${itemLabel}=${items} ms=${result.durationMs.toFixed(1)} ` + + `throughput=${throughput.toFixed(1)}/s bytes=${result.bytes}${extras}` + ); + return throughput; +}; + +export const printThroughputDelta = (baseline, current, baselineThroughput, currentThroughput) => { + const deltaMs = current.durationMs - baseline.durationMs; + const deltaPct = baseline.durationMs > 0 ? (deltaMs / baseline.durationMs) * 100 : 0; + const deltaThroughput = currentThroughput - baselineThroughput; + const deltaBytes = current.bytes - baseline.bytes; + console.log( + `[bench] delta ms=${deltaMs.toFixed(1)} (${deltaPct.toFixed(1)}%) ` + + `throughput=${currentThroughput.toFixed(1)}/s Δ=${deltaThroughput.toFixed(1)}/s ` + + `bytes=${current.bytes} Δ=${deltaBytes}` + ); +}; + +export const runComparedThroughputBenchmarks = async ({ + mode, + runBaseline, + runCurrent, + printResult = printThroughputResult +}) => { + let baseline = null; + let current = null; + let baselineThroughput = 0; + let currentThroughput = 0; + + if (mode !== 'current') { + baseline = await runBaseline(); + baselineThroughput = printResult(baseline); + } + + if (mode !== 'baseline') { + current = await runCurrent(); + currentThroughput = printResult(current); + } + + if (baseline && current) { + printThroughputDelta(baseline, current, baselineThroughput, currentThroughput); + } + + return { baseline, current, baselineThroughput, currentThroughput }; +}; diff --git a/tools/bench/language-blocker-closure.js b/tools/bench/language-blocker-closure.js new file mode 100644 index 000000000..4c75dad85 --- /dev/null +++ b/tools/bench/language-blocker-closure.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +import { createCli } from '../../src/shared/cli.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { writeTextIfChanged } from '../shared/generated-report.js'; +import { + buildBenchRuntimeBlockerClosureEvidence, + formatBenchRuntimeBlockerClosureEvidenceMarkdown +} from './language/canaries.js'; + +const argv = createCli({ + scriptName: 'pairofcleats bench language blocker-closure', + options: { + 'live-summary': { type: 'string', demandOption: true }, + 'benchmark-report': { type: 'array', default: [] }, + 'out-json': { type: 'string', default: '' }, + 'out-md': { type: 'string', default: '' }, + 'require-closure': { type: 'boolean', default: false } + } +}).parse(); + +const root = process.cwd(); +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); + +const run = async () => { + const liveSummaryPath = path.resolve(root, String(argv['live-summary']).trim()); + const reportPaths = (Array.isArray(argv['benchmark-report']) ? argv['benchmark-report'] : []) + .map((entry) => path.resolve(root, String(entry || '').trim())) + .filter(Boolean); + const liveSummary = readJson(liveSummaryPath); + const benchmarkConfirmations = reportPaths + .map((filePath) => readJson(filePath)?.blockerConfirmations?.summary || null) + .filter(Boolean); + const evidence = buildBenchRuntimeBlockerClosureEvidence({ + liveSummary, + benchmarkConfirmations + }); + + const outJsonPath = String(argv['out-json'] || '').trim() + ? path.resolve(root, String(argv['out-json']).trim()) + : ''; + const outMdPath = String(argv['out-md'] || '').trim() + ? path.resolve(root, String(argv['out-md']).trim()) + : ''; + if (outJsonPath) { + await writeJsonFileResolved(outJsonPath, evidence, { trailingNewline: true }); + } + if (outMdPath) { + await writeTextIfChanged(outMdPath, formatBenchRuntimeBlockerClosureEvidenceMarkdown(evidence), { encoding: 'utf8' }); + } + process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`); + if (argv['require-closure'] === true && !evidence.ok) { + process.exit(1); + } +}; + +run().catch((error) => { + console.error(error?.message || error); + process.exit(1); +}); diff --git a/tools/bench/language-canaries.js b/tools/bench/language-canaries.js new file mode 100644 index 000000000..c684a7a15 --- /dev/null +++ b/tools/bench/language-canaries.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import path from 'node:path'; + +import { createCli } from '../../src/shared/cli.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { resolveRepoRootArg } from '../shared/dict-utils.js'; +import { writeTextIfChanged } from '../shared/generated-report.js'; +import { + buildBenchRuntimeLiveCanarySummary, + formatBenchRuntimeLiveCanarySummaryMarkdown, + loadBenchRuntimeCanaryManifest, + runBenchRuntimeLiveCanary, + validateBenchRuntimeCanaryManifest +} from './language/canaries.js'; + +const argv = createCli({ + scriptName: 'pairofcleats bench language canaries', + options: { + only: { type: 'string', default: '' }, + 'require-target': { type: 'boolean', default: false }, + 'out-json': { type: 'string', default: '' }, + 'out-md': { type: 'string', default: '' } + } +}).parse(); + +const root = resolveRepoRootArg(null, process.cwd()); + +const selectedIds = new Set( + String(argv.only || '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +); + +const run = async () => { + const { manifest } = await loadBenchRuntimeCanaryManifest(root); + const manifestFailures = validateBenchRuntimeCanaryManifest(manifest); + if (manifestFailures.length) { + throw new Error(`invalid bench runtime canary manifest:\n- ${manifestFailures.join('\n- ')}`); + } + const entries = (manifest.liveCanaries || []).filter((entry) => ( + selectedIds.size === 0 || selectedIds.has(String(entry?.id || '').trim()) + )); + if (entries.length === 0) { + throw new Error('no live canaries matched the requested filter'); + } + const results = []; + for (const entry of entries) { + results.push(await runBenchRuntimeLiveCanary(entry, root)); + } + const summary = buildBenchRuntimeLiveCanarySummary(results, { + requireTarget: argv['require-target'] === true + }); + + const outJsonPath = String(argv['out-json'] || '').trim() + ? path.resolve(root, String(argv['out-json']).trim()) + : ''; + const outMdPath = String(argv['out-md'] || '').trim() + ? path.resolve(root, String(argv['out-md']).trim()) + : ''; + + if (outJsonPath) { + await writeJsonFileResolved(outJsonPath, summary, { trailingNewline: true }); + } + if (outMdPath) { + await writeTextIfChanged(outMdPath, formatBenchRuntimeLiveCanarySummaryMarkdown(summary), { encoding: 'utf8' }); + } + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (!summary.ok) process.exit(1); +}; + +run().catch((error) => { + console.error(error?.message || error); + process.exit(1); +}); diff --git a/tools/bench/language-matrix.js b/tools/bench/language-matrix.js index 41cb4f141..d0bba6b21 100644 --- a/tools/bench/language-matrix.js +++ b/tools/bench/language-matrix.js @@ -1,16 +1,15 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSubprocess } from '../../src/shared/subprocess.js'; +import { spawnSubprocess } from '../../src/shared/subprocess/runner.js'; import { createCli } from '../../src/shared/cli.js'; import { BENCH_OPTIONS, mergeCliOptions, validateBenchArgs } from '../../src/shared/cli-options.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; import { - getRuntimeConfig, - resolveRepoConfig, - resolveRuntimeEnv, + bootstrapRuntime, resolveToolRoot } from '../shared/dict-utils.js'; -import { parseCommaList } from '../shared/text-utils.js'; +import { parseCommaList } from '../../src/shared/comma-list.js'; const benchOptions = mergeCliOptions( BENCH_OPTIONS, @@ -39,15 +38,13 @@ const benchOptions = mergeCliOptions( } ); const argv = createCli({ - scriptName: 'bench-language-matrix', + scriptName: 'pairofcleats bench matrix', options: benchOptions }).parse(); validateBenchArgs(argv, { allowedOptions: benchOptions }); const scriptRoot = resolveToolRoot(); -const { repoRoot, userConfig } = resolveRepoConfig(argv.root); -const runtimeConfig = getRuntimeConfig(repoRoot, userConfig); -const runtimeEnv = resolveRuntimeEnv(runtimeConfig, process.env); +const { repoRoot, userConfig, runtimeEnv } = bootstrapRuntime(argv.root); const benchScript = path.join(scriptRoot, 'tools', 'bench', 'language-repos.js'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const resultsRoot = path.resolve(argv.results || path.join(scriptRoot, 'benchmarks', 'results')); @@ -200,7 +197,7 @@ async function main() { results }; const summaryPath = path.join(runRoot, 'matrix.json'); - await fsPromises.writeFile(summaryPath, JSON.stringify(summary, null, 2)); + await writeJsonFileResolved(summaryPath, summary); console.error(`\n[bench-matrix] Summary written to ${summaryPath}`); } diff --git a/tools/bench/language-repos.js b/tools/bench/language-repos.js index d5f44dbba..6722d48cc 100644 --- a/tools/bench/language-repos.js +++ b/tools/bench/language-repos.js @@ -2,7 +2,9 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { getBenchMirrorRefreshMs } from '../../src/shared/env.js'; +import { getBenchMirrorRefreshMs } from '../../src/shared/env/bench.js'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { applyToolchainDaemonPolicyEnv } from '../../src/shared/toolchain-env.js'; import { parseBenchLanguageArgs } from './language/cli.js'; import { loadBenchConfig } from './language/config.js'; import { @@ -15,7 +17,11 @@ import { import { createProcessRunner } from './language/process.js'; import { buildBenchEnvironmentMetadata } from './language/logging.js'; import { validateEncodingFixtures } from './language/metrics.js'; -import { buildReportOutput, printSummary } from './language/report.js'; +import { + createBenchMethodologyPolicy, + filterTasksToControlSlice +} from './language/policy.js'; +import { buildBenchRunDiagnosticsSummaryLines, buildReportOutput, printSummary } from './language/report.js'; import { createToolDisplay } from '../shared/cli-display.js'; import { assignRepoLogMetadata, @@ -25,6 +31,11 @@ import { } from './language-repos/planning.js'; import { createBenchLogger } from './language-repos/logging.js'; import { createRepoLifecycle } from './language-repos/lifecycle.js'; +import { + buildBenchRunSummaryFromLedgerEvents, + createBenchRunLedger, + readBenchRunLedger +} from './language-repos/run-ledger.js'; import { createBenchProgressRuntime, runBenchExecutionLoop } from './language-repos/run-loop.js'; const USR_GUARDRAIL_BENCHMARKS = Object.freeze([ @@ -70,10 +81,13 @@ const { argv, scriptRoot, runSuffix, + mode, configPath, reposRoot, cacheRoot, resultsRoot, + corpusVersion, + waiverFile, logPath: masterLogPath, cloneEnabled, dryRun, @@ -89,7 +103,7 @@ const { const mirrorCacheRoot = resolveMirrorCacheRoot({ reposRoot }); const mirrorRefreshMs = resolveMirrorRefreshMs(getBenchMirrorRefreshMs()); -const baseEnv = { ...process.env }; +const baseEnv = applyToolchainDaemonPolicyEnv(process.env); const benchEnvironmentMetadata = buildBenchEnvironmentMetadata(baseEnv); const quietMode = argv.quiet === true || argv.json === true; const display = createToolDisplay({ @@ -123,7 +137,9 @@ const { initRepoLog, closeRepoLog, closeMasterLog, + closeLogsSync, appendLog, + appendLogSync, writeListLine, writeLog, writeLogSync, @@ -133,6 +149,9 @@ const { getLogPaths, logHistory } = logger; +let runLedger = null; +let finalizedRunSummary = null; +let runCloseoutFinalized = false; const progressRuntime = createBenchProgressRuntime({ display, @@ -149,9 +168,114 @@ const processRunner = createProcessRunner({ onProgressEvent: progressRuntime.handleProgressEvent }); -const closeLogs = () => { - closeRepoLog(); - closeMasterLog(); +const closeLogs = async () => { + await closeRepoLog(); + await closeMasterLog(); +}; + +const emitRunFooterToOperatorLog = async (summary, { sync = false } = {}) => { + if (!runLedger || !summary) return []; + const lines = sync + ? await runLedger.writeFooterArtifact(summary, { sync: true }) + : await runLedger.writeFooterArtifact(summary); + for (const line of lines) { + writeLogSync(line); + if (!quietMode && argv.json !== true) { + display.log(line, { forceOutput: true }); + } + } + return lines; +}; + +const rebuildRunSummary = async (output = null) => { + if (!runLedger) return null; + const events = await readBenchRunLedger(runLedger.ledgerPath); + return await buildBenchRunSummaryFromLedgerEvents({ + events, + diagnosticsRoot: runDiagnosticsRoot, + output, + runSuffix, + logPaths: { + masterLogPath, + footerPath: runLedger.footerPath, + ledgerPath: runLedger.ledgerPath, + summaryPath: runLedger.summaryPath + } + }); +}; + +const finalizeRunCloseout = async ({ + endState, + endReason = null, + signal = null, + exitCode = null, + output = null +}) => { + if (!runLedger) return null; + if (runCloseoutFinalized && finalizedRunSummary) return finalizedRunSummary; + let stage = 'closeout.start'; + try { + runLedger.recordCloseoutEvent('closeout.started', { + state: endState, + reason: endReason, + signal, + exitCode + }); + stage = 'summary.build'; + let summary = await runLedger.buildSummary({ + output, + endState, + endReason, + signal, + exitCode + }); + stage = 'summary.write'; + await runLedger.writeSummaryArtifact(summary); + runLedger.recordCloseoutEvent('closeout.summary_written', { + summaryPath: runLedger.summaryPath + }); + await runLedger.flush(); + summary = await rebuildRunSummary(output); + if (summary) { + await runLedger.writeSummaryArtifact(summary); + } + stage = 'footer.write'; + const footerLines = await emitRunFooterToOperatorLog(summary); + runLedger.recordCloseoutEvent('closeout.footer_written', { + footerPath: runLedger.footerPath, + lineCount: footerLines.length + }); + await runLedger.flush(); + summary = await rebuildRunSummary(output); + if (summary) { + await runLedger.writeSummaryArtifact(summary); + } + finalizedRunSummary = summary; + runCloseoutFinalized = true; + await runLedger.close(); + return summary; + } catch (error) { + try { + runLedger.recordCloseoutEvent('closeout.failed', { + stage, + message: error?.message || String(error) + }, { sync: true }); + const fallbackSummary = runLedger.buildSummarySync({ + endState, + endReason: endReason || 'closeout_failed', + signal, + exitCode + }); + runLedger.writeSummaryArtifactSync(fallbackSummary); + await emitRunFooterToOperatorLog(fallbackSummary, { sync: true }); + finalizedRunSummary = fallbackSummary; + runCloseoutFinalized = true; + } catch {} + try { + runLedger.closeSync(); + } catch {} + throw error; + } }; const reportFatal = (label, err) => { @@ -160,10 +284,10 @@ const reportFatal = (label, err) => { } catch {} try { const details = err?.stack || String(err); - display.error(`[bench-language] Fatal: ${label}`); - display.error(details); const names = getLogPaths().map((entry) => path.basename(entry)); - display.error(`[bench-language] Details logged (${names.join(', ')})`); + appendLogSync(`[bench-language] Fatal: ${label}`, 'error', { forceOutput: true }); + appendLogSync(details, 'error', { forceOutput: true }); + appendLogSync(`[bench-language] Details logged (${names.join(', ')})`, 'error', { forceOutput: true }); } catch {} }; @@ -214,8 +338,22 @@ const gracefulShutdown = ({ ); } } + try { + await finalizeRunCloseout({ + endState: normalizedReason === 'SIGINT' || normalizedReason === 'SIGTERM' + ? 'interrupted' + : 'fatal', + endReason: normalizedReason, + signal: normalizedReason === 'SIGINT' || normalizedReason === 'SIGTERM' + ? normalizedReason + : null, + exitCode + }); + } catch (closeoutError) { + writeLogSync(`[closeout] failed during ${normalizedReason}: ${closeoutError?.message || closeoutError}`); + } processRunner.logExit(normalizedReason, exitCode); - closeLogs(); + await closeLogs(); display.close(); process.exit(exitCode); })(); @@ -224,7 +362,8 @@ const gracefulShutdown = ({ process.on('exit', (code) => { processRunner.logExit('exit', code); - closeLogs(); + runLedger?.closeSync?.(); + closeLogsSync(); }); process.on('SIGINT', () => { void gracefulShutdown({ @@ -345,13 +484,27 @@ try { scriptRoot }); } catch (err) { - display.error(err?.message || String(err)); + appendLog(err?.message || String(err), 'error', { forceOutput: true }); exitWithDisplay(1); } if (argv.random) { shuffleInPlace(tasks); } +const methodology = createBenchMethodologyPolicy({ + argv: { + ...argv, + mode, + 'control-slice-max': argv['control-slice-max'] + }, + tasks, + configPath, + waiverFile, + corpusVersion +}); +if (argv['control-slice'] === true) { + tasks = filterTasksToControlSlice(tasks, methodology); +} assignRepoLogMetadata({ plannedTasks: tasks, repoLogsEnabled @@ -368,6 +521,7 @@ if (argv.list) { logsRoot: path.dirname(masterLogPath), diagnosticsRoot: runDiagnosticsRoot, runSuffix, + methodology, randomizedOrder: argv.random === true, masterLog: masterLogPath, languages: Object.keys(config), @@ -399,7 +553,7 @@ if (argv.list) { } if (!tasks.length) { - display.error('No benchmark targets match the requested filters.'); + appendLog('No benchmark targets match the requested filters.', 'error', { forceOutput: true }); exitWithDisplay(1); } @@ -429,6 +583,35 @@ const { executionPlans, precreateDirs } = buildExecutionPlans({ cacheRoot }); await Promise.all(precreateDirs.map((dir) => fsPromises.mkdir(dir, { recursive: true }))); +runLedger = createBenchRunLedger({ + logsRoot: path.dirname(masterLogPath), + runSuffix, + diagnosticsRoot: runDiagnosticsRoot, + configPath, + reposRoot, + cacheRoot, + resultsRoot, + masterLogPath, + waiverFile, + methodology +}); +runLedger.recordRunStarted({ + plannedRepoCount: executionPlans.length, + taskCount: tasks.length, + environment: benchEnvironmentMetadata +}); +const testInterruptAfterMs = Number(process.env.PAIROFCLEATS_TEST_BENCH_SELF_INTERRUPT_AFTER_MS); +if ( + process.env.PAIROFCLEATS_TESTING === '1' + && Number.isFinite(testInterruptAfterMs) + && testInterruptAfterMs > 0 +) { + setTimeout(() => { + try { + process.kill(process.pid, 'SIGINT'); + } catch {} + }, Math.floor(testInterruptAfterMs)).unref?.(); +} const lifecycle = createRepoLifecycle({ appendLog, @@ -445,8 +628,7 @@ const lifecycle = createRepoLifecycle({ runDiagnosticsRoot, runSuffix, benchEnvironmentMetadata, - logHistory, - exitWithDisplay + logHistory }); progressRuntime.setTotal(tasks.length); @@ -465,13 +647,15 @@ const results = await runBenchExecutionLoop({ getRepoLogPath, clearLogHistory, hasDiskFullMessageInHistory, + runLedger, progressRuntime, lifecycle, wantsSqlite, backendList, lockMode, lockWaitMs, - lockStaleMs + lockStaleMs, + benchTimeoutMs }); const output = await buildReportOutput({ @@ -479,7 +663,12 @@ const output = await buildReportOutput({ cacheRoot, resultsRoot, results, - config + config, + environmentMetadata: benchEnvironmentMetadata, + runLabel: runSuffix, + runSuffix, + waiverFile, + methodology }); if (usrGuardrailBenchmarks.length) { output.usrGuardrails = { @@ -499,27 +688,46 @@ if (!quietMode) { printSummary('Overall', output.overallSummary, results.length, quietMode, { writeLine: (line) => appendLog(line) }); - const retainedCount = Number(output?.diagnostics?.crashRetention?.retainedCount) || 0; - if (retainedCount > 0) { - appendLog(`[diagnostics] retained crash bundles: ${retainedCount}`); + for (const line of buildBenchRunDiagnosticsSummaryLines(output)) { + appendLog(line); + } + if (output?.run?.aggregateResultClass) { + appendLog( + `[verdict] ${output.run.aggregateResultClass} ` + + `(unwaived=${Number(output?.run?.issues?.unwaivedCount || 0)} waived=${Number(output?.run?.issues?.waivedCount || 0)})` + ); } } const outputPath = argv.out ? path.resolve(argv.out) : null; if (outputPath) { - await fsPromises.mkdir(path.dirname(outputPath), { recursive: true }); - await fsPromises.writeFile(outputPath, JSON.stringify(output, null, 2)); + await writeJsonFileResolved(outputPath, output); +} +appendLog(`Completed ${results.length} benchmark runs.`); +if (outputPath) { + appendLog(`[summary] written (${path.basename(outputPath)})`, 'info', { + fileOnlyLine: `Summary written to ${outputPath}` + }); } +finalizedRunSummary = await finalizeRunCloseout({ + endState: 'completed', + endReason: 'completed', + exitCode: Number.isFinite(Number(output?.run?.exitCode)) + ? Number(output.run.exitCode) + : 0, + output +}); + if (argv.json) { + await closeLogs(); display.close(); console.log(JSON.stringify(output, null, 2)); } else { - appendLog(`Completed ${results.length} benchmark runs.`); - if (outputPath) { - appendLog(`[summary] written (${path.basename(outputPath)})`, 'info', { - fileOnlyLine: `Summary written to ${outputPath}` - }); - } + await closeLogs(); display.close(); } + +process.exitCode = Number.isFinite(Number(output?.run?.exitCode)) + ? Number(output.run.exitCode) + : 0; diff --git a/tools/bench/language-repos/lifecycle.js b/tools/bench/language-repos/lifecycle.js index 2bc981d6a..de4b4b7ea 100644 --- a/tools/bench/language-repos/lifecycle.js +++ b/tools/bench/language-repos/lifecycle.js @@ -1,9 +1,16 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; +import { removePathWithRetry } from '../../../src/shared/io/remove-path-with-retry.js'; +import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; import { retainCrashArtifacts } from '../../../src/index/build/crash-log.js'; -import { isInside, isRootPath } from '../../shared/path-utils.js'; +import { isRootPath } from '../../../src/shared/file-paths.js'; +import { isPathUnderDir } from '../../../src/shared/path-normalize.js'; import { ensureRepoBenchmarkReady, tryMirrorClone } from '../language/repos.js'; +import { + classifyRepoPreflightBlock, + resolveRepoPlatformCompatibility +} from '../language/repo-preflight-contracts.js'; /** * Ensure repository-local benchmark config exists so bench runs inherit the @@ -23,8 +30,8 @@ export const ensureBenchConfig = async (repoPath, cacheRoot) => { /** * @typedef {object} RepoLifecycle * @property {(repoPath:string) => boolean} hasRepoPath - * @property {(input:{task:object,repoPath:string,repoLabel:string}) => Promise<{ok:boolean,failureCode?:number|null,schedulerEvents?:object[]}>} ensureRepoPresent - * @property {(input:{repoPath:string}) => Promise<{ok:boolean,failureReason?:string,failureCode?:number|null}>} prepareRepoWorkspace + * @property {(input:{task:object,repoPath:string,repoLabel:string}) => Promise<{ok:boolean,failureReason?:string,failureCode?:number|null,failureDetail?:string|null,schedulerEvents?:object[]}>} ensureRepoPresent + * @property {(input:{repoPath:string}) => Promise<{ok:boolean,failureReason?:string,failureCode?:number|null,failureDetail?:string|null}>} prepareRepoWorkspace * @property {(input:{repoCacheRoot:string,repoLabel:string}) => Promise} cleanRepoCache * @property {(input:{ * task:object, @@ -56,8 +63,7 @@ export const ensureBenchConfig = async (repoPath, cacheRoot) => { * runDiagnosticsRoot:string, * runSuffix:string, * benchEnvironmentMetadata:object, - * logHistory:string[], - * exitWithDisplay:(code:number) => void + * logHistory:string[] * }} input * @returns {RepoLifecycle} */ @@ -76,8 +82,7 @@ export const createRepoLifecycle = ({ runDiagnosticsRoot, runSuffix, benchEnvironmentMetadata, - logHistory, - exitWithDisplay + logHistory }) => { const resolvedCacheRoot = path.resolve(cacheRoot); const repoPresenceCache = new Map(); @@ -94,6 +99,17 @@ export const createRepoLifecycle = ({ repoPresenceCache.set(repoPath, Boolean(exists)); }; + const summarizeRecentCloneFailure = (logStartIndex) => { + const lines = Array.isArray(logHistory) + ? logHistory.slice(Math.max(0, Number(logStartIndex) || 0)) + : []; + const recent = lines + .map((line) => String(line || '').trim()) + .filter(Boolean) + .slice(-6); + return recent.join(' | ').trim() || null; + }; + /** * Ensure a repo exists on disk, optionally cloning when missing. * @@ -103,12 +119,37 @@ export const createRepoLifecycle = ({ const ensureRepoPresent = async ({ task, repoPath, repoLabel }) => { if (hasRepoPath(repoPath)) return { ok: true }; if (!cloneEnabled && !dryRun) { - display.error(`Missing repo ${task.repo} at ${repoPath}. Re-run with --clone.`); - exitWithDisplay(1); - return { ok: false }; + appendLog(`Missing repo ${task.repo} at ${repoPath}. Continuing without clone.`, 'error', { + forceOutput: true + }); + return { + ok: false, + failureCode: null, + schedulerEvents: [] + }; } if (dryRun || !cloneEnabled || !cloneTool) return { ok: true }; + const compatibility = resolveRepoPlatformCompatibility({ + repo: task?.repo || null, + repoPath, + platform: process.platform + }); + if (compatibility.state === 'blocked') { + appendLog( + `[clone] skipped ${repoLabel}: ${compatibility.detail || 'repo is not checkout-compatible on this platform.'}`, + 'warn' + ); + markRepoPath(repoPath, false); + return { + ok: false, + failureReason: compatibility.failureReason || 'platform_incompatible_checkout', + failureCode: null, + failureDetail: compatibility.detail || null, + schedulerEvents: [] + }; + } + let clonedFromMirror = false; if (cloneTool.supportsMirrorClone) { const mirrorClone = tryMirrorClone({ @@ -128,12 +169,27 @@ export const createRepoLifecycle = ({ `[clone] mirror unavailable for ${repoLabel}; falling back to direct clone (${mirrorClone.reason || 'unknown'}).`, 'warn' ); + const mirrorBlocked = classifyRepoPreflightBlock({ + detail: mirrorClone.reason || '', + timedOut: false + }); + if (mirrorBlocked.state === 'platform_incompatible_checkout') { + markRepoPath(repoPath, false); + return { + ok: false, + failureReason: 'platform_incompatible_checkout', + failureCode: null, + failureDetail: mirrorClone.reason || null, + schedulerEvents: [] + }; + } try { await fsPromises.rm(repoPath, { recursive: true, force: true }); } catch {} } } if (!clonedFromMirror) { + const logStartIndex = Array.isArray(logHistory) ? logHistory.length : 0; const args = cloneTool.buildArgs(task.repo, repoPath); const cloneResult = await processRunner.runProcess(`clone ${task.repo}`, cloneTool.label, args, { env: cloneCommandEnv, @@ -141,9 +197,18 @@ export const createRepoLifecycle = ({ }); if (!cloneResult.ok) { markRepoPath(repoPath, false); + const failureDetail = summarizeRecentCloneFailure(logStartIndex); + const blocked = classifyRepoPreflightBlock({ + detail: failureDetail || '', + timedOut: false + }); return { ok: false, + failureReason: blocked.state === 'platform_incompatible_checkout' + ? 'platform_incompatible_checkout' + : 'clone', failureCode: cloneResult.code ?? null, + failureDetail, schedulerEvents: cloneResult.schedulerEvents || [] }; } @@ -171,7 +236,8 @@ export const createRepoLifecycle = ({ return { ok: false, failureReason: preflightSummary.failureReason || 'preflight', - failureCode: preflightSummary.failureCode ?? null + failureCode: preflightSummary.failureCode ?? null, + failureDetail: preflightSummary.failureDetail || null }; } } @@ -192,14 +258,26 @@ export const createRepoLifecycle = ({ if (keepCache || dryRun || !repoCacheRoot) return; try { const resolvedRepoCacheRoot = path.resolve(repoCacheRoot); - if (!isInside(resolvedCacheRoot, resolvedRepoCacheRoot) || isRootPath(resolvedRepoCacheRoot)) { + if (!isPathUnderDir(resolvedCacheRoot, resolvedRepoCacheRoot) || isRootPath(resolvedRepoCacheRoot)) { appendLog('[cache] skip cleanup; repo cache path escaped cache root.', 'warn', { fileOnlyLine: `[cache] Skip cleanup; repo cache path not under cache root (${resolvedRepoCacheRoot}).` }); return; } if (!fs.existsSync(resolvedRepoCacheRoot)) return; - await fsPromises.rm(resolvedRepoCacheRoot, { recursive: true, force: true }); + const removal = await runWithTimeout( + () => removePathWithRetry(resolvedRepoCacheRoot), + { + timeoutMs: 15000, + errorFactory: () => createTimeoutError({ + code: 'ERR_BENCH_CACHE_CLEANUP_TIMEOUT', + message: `Repo cache cleanup timed out for ${repoLabel}.` + }) + } + ); + if (!removal?.ok) { + throw removal?.error || new Error(`Failed to clean repo cache for ${repoLabel}.`); + } appendLog(`[cache] cleaned ${repoLabel}.`); } catch (err) { appendLog(`[cache] cleanup failed for ${repoLabel}: ${err?.message || err}`, 'warn'); @@ -217,6 +295,7 @@ export const createRepoLifecycle = ({ * outFile:string|null, * failureReason:string, * failureCode?:number|null, + * failureContext?:object|null, * schedulerEvents?:object[] * }} input * @returns {Promise} @@ -229,6 +308,7 @@ export const createRepoLifecycle = ({ outFile, failureReason, failureCode = null, + failureContext = null, schedulerEvents = [] }) => { if (dryRun || !repoCacheRoot) return null; @@ -243,6 +323,9 @@ export const createRepoLifecycle = ({ reason: failureReason || 'unknown', code: Number.isFinite(Number(failureCode)) ? Number(failureCode) : null }, + failureContext: failureContext && typeof failureContext === 'object' + ? { ...failureContext } + : null, runtime: { runSuffix, language: task?.language || null, diff --git a/tools/bench/language-repos/logging.js b/tools/bench/language-repos/logging.js index 4d8219c59..f71b210b7 100644 --- a/tools/bench/language-repos/logging.js +++ b/tools/bench/language-repos/logging.js @@ -1,7 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; +import { createQueuedAppendWriter } from '../../../src/shared/io/append-writer.js'; +import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; +import { createDisplayLoggerAdapter } from '../../shared/cli-display.js'; const DEFAULT_LOG_HISTORY_LIMIT = 50; +const DEFAULT_LOG_CLOSE_TIMEOUT_MS = 5000; +const DEFAULT_LOG_FLUSH_INTERVAL_MS = 2000; /** * Detect disk-full diagnostics from subprocess output. @@ -36,10 +41,13 @@ export const isDiskFullMessage = (line) => { * }} input * @returns {{ * initMasterLog:() => void, - * initRepoLog:(input:{label:string,tier?:string,repoPath:string,slug:string}) => (string|null), - * closeRepoLog:() => void, - * closeMasterLog:() => void, + * initRepoLog:(input:{label:string,tier?:string,repoPath:string,slug:string}) => Promise<(string|null)>, + * flushLogs:() => Promise, + * closeRepoLog:() => Promise, + * closeMasterLog:() => Promise, + * closeLogsSync:() => void, * appendLog:(line:string,level?:'info'|'warn'|'error',meta?:object|null) => void, + * appendLogSync:(line:string,level?:'info'|'warn'|'error',meta?:object|null) => void, * writeListLine:(line:string) => void, * writeLog:(line:string) => void, * writeLogSync:(line:string) => void, @@ -61,23 +69,98 @@ export const createBenchLogger = ({ repoLogsEnabled, logHistoryLimit = DEFAULT_LOG_HISTORY_LIMIT }) => { - let masterLogStream = null; - let repoLogStream = null; + let masterLogWriter = null; + let repoLogWriter = null; let repoLogPath = null; + let flushTimer = null; + const pendingSyncWrites = new Map(); const logsRoot = path.dirname(masterLogPath); const logHistory = []; + const displayLogger = createDisplayLoggerAdapter(display); + + const createLogWriter = (filePath) => createQueuedAppendWriter({ + filePath, + ensureDir: true, + syncOnFlush: false + }); + + const trackPendingWrite = (filePath, text, promise) => { + if (!filePath || typeof text !== 'string' || !promise?.finally) return promise; + const pending = pendingSyncWrites.get(filePath) || []; + pending.push(text); + pendingSyncWrites.set(filePath, pending); + return promise.finally(() => { + const current = pendingSyncWrites.get(filePath); + if (!Array.isArray(current) || !current.length) return; + const index = current.indexOf(text); + if (index >= 0) current.splice(index, 1); + if (!current.length) { + pendingSyncWrites.delete(filePath); + } else { + pendingSyncWrites.set(filePath, current); + } + }); + }; + + const enqueueWriterText = (writer, filePath, text) => { + if (!writer?.enqueue || typeof text !== 'string') return; + void trackPendingWrite(filePath, text, writer.enqueue(text)); + }; + + const clearFlushTimer = () => { + if (!flushTimer) return; + clearInterval(flushTimer); + flushTimer = null; + }; + + const ensureFlushTimer = () => { + if (flushTimer || (!masterLogWriter && !repoLogWriter)) return; + flushTimer = setInterval(() => { + void flushLogs(); + }, DEFAULT_LOG_FLUSH_INTERVAL_MS); + flushTimer.unref?.(); + }; + + const flushWriter = async (writer, reason) => { + if (!writer?.flush) return; + await runWithTimeout( + () => writer.flush(), + { + timeoutMs: DEFAULT_LOG_CLOSE_TIMEOUT_MS, + errorFactory: () => createTimeoutError({ + code: 'ERR_BENCH_LOG_FLUSH_TIMEOUT', + message: `Bench log flush timed out during ${reason}.` + }) + } + ); + }; + + const closeWriter = async (writer, reason) => { + if (!writer?.close) return; + await runWithTimeout( + () => writer.close(), + { + timeoutMs: DEFAULT_LOG_CLOSE_TIMEOUT_MS, + errorFactory: () => createTimeoutError({ + code: 'ERR_BENCH_LOG_CLOSE_TIMEOUT', + message: `Bench log close timed out during ${reason}.` + }) + } + ); + }; const initMasterLog = () => { - if (masterLogStream) return; + if (masterLogWriter) return; fs.mkdirSync(logsRoot, { recursive: true }); - masterLogStream = fs.createWriteStream(masterLogPath, { flags: 'a' }); - masterLogStream.write(`\n=== Bench run ${new Date().toISOString()} ===\n`); - masterLogStream.write(`Config: ${configPath}\n`); - masterLogStream.write(`Repos: ${reposRoot}\n`); - masterLogStream.write(`Cache: ${cacheRoot}\n`); - masterLogStream.write(`Results: ${resultsRoot}\n`); + masterLogWriter = createLogWriter(masterLogPath); + ensureFlushTimer(); + enqueueWriterText(masterLogWriter, masterLogPath, `\n=== Bench run ${new Date().toISOString()} ===\n`); + enqueueWriterText(masterLogWriter, masterLogPath, `Config: ${configPath}\n`); + enqueueWriterText(masterLogWriter, masterLogPath, `Repos: ${reposRoot}\n`); + enqueueWriterText(masterLogWriter, masterLogPath, `Cache: ${cacheRoot}\n`); + enqueueWriterText(masterLogWriter, masterLogPath, `Results: ${resultsRoot}\n`); if (repoLogsEnabled) { - masterLogStream.write(`Repo logs: ${logsRoot}\n`); + enqueueWriterText(masterLogWriter, masterLogPath, `Repo logs: ${logsRoot}\n`); } }; @@ -88,42 +171,100 @@ export const createBenchLogger = ({ * @param {{label:string,tier?:string,repoPath:string,slug:string}} input * @returns {string|null} */ - const initRepoLog = ({ label, tier, repoPath: repoDir, slug }) => { + const initRepoLog = async ({ label, tier, repoPath: repoDir, slug }) => { if (!repoLogsEnabled) return null; - try { - if (repoLogStream) repoLogStream.end(); - } catch {} - repoLogStream = null; + await closeRepoLog(); repoLogPath = path.join(logsRoot, `${runSuffix}-${slug}.log`); fs.mkdirSync(path.dirname(repoLogPath), { recursive: true }); - repoLogStream = fs.createWriteStream(repoLogPath, { flags: 'a' }); - repoLogStream.write(`\n=== Bench run ${new Date().toISOString()} ===\n`); - repoLogStream.write(`Target: ${label}${tier ? ` tier=${tier}` : ''}\n`); - repoLogStream.write(`Repo path: ${repoDir}\n`); - repoLogStream.write(`Config: ${configPath}\n`); - repoLogStream.write(`Cache: ${cacheRoot}\n`); - repoLogStream.write(`Results: ${resultsRoot}\n`); - repoLogStream.write(`Master log: ${masterLogPath}\n`); + repoLogWriter = createLogWriter(repoLogPath); + ensureFlushTimer(); + const headerLines = [ + `\n=== Bench run ${new Date().toISOString()} ===\n`, + `Target: ${label}${tier ? ` tier=${tier}` : ''}\n`, + `Repo path: ${repoDir}\n`, + `Config: ${configPath}\n`, + `Cache: ${cacheRoot}\n`, + `Results: ${resultsRoot}\n`, + `Master log: ${masterLogPath}\n` + ]; + for (const line of headerLines) { + await trackPendingWrite(repoLogPath, line, repoLogWriter.enqueue(line)); + } initMasterLog(); - masterLogStream?.write(`[log] Repo log for ${label}: ${repoLogPath}\n`); + if (masterLogWriter) { + await trackPendingWrite(masterLogPath, `[log] Repo log for ${label}: ${repoLogPath}\n`, masterLogWriter.enqueue(`[log] Repo log for ${label}: ${repoLogPath}\n`)); + } return repoLogPath; }; - const closeRepoLog = () => { - if (!repoLogStream) return; + const handleLogOpSettled = (results, { reason = 'log-op', fatal = false } = {}) => { + const failures = Array.isArray(results) + ? results.filter((entry) => entry?.status === 'rejected').map((entry) => entry.reason) + : []; + if (!failures.length) return; + const message = `[log] ${reason} failed for ${failures.length} writer(s): ${failures.map((error) => error?.message || error).join('; ')}`; + logHistory.push(message); + while (logHistory.length > logHistoryLimit) logHistory.shift(); try { - repoLogStream.end(); + display?.warn?.(message); } catch {} - repoLogStream = null; + if (fatal) { + throw new AggregateError(failures, message); + } + }; + + const flushLogs = async () => { + const results = await Promise.allSettled([ + flushWriter(masterLogWriter, 'periodic-master'), + flushWriter(repoLogWriter, 'periodic-repo') + ]); + handleLogOpSettled(results, { reason: 'periodic flush', fatal: false }); + }; + + const closeRepoLog = async () => { + const writer = repoLogWriter; + repoLogWriter = null; repoLogPath = null; + if (!writer) return; + const flushResults = await Promise.allSettled([ + flushWriter(writer, 'repo-rotate') + ]); + handleLogOpSettled(flushResults, { reason: 'repo log flush', fatal: true }); + const closeResults = await Promise.allSettled([ + closeWriter(writer, 'repo-rotate') + ]); + handleLogOpSettled(closeResults, { reason: 'repo log close', fatal: true }); + if (!repoLogWriter && !masterLogWriter) clearFlushTimer(); }; - const closeMasterLog = () => { - if (!masterLogStream) return; - try { - masterLogStream.end(); - } catch {} - masterLogStream = null; + const closeMasterLog = async () => { + const writer = masterLogWriter; + masterLogWriter = null; + if (!writer) return; + const flushResults = await Promise.allSettled([ + flushWriter(writer, 'master-close') + ]); + handleLogOpSettled(flushResults, { reason: 'master log flush', fatal: true }); + const closeResults = await Promise.allSettled([ + closeWriter(writer, 'master-close') + ]); + handleLogOpSettled(closeResults, { reason: 'master log close', fatal: true }); + if (!repoLogWriter && !masterLogWriter) clearFlushTimer(); + }; + + const closeLogsSync = () => { + clearFlushTimer(); + for (const [filePath, pending] of pendingSyncWrites.entries()) { + if (!Array.isArray(pending) || !pending.length) continue; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, pending.join('')); + } catch {} + } + pendingSyncWrites.clear(); + repoLogWriter = null; + repoLogPath = null; + masterLogWriter = null; }; const appendToLogFileSync = (filePath, line) => { @@ -134,10 +275,20 @@ export const createBenchLogger = ({ } catch {} }; + const emitToDisplay = (line, level, meta) => { + if (level === 'error') { + displayLogger.error(line, meta); + } else if (level === 'warn') { + displayLogger.warn(line, meta); + } else { + displayLogger.log(line, meta); + } + }; + const writeLog = (line) => { - if (!masterLogStream) initMasterLog(); - if (masterLogStream) masterLogStream.write(`${line}\n`); - if (repoLogStream) repoLogStream.write(`${line}\n`); + if (!masterLogWriter) initMasterLog(); + if (masterLogWriter) enqueueWriterText(masterLogWriter, masterLogPath, `${line}\n`); + if (repoLogWriter && repoLogPath) enqueueWriterText(repoLogWriter, repoLogPath, `${line}\n`); }; const writeLogSync = (line) => { @@ -161,15 +312,18 @@ export const createBenchLogger = ({ ? meta.fileOnlyLine : null; writeLog(fileOnlyLine || line); - if (level === 'error') { - display.error(line, meta); - } else if (level === 'warn') { - display.warn(line, meta); - } else if (meta && typeof meta === 'object' && meta.kind === 'status') { - display.logLine(line, meta); - } else { - display.log(line, meta); - } + emitToDisplay(line, level, meta); + logHistory.push(line); + if (logHistory.length > logHistoryLimit) logHistory.shift(); + }; + + const appendLogSync = (line, level = 'info', meta = null) => { + if (!line) return; + const fileOnlyLine = meta && typeof meta === 'object' && typeof meta.fileOnlyLine === 'string' + ? meta.fileOnlyLine + : null; + writeLogSync(fileOnlyLine || line); + emitToDisplay(line, level, meta); logHistory.push(line); if (logHistory.length > logHistoryLimit) logHistory.shift(); }; @@ -193,9 +347,12 @@ export const createBenchLogger = ({ return { initMasterLog, initRepoLog, + flushLogs, closeRepoLog, closeMasterLog, + closeLogsSync, appendLog, + appendLogSync, writeListLine, writeLog, writeLogSync, diff --git a/tools/bench/language-repos/planning.js b/tools/bench/language-repos/planning.js index e604e4db1..dde6045c6 100644 --- a/tools/bench/language-repos/planning.js +++ b/tools/bench/language-repos/planning.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { resolveRepoCacheRoot, resolveRepoDir } from '../language/repos.js'; -import { parseCommaList } from '../../shared/text-utils.js'; +import { parseCommaList } from '../../../src/shared/comma-list.js'; /** * @typedef {object} BenchTaskDescriptor diff --git a/tools/bench/language-repos/run-ledger.js b/tools/bench/language-repos/run-ledger.js new file mode 100644 index 000000000..f703c9278 --- /dev/null +++ b/tools/bench/language-repos/run-ledger.js @@ -0,0 +1,649 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +import { createQueuedAppendWriter } from '../../../src/shared/io/append-writer.js'; +import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; +import { evaluateBenchVerdict, loadBenchPolicy } from '../language/verdict.js'; + +export const BENCH_RUN_LEDGER_SCHEMA_VERSION = 1; +export const BENCH_RUN_SUMMARY_SCHEMA_VERSION = 1; +export const BENCH_RUN_LEDGER_EVENT_VERSION = 1; + +const DEFAULT_LEDGER_CLOSE_TIMEOUT_MS = 5000; +const DEFAULT_LEDGER_FLUSH_INTERVAL_MS = 2000; +const RETAINED_CRASH_BUNDLE_NAME = 'retained-crash-bundle.json'; + +const toFiniteNumber = (value, fallback = 0) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const toText = (value) => { + const text = String(value == null ? '' : value).trim(); + return text || null; +}; + +const sortObjectKeys = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + return Object.fromEntries( + Object.entries(value).sort(([left], [right]) => String(left).localeCompare(String(right))) + ); +}; + +const buildRepoKey = (entry) => { + const language = toText(entry?.language) || '_unknown'; + const tier = toText(entry?.tier) || '_unknown'; + const repo = toText(entry?.repo) || '_unknown'; + return `${language}:${tier}:${repo}`; +}; + +const normalizeCountsByType = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const out = {}; + for (const [key, count] of Object.entries(value)) { + const numeric = Number(count); + if (!Number.isFinite(numeric) || numeric <= 0) continue; + out[key] = numeric; + } + const sorted = sortObjectKeys(out); + return Object.keys(sorted || {}).length ? sorted : null; +}; + +const normalizeCrashRetention = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const bundlePath = toText(value.bundlePath); + if (!bundlePath) return null; + return { + bundlePath, + markerPath: toText(value.markerPath), + diagnosticsDir: toText(value.diagnosticsDir), + checksum: toText(value.checksum) + }; +}; + +const normalizeTaskEntry = (entry) => { + if (!entry || typeof entry !== 'object') return null; + const payload = { + language: toText(entry.language), + tier: toText(entry.tier), + repo: toText(entry.repo), + repoPath: toText(entry.repoPath), + outFile: toText(entry.outFile), + failed: entry.failed === true, + skipped: entry.skipped === true, + skipReason: toText(entry.skipReason), + failureReason: toText(entry.failureReason), + failureCode: Number.isFinite(Number(entry.failureCode)) ? Number(entry.failureCode) : null, + failureSignal: toText(entry.failureSignal || entry.signal), + timeoutKind: toText(entry.timeoutKind), + timeoutDecision: entry?.timeoutDecision && typeof entry.timeoutDecision === 'object' + ? { + phase: toText(entry.timeoutDecision.phase), + resourceClass: toText(entry.timeoutDecision.resourceClass), + failureMode: toText(entry.timeoutDecision.failureMode), + decisionReason: toText(entry.timeoutDecision.decisionReason), + timeoutClass: toText(entry.timeoutDecision.timeoutClass), + candidateTimeoutClass: toText(entry.timeoutDecision.candidateTimeoutClass) + } + : null, + lastActivity: entry?.lastActivity && typeof entry.lastActivity === 'object' + ? { + source: toText(entry.lastActivity.source), + ageMs: Number.isFinite(Number(entry.lastActivity.ageMs)) ? Number(entry.lastActivity.ageMs) : null, + text: toText(entry.lastActivity.text) + } + : null, + diagnostics: { + process: entry?.diagnostics?.process && typeof entry.diagnostics.process === 'object' + ? { + countsByType: normalizeCountsByType(entry.diagnostics.process.countsByType), + countsBySeverity: normalizeCountsByType(entry.diagnostics.process.countsBySeverity), + eventCount: Number.isFinite(Number(entry.diagnostics.process.eventCount)) + ? Number(entry.diagnostics.process.eventCount) + : null + } + : null, + countsByType: normalizeCountsByType(entry?.diagnostics?.countsByType), + progressConfidence: entry?.diagnostics?.progressConfidence && typeof entry.diagnostics.progressConfidence === 'object' + ? { + bucket: toText(entry.diagnostics.progressConfidence.bucket), + ratio: Number.isFinite(Number(entry.diagnostics.progressConfidence.ratio)) + ? Number(entry.diagnostics.progressConfidence.ratio) + : null + } + : null, + crashRetention: normalizeCrashRetention( + entry?.diagnostics?.crashRetention || entry?.crashRetention || null + ) + } + }; + return payload; +}; + +const collectRetainedCrashBundles = async (diagnosticsRoot) => { + const resolvedRoot = toText(diagnosticsRoot); + if (!resolvedRoot || !fs.existsSync(resolvedRoot)) return []; + const found = []; + const walk = async (current) => { + const entries = await fsPromises.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const target = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(target); + continue; + } + if (entry.isFile() && entry.name === RETAINED_CRASH_BUNDLE_NAME) { + found.push(target); + } + } + }; + await walk(resolvedRoot); + found.sort((left, right) => left.localeCompare(right)); + return found; +}; + +export const readBenchRunLedger = async (ledgerPath) => { + if (!ledgerPath || !fs.existsSync(ledgerPath)) return []; + const raw = await fsPromises.readFile(ledgerPath, 'utf8'); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +}; + +const buildRunState = ({ endEvent, repoEntries, plannedCount }) => { + const finishedCount = repoEntries.length; + const plannedRepoCount = Number.isFinite(Number(plannedCount)) ? Number(plannedCount) : finishedCount; + const unfinishedCount = Math.max(0, plannedRepoCount - finishedCount); + const payload = endEvent?.payload && typeof endEvent.payload === 'object' + ? endEvent.payload + : {}; + const state = toText(payload.state) || 'unknown'; + return { + state, + reason: toText(payload.reason), + signal: toText(payload.signal), + exitCode: Number.isFinite(Number(payload.exitCode)) ? Number(payload.exitCode) : null, + startedAt: toText(payload.runStartedAt), + endedAt: toText(endEvent?.ts), + plannedRepoCount, + finishedRepoCount: finishedCount, + unfinishedRepoCount: unfinishedCount + }; +}; + +const buildParities = ({ + repoEntries, + retainedBundleEvents, + retainedBundlePaths, + output +}) => { + const ledgerCrashPaths = new Set( + retainedBundleEvents + .map((entry) => toText(entry?.bundlePath || entry?.payload?.bundlePath)) + .filter(Boolean) + ); + for (const entry of repoEntries) { + const bundlePath = toText(entry?.diagnostics?.crashRetention?.bundlePath); + if (bundlePath) ledgerCrashPaths.add(bundlePath); + } + const directoryCrashPaths = new Set(retainedBundlePaths.filter(Boolean)); + const outputCrashPaths = new Set( + Array.isArray(output?.diagnostics?.crashRetention?.retained) + ? output.diagnostics.crashRetention.retained + .map((entry) => toText(entry?.bundlePath)) + .filter(Boolean) + : [] + ); + const missingOnDisk = Array.from(ledgerCrashPaths).filter((entry) => !directoryCrashPaths.has(entry)); + const missingInLedger = Array.from(directoryCrashPaths).filter((entry) => !ledgerCrashPaths.has(entry)); + const missingInOutput = Array.from(ledgerCrashPaths).filter((entry) => output && !outputCrashPaths.has(entry)); + return { + crashRetention: { + ledgerCount: ledgerCrashPaths.size, + directoryCount: directoryCrashPaths.size, + outputCount: output ? outputCrashPaths.size : null, + ok: missingOnDisk.length === 0 + && missingInLedger.length === 0 + && (!output || missingInOutput.length === 0), + missingOnDisk, + missingInLedger, + missingInOutput + } + }; +}; + +export const buildBenchRunSummaryFromLedgerEvents = async ({ + events, + diagnosticsRoot = null, + policy = null, + output = null, + runSuffix = null, + logPaths = null +} = {}) => { + const rows = Array.isArray(events) ? events.filter((entry) => entry && typeof entry === 'object') : []; + const startEvent = rows.find((entry) => entry.eventType === 'run.started') || null; + const endEvent = [...rows].reverse().find((entry) => entry.eventType === 'run.ended') || null; + const repoStarted = rows.filter((entry) => entry.eventType === 'repo.started'); + const repoCompleted = rows + .filter((entry) => entry.eventType === 'repo.completed') + .map((entry) => normalizeTaskEntry(entry.payload?.result)) + .filter(Boolean); + const retainedBundleEvents = rows + .filter((entry) => entry.eventType === 'repo.crash_retained') + .map((entry) => entry.payload || {}); + const retainedBundlePaths = await collectRetainedCrashBundles(diagnosticsRoot); + const effectivePolicy = policy || await loadBenchPolicy({ + waiverFile: toText(startEvent?.payload?.waiverFile) + }); + const verdict = evaluateBenchVerdict({ + tasks: repoCompleted, + policy: effectivePolicy + }); + const plannedCount = Number(startEvent?.payload?.plannedRepoCount || 0); + const startedRepoKeys = new Map( + repoStarted.map((entry) => [buildRepoKey(entry.payload), entry.payload || {}]) + ); + const completedRepoKeys = new Set(repoCompleted.map((entry) => buildRepoKey(entry))); + const unfinishedRepos = Array.from(startedRepoKeys.entries()) + .filter(([key]) => !completedRepoKeys.has(key)) + .map(([, entry]) => ({ + language: toText(entry.language), + tier: toText(entry.tier), + repo: toText(entry.repo), + repoPath: toText(entry.repoPath) + })) + .sort((left, right) => buildRepoKey(left).localeCompare(buildRepoKey(right))); + const run = buildRunState({ + endEvent, + repoEntries: repoCompleted, + plannedCount: plannedCount || startEvent?.payload?.taskCount || 0 + }); + const parities = buildParities({ + repoEntries: repoCompleted, + retainedBundleEvents, + retainedBundlePaths, + output + }); + return { + schemaVersion: BENCH_RUN_SUMMARY_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + runSuffix: toText(runSuffix || startEvent?.payload?.runSuffix), + environment: startEvent?.payload?.environment || null, + run, + verdict: output?.run || verdict.run, + counts: { + planned: run.plannedRepoCount, + started: repoStarted.length, + finished: repoCompleted.length, + unfinished: unfinishedRepos.length + }, + unfinishedRepos, + parities, + paths: { + masterLogPath: toText(logPaths?.masterLogPath || startEvent?.payload?.masterLogPath), + footerPath: toText(logPaths?.footerPath || startEvent?.payload?.footerPath), + ledgerPath: toText(logPaths?.ledgerPath || startEvent?.payload?.ledgerPath), + summaryPath: toText(logPaths?.summaryPath || startEvent?.payload?.summaryPath), + diagnosticsRoot: toText(diagnosticsRoot || startEvent?.payload?.diagnosticsRoot) + }, + closeout: { + eventCount: rows.length, + closeoutStarted: rows.some((entry) => entry.eventType === 'closeout.started'), + closeoutSummaryWritten: rows.some((entry) => entry.eventType === 'closeout.summary_written'), + closeoutFooterWritten: rows.some((entry) => entry.eventType === 'closeout.footer_written'), + closeoutFailures: rows + .filter((entry) => entry.eventType === 'closeout.failed') + .map((entry) => ({ + stage: toText(entry.payload?.stage), + message: toText(entry.payload?.message) + })) + } + }; +}; + +export const formatBenchRunFooter = (summary) => { + const lines = []; + const generatedAt = toText(summary?.generatedAt) || new Date().toISOString(); + const state = toText(summary?.run?.state) || 'unknown'; + const verdict = toText(summary?.verdict?.aggregateResultClass) || 'unknown'; + const unwaived = Number(summary?.verdict?.issues?.unwaivedCount || 0); + const waived = Number(summary?.verdict?.issues?.waivedCount || 0); + const planned = Number(summary?.counts?.planned || 0); + const finished = Number(summary?.counts?.finished || 0); + const unfinished = Number(summary?.counts?.unfinished || 0); + const crashParity = summary?.parities?.crashRetention || {}; + lines.push(`=== Bench closeout ${generatedAt} ===`); + lines.push(`State: ${state}`); + lines.push(`Repos: planned ${planned} | finished ${finished} | unfinished ${unfinished}`); + lines.push(`Verdict: ${verdict} (unwaived=${unwaived} waived=${waived})`); + lines.push( + `Crash retention parity: ledger ${Number(crashParity.ledgerCount || 0)} | ` + + `directory ${Number(crashParity.directoryCount || 0)}` + + `${crashParity.outputCount == null ? '' : ` | output ${Number(crashParity.outputCount || 0)}`}` + + ` | ${crashParity.ok === false ? 'mismatch' : 'ok'}` + ); + if (summary?.paths?.summaryPath) { + lines.push(`Run summary: ${summary.paths.summaryPath}`); + } + if (summary?.paths?.ledgerPath) { + lines.push(`Run ledger: ${summary.paths.ledgerPath}`); + } + return lines; +}; + +export const createBenchRunLedger = ({ + logsRoot, + runSuffix, + diagnosticsRoot, + configPath, + reposRoot, + cacheRoot, + resultsRoot, + masterLogPath, + waiverFile, + methodology +}) => { + const ledgerPath = path.join(logsRoot, `${runSuffix}-run-ledger.jsonl`); + const summaryPath = path.join(logsRoot, `${runSuffix}-run-summary.json`); + const footerPath = path.join(logsRoot, `${runSuffix}-footer.log`); + let writer = null; + let flushTimer = null; + const pendingSyncWrites = []; + + const ensureWriter = () => { + if (writer) return writer; + fs.mkdirSync(logsRoot, { recursive: true }); + writer = createQueuedAppendWriter({ + filePath: ledgerPath, + ensureDir: true, + syncOnFlush: false + }); + if (!flushTimer) { + flushTimer = setInterval(() => { + void flush(); + }, DEFAULT_LEDGER_FLUSH_INTERVAL_MS); + flushTimer.unref?.(); + } + return writer; + }; + + const trackPending = (line, promise) => { + pendingSyncWrites.push(line); + return promise.finally(() => { + const index = pendingSyncWrites.indexOf(line); + if (index >= 0) pendingSyncWrites.splice(index, 1); + }); + }; + + const appendLine = (entry) => { + const line = `${JSON.stringify(entry)}\n`; + const target = ensureWriter(); + void trackPending(line, target.enqueue(line)); + }; + + const appendLineSync = (entry) => { + const line = `${JSON.stringify(entry)}\n`; + try { + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.appendFileSync(ledgerPath, line); + } catch {} + }; + + const buildEvent = (eventType, payload = null) => ({ + schemaVersion: BENCH_RUN_LEDGER_SCHEMA_VERSION, + eventVersion: BENCH_RUN_LEDGER_EVENT_VERSION, + ts: new Date().toISOString(), + eventType, + payload: payload && typeof payload === 'object' ? payload : {} + }); + + const flush = async () => { + if (!writer?.flush) return; + await runWithTimeout( + () => writer.flush(), + { + timeoutMs: DEFAULT_LEDGER_CLOSE_TIMEOUT_MS, + errorFactory: () => createTimeoutError({ + code: 'ERR_BENCH_RUN_LEDGER_FLUSH_TIMEOUT', + message: 'Bench run ledger flush timed out.' + }) + } + ); + }; + + const close = async () => { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + const target = writer; + writer = null; + if (!target) return; + await flush(); + await runWithTimeout( + () => target.close(), + { + timeoutMs: DEFAULT_LEDGER_CLOSE_TIMEOUT_MS, + errorFactory: () => createTimeoutError({ + code: 'ERR_BENCH_RUN_LEDGER_CLOSE_TIMEOUT', + message: 'Bench run ledger close timed out.' + }) + } + ); + }; + + const closeSync = () => { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + if (!pendingSyncWrites.length) return; + try { + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.appendFileSync(ledgerPath, pendingSyncWrites.join('')); + } catch {} + pendingSyncWrites.length = 0; + writer = null; + }; + + const recordRunStarted = ({ plannedRepoCount = 0, taskCount = 0, environment = null }) => { + appendLine(buildEvent('run.started', { + runSuffix, + plannedRepoCount: Number(plannedRepoCount || 0), + taskCount: Number(taskCount || 0), + diagnosticsRoot, + configPath, + reposRoot, + cacheRoot, + resultsRoot, + masterLogPath, + ledgerPath, + summaryPath, + footerPath, + waiverFile: waiverFile || null, + environment: environment && typeof environment === 'object' + ? sortObjectKeys(environment) + : null, + methodology: methodology || null + })); + }; + + const recordRepoStarted = (plan) => { + appendLine(buildEvent('repo.started', { + language: toText(plan?.task?.language || plan?.language), + tier: toText(plan?.task?.tier || plan?.tierLabel), + repo: toText(plan?.task?.repo || plan?.repo), + repoPath: toText(plan?.repoPath), + outFile: toText(plan?.outFile) + })); + }; + + const recordRepoCompleted = (result) => { + const normalized = normalizeTaskEntry(result); + appendLine(buildEvent('repo.completed', { + result: normalized + })); + const crashRetention = normalized?.diagnostics?.crashRetention; + if (crashRetention?.bundlePath) { + appendLine(buildEvent('repo.crash_retained', crashRetention)); + } + }; + + const recordCloseoutEvent = (eventType, payload = null, { sync = false } = {}) => { + const event = buildEvent(eventType, payload); + if (sync) { + appendLineSync(event); + } else { + appendLine(event); + } + }; + + const writeFooterArtifact = async (summary, { sync = false } = {}) => { + const lines = formatBenchRunFooter(summary); + const text = `${lines.join('\n')}\n`; + if (sync) { + fs.mkdirSync(path.dirname(footerPath), { recursive: true }); + fs.writeFileSync(footerPath, text, 'utf8'); + return lines; + } + await fsPromises.mkdir(path.dirname(footerPath), { recursive: true }); + await fsPromises.writeFile(footerPath, text, 'utf8'); + return lines; + }; + + const buildSummary = async ({ output = null, endState, endReason = null, signal = null, exitCode = null }) => { + recordCloseoutEvent('run.ended', { + state: endState, + reason: endReason, + signal, + exitCode, + runStartedAt: null + }); + await flush(); + const events = await readBenchRunLedger(ledgerPath); + const summary = await buildBenchRunSummaryFromLedgerEvents({ + events, + diagnosticsRoot, + output, + runSuffix, + logPaths: { + masterLogPath, + footerPath, + ledgerPath, + summaryPath + } + }); + return summary; + }; + + const buildSummarySync = ({ endState, endReason = null, signal = null, exitCode = null }) => { + recordCloseoutEvent('run.ended', { + state: endState, + reason: endReason, + signal, + exitCode, + runStartedAt: null + }, { sync: true }); + const raw = fs.existsSync(ledgerPath) ? fs.readFileSync(ledgerPath, 'utf8') : ''; + const events = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + const summary = { + schemaVersion: BENCH_RUN_SUMMARY_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + runSuffix, + run: { + state: endState, + reason: endReason, + signal, + exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null, + plannedRepoCount: Number(events.find((entry) => entry.eventType === 'run.started')?.payload?.plannedRepoCount || 0), + finishedRepoCount: events.filter((entry) => entry.eventType === 'repo.completed').length + }, + verdict: evaluateBenchVerdict({ + tasks: events + .filter((entry) => entry.eventType === 'repo.completed') + .map((entry) => normalizeTaskEntry(entry.payload?.result)) + .filter(Boolean), + policy: { + schemaVersion: 1, + policyVersion: 'bench-language-policy-v1', + waiverFile: null, + waiverSchemaVersion: 1, + waivers: [], + loadErrors: [] + } + }).run, + counts: { + planned: Number(events.find((entry) => entry.eventType === 'run.started')?.payload?.plannedRepoCount || 0), + started: events.filter((entry) => entry.eventType === 'repo.started').length, + finished: events.filter((entry) => entry.eventType === 'repo.completed').length, + unfinished: Math.max( + 0, + Number(events.find((entry) => entry.eventType === 'run.started')?.payload?.plannedRepoCount || 0) + - events.filter((entry) => entry.eventType === 'repo.completed').length + ) + }, + unfinishedRepos: [], + parities: { + crashRetention: { + ledgerCount: events.filter((entry) => entry.eventType === 'repo.crash_retained').length, + directoryCount: 0, + outputCount: null, + ok: true, + missingOnDisk: [], + missingInLedger: [], + missingInOutput: [] + } + }, + paths: { + masterLogPath, + footerPath, + ledgerPath, + summaryPath, + diagnosticsRoot + }, + closeout: { + eventCount: events.length, + closeoutStarted: events.some((entry) => entry.eventType === 'closeout.started'), + closeoutSummaryWritten: false, + closeoutFooterWritten: false, + closeoutFailures: [] + } + }; + return summary; + }; + + const writeSummaryArtifact = async (summary) => { + await fsPromises.mkdir(path.dirname(summaryPath), { recursive: true }); + await fsPromises.writeFile(summaryPath, JSON.stringify(summary, null, 2)); + }; + + const writeSummaryArtifactSync = (summary) => { + fs.mkdirSync(path.dirname(summaryPath), { recursive: true }); + fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); + }; + + return { + ledgerPath, + summaryPath, + footerPath, + flush, + close, + closeSync, + recordRunStarted, + recordRepoStarted, + recordRepoCompleted, + recordCloseoutEvent, + writeSummaryArtifact, + writeSummaryArtifactSync, + writeFooterArtifact, + buildSummary, + buildSummarySync + }; +}; diff --git a/tools/bench/language-repos/run-loop.js b/tools/bench/language-repos/run-loop.js index 52f4fc2fd..9e3bfbcb6 100644 --- a/tools/bench/language-repos/run-loop.js +++ b/tools/bench/language-repos/run-loop.js @@ -1,6 +1,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { formatEtaSeconds } from '../../../src/shared/perf/eta.js'; +import { applyToolchainDaemonPolicyEnv } from '../../../src/shared/toolchain-env.js'; import { getRuntimeConfig, loadUserConfig, resolveRuntimeEnv } from '../../shared/dict-utils.js'; import { checkIndexLock, formatLockDetail } from '../language/locks.js'; import { @@ -10,8 +11,15 @@ import { getRecommendedHeapMb, stripMaxOldSpaceFlag } from '../language/metrics.js'; +import { + resolveBenchProcessTimeoutProfile, + resolveBenchRuntimeAdaptationPlan +} from '../language/timeout.js'; import { needsIndexArtifacts, needsSqliteArtifacts } from '../language/repos.js'; +const BENCH_CRASH_QUARANTINE_SCHEMA_VERSION = 1; +const OPENMOONRAY_WORKER_POOL_QUARANTINE_ID = 'openmoonray-worker-pool-off'; + /** * @typedef {object} BenchProgressEvent * @property {string} [event] @@ -265,6 +273,127 @@ const formatLineStatsSummary = (stats) => { return parts.join(' '); }; +const BENCH_REPO_SUMMARY_TYPE_LABELS = Object.freeze([ + ['provider_request_timeout', 'timeouts'], + ['provider_request_failed', 'request-failures'], + ['provider_circuit_breaker', 'circuit-breakers'], + ['provider_degraded_mode_entered', 'degraded'], + ['provider_preflight_blocked', 'blocked'], + ['artifact_tail_stall', 'artifact-stalls'], + ['queue_delay_hotspot', 'queue-hotspots'], + ['warning_suppressed', 'warning-suppressed'], + ['fallback_used', 'fallbacks'] +]); + +const formatRepoTopSignal = (entry) => { + if (!entry || typeof entry !== 'object') return null; + const label = String(entry.summaryLabel || '').trim(); + if (!label) return null; + const count = Number.isFinite(Number(entry.count)) ? Number(entry.count) : 0; + return count > 1 ? `${label} x${count}` : label; +}; + +export const buildBenchRepoCloseoutSummaryLines = ({ + repoLabel, + outcome = 'ok', + failureReason = null, + diagnostics = null, + progressConfidence = null, + crashRetention = null, + timeoutDecision = null +} = {}) => { + const label = String(repoLabel || '').trim() || 'repo'; + const countsByType = diagnostics?.countsByType && typeof diagnostics.countsByType === 'object' + ? diagnostics.countsByType + : {}; + const countsBySeverity = diagnostics?.countsBySeverity && typeof diagnostics.countsBySeverity === 'object' + ? diagnostics.countsBySeverity + : {}; + const issueParts = []; + for (const [eventType, displayLabel] of BENCH_REPO_SUMMARY_TYPE_LABELS) { + const count = Number(countsByType[eventType] || 0); + if (count > 0) { + issueParts.push(`${displayLabel}=${count}`); + } + } + const confidenceBucket = String(progressConfidence?.bucket || '').trim().toLowerCase(); + if (confidenceBucket && confidenceBucket !== 'high') { + const score = Number(progressConfidence?.score); + issueParts.push( + Number.isFinite(score) + ? `confidence=${confidenceBucket}:${score.toFixed(2)}` + : `confidence=${confidenceBucket}` + ); + } + if (crashRetention?.bundlePath) { + issueParts.push('crash-bundle=yes'); + } + const warningCount = Number(countsBySeverity.warn || 0); + const errorCount = Number(countsBySeverity.error || 0); + if (warningCount > 0 || errorCount > 0) { + issueParts.push(`severity=error:${errorCount},warn:${warningCount}`); + } + if (timeoutDecision && typeof timeoutDecision === 'object') { + const timeoutPhase = String(timeoutDecision.phase || '').trim(); + const timeoutResource = String(timeoutDecision.resourceClass || '').trim(); + const timeoutMode = String(timeoutDecision.failureMode || '').trim(); + const timeoutParts = [timeoutPhase, timeoutResource, timeoutMode].filter(Boolean); + if (timeoutParts.length) { + issueParts.push(`timeout=${timeoutParts.join('/')}`); + } + } + const summaryLine = `[repo-summary] ${label} ${outcome}${failureReason ? ` (${failureReason})` : ''}` + + (issueParts.length ? ` | ${issueParts.join(' | ')}` : ''); + const topSignals = Array.isArray(diagnostics?.topSignals) + ? diagnostics.topSignals.map(formatRepoTopSignal).filter(Boolean).slice(0, 3) + : []; + return topSignals.length + ? [summaryLine, `[repo-summary] ${label} top: ${topSignals.join(' | ')}`] + : [summaryLine]; +}; + +const maybeDelayBenchTestRepoStart = async () => { + if (process.env.PAIROFCLEATS_TESTING !== '1') return; + const delayMs = Number(process.env.PAIROFCLEATS_TEST_BENCH_REPO_DELAY_MS); + if (!Number.isFinite(delayMs) || delayMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, Math.floor(delayMs))); +}; + +const isCrashLikeBenchResult = (benchResult) => ( + benchResult + && benchResult.ok === false + && benchResult.diagnostics?.crashAttribution +); + +export const resolveBenchCrashQuarantineDecision = ({ + task, + benchResult, + crashRetention +} = {}) => { + if (process.platform !== 'win32') return null; + const repo = String(task?.repo || '').trim().toLowerCase(); + if (repo !== 'dreamworksanimation/openmoonray') return null; + const crashAttribution = benchResult?.diagnostics?.crashAttribution; + if (!crashAttribution || typeof crashAttribution !== 'object') return null; + if (String(crashAttribution.crashClass || '').trim() !== 'windows_access_violation') return null; + if (String(crashAttribution.recentCleanupLabel || '').trim() !== 'runtime.worker-pools.destroy') return null; + if (String(crashRetention?.crashState?.phase || '').trim() !== 'stage3:init') return null; + return { + schemaVersion: BENCH_CRASH_QUARANTINE_SCHEMA_VERSION, + quarantineId: OPENMOONRAY_WORKER_POOL_QUARANTINE_ID, + reason: 'windows_access_violation_after_embeddings_stage3_during_worker_pool_destroy', + likelySubsystem: 'embeddings-worker-pool-teardown', + envOverrides: { + PAIROFCLEATS_WORKER_POOL: 'off' + }, + failureContext: { + crashClass: crashAttribution.crashClass, + recentCleanupLabel: crashAttribution.recentCleanupLabel, + crashStatePhase: crashRetention?.crashState?.phase || null + } + }; +}; + /** * Build child bench command args for one repo plan. * @@ -331,17 +460,19 @@ const buildBenchArgs = ({ * quietMode:boolean, * dryRun:boolean, * repoLogsEnabled:boolean, - * initRepoLog:(input:{label:string,tier?:string,repoPath:string,slug:string}) => (string|null), + * initRepoLog:(input:{label:string,tier?:string,repoPath:string,slug:string}) => Promise<(string|null)>, * getRepoLogPath:() => (string|null), * clearLogHistory:() => void, * hasDiskFullMessageInHistory:() => boolean, + * runLedger?:object|null, * progressRuntime:BenchProgressRuntime, * lifecycle:object, * wantsSqlite:boolean, * backendList:string[], * lockMode:string, * lockWaitMs:number, - * lockStaleMs:number + * lockStaleMs:number, + * benchTimeoutMs:number * }} input * @returns {Promise} */ @@ -360,13 +491,15 @@ export const runBenchExecutionLoop = async ({ getRepoLogPath, clearLogHistory, hasDiskFullMessageInHistory, + runLedger = null, progressRuntime, lifecycle, wantsSqlite, backendList, lockMode, lockWaitMs, - lockStaleMs + lockStaleMs, + benchTimeoutMs }) => { const results = []; const benchScript = path.join(scriptRoot, 'tests', 'perf', 'bench', 'run.test.js'); @@ -379,7 +512,7 @@ export const runBenchExecutionLoop = async ({ ? stripMaxOldSpaceFlag(baseEnv.NODE_OPTIONS || '') : (baseEnv.NODE_OPTIONS || ''); const baseNodeOptionsHasHeapFlag = baseNodeOptionsForRun.includes('--max-old-space-size'); - const baseEnvForRepoRuntime = { ...baseEnv }; + const baseEnvForRepoRuntime = applyToolchainDaemonPolicyEnv(baseEnv); if (typeof baseEnv.NODE_OPTIONS === 'string' || baseNodeOptionsForRun) { baseEnvForRepoRuntime.NODE_OPTIONS = baseNodeOptionsForRun; } @@ -399,7 +532,6 @@ export const runBenchExecutionLoop = async ({ if (argv.backend) benchArgsSuffix.push('--backend', String(argv.backend)); if (argv.top) benchArgsSuffix.push('--top', String(argv.top)); if (argv.limit) benchArgsSuffix.push('--limit', String(argv.limit)); - if (argv.threads) benchArgsSuffix.push('--threads', String(argv.threads)); const childProgressMode = argv.progress === 'off' ? 'off' : 'jsonl'; benchArgsSuffix.push('--progress', childProgressMode); if (argv.verbose) benchArgsSuffix.push('--verbose'); @@ -455,12 +587,14 @@ export const runBenchExecutionLoop = async ({ fallbackLogSlug } = plan; progressRuntime.beginRepo({ tierLabel, repo: task.repo }); + runLedger?.recordRepoStarted?.(plan); + await maybeDelayBenchTestRepoStart(); // Reset per-repo transient history so failure summaries and disk-full // detection reflect only the currently executing repo. clearLogHistory(); if (repoLogsEnabled) { - initRepoLog({ + await initRepoLog({ label: repoLabel, tier: tierLabel, repoPath, @@ -480,31 +614,39 @@ export const runBenchExecutionLoop = async ({ } const repoState = await lifecycle.ensureRepoPresent({ task, repoPath, repoLabel }); if (!repoState.ok) { - appendLog(`[error] clone failed for ${repoLabel}; continuing.`, 'error'); + appendLog( + `[error] ${repoState.failureReason === 'platform_incompatible_checkout' ? 'platform compatibility blocked' : 'clone failed'} for ${repoLabel}; continuing.`, + 'error' + ); + if (repoState.failureDetail) { + appendLog(`[error] ${repoState.failureDetail}`, 'error'); + } const crashRetention = await lifecycle.attachCrashRetention({ task, repoLabel, repoPath, repoCacheRoot, outFile: null, - failureReason: 'clone', + failureReason: repoState.failureReason || 'clone', failureCode: repoState.failureCode ?? null, schedulerEvents: repoState.schedulerEvents || [] }); progressRuntime.completeRepo(); - appendLog('[metrics] failed (clone)'); - results.push({ + appendLog(`[metrics] failed (${repoState.failureReason || 'clone'})`); + const result = { ...task, repoPath, outFile: null, summary: null, failed: true, - failureReason: 'clone', + failureReason: repoState.failureReason || 'clone', failureCode: repoState.failureCode ?? null, ...(crashRetention ? { diagnostics: { crashRetention } } : {}) - }); + }; + results.push(result); + runLedger?.recordRepoCompleted?.(result); continue; } @@ -523,7 +665,7 @@ export const runBenchExecutionLoop = async ({ }); progressRuntime.completeRepo(); appendLog('[metrics] failed (preflight)'); - results.push({ + const result = { ...task, repoPath, outFile: null, @@ -534,7 +676,9 @@ export const runBenchExecutionLoop = async ({ ...(crashRetention ? { diagnostics: { crashRetention } } : {}) - }); + }; + results.push(result); + runLedger?.recordRepoCompleted?.(result); continue; } @@ -607,6 +751,37 @@ export const runBenchExecutionLoop = async ({ } } + const lineStats = lineStatsCache.get(repoPath) || null; + const timeoutAdaptation = resolveBenchRuntimeAdaptationPlan({ + repoTimeoutMs: benchTimeoutMs, + language: task.language || null, + lineStats, + buildIndex: shouldBuildIndex, + buildSqlite: shouldBuildSqlite, + queryCount: Number.isFinite(Number(task.queryCount)) ? Number(task.queryCount) : 0, + backendCount: backendList.length, + realEmbeddings: argv['stub-embeddings'] !== true, + requestedThreads: argv.threads + }); + const timeoutProfile = resolveBenchProcessTimeoutProfile({ + repoTimeoutMs: timeoutAdaptation.repoTimeoutMs + }); + const explicitThreads = Number.isFinite(Number(argv.threads)) && Number(argv.threads) > 0 + ? Math.floor(Number(argv.threads)) + : null; + const effectiveThreads = explicitThreads || timeoutAdaptation.recommendedThreads; + if (timeoutAdaptation.adapted || Number.isFinite(effectiveThreads)) { + const adaptationParts = [ + `tier=${timeoutAdaptation.repoShape.tier}`, + `timeout=${timeoutProfile.idleTimeoutMs}ms/${timeoutProfile.hardTimeoutMs}ms` + ]; + if (Number.isFinite(effectiveThreads)) adaptationParts.push(`threads=${effectiveThreads}`); + if (timeoutAdaptation.adaptationReasons.length) { + adaptationParts.push(`reasons=${timeoutAdaptation.adaptationReasons.join(',')}`); + } + appendLog(`[timeout] repo adaptation for ${repoLabel}: ${adaptationParts.join(' | ')}`); + } + const lockCheck = await checkIndexLock({ repoCacheRoot, repoLabel, @@ -618,11 +793,10 @@ export const runBenchExecutionLoop = async ({ if (!lockCheck.ok) { const detail = formatLockDetail(lockCheck.detail); const message = `Skipping ${repoLabel}: index lock held ${detail}`.trim(); - appendLog(`[lock] ${message}`); - if (!quietMode) display.error(message); + appendLog(`[lock] ${message}`, 'error', { forceOutput: true }); progressRuntime.completeRepo(); appendLog('[metrics] skipped (lock)'); - results.push({ + const result = { ...task, repoPath, outFile, @@ -630,10 +804,16 @@ export const runBenchExecutionLoop = async ({ skipped: true, skipReason: 'lock', lock: lockCheck.detail || null - }); + }; + results.push(result); + runLedger?.recordRepoCompleted?.(result); continue; } + const perRepoBenchArgsSuffix = benchArgsSuffix.slice(); + if (Number.isFinite(effectiveThreads)) { + perRepoBenchArgsSuffix.push('--threads', String(effectiveThreads)); + } const benchArgs = buildBenchArgs({ benchScript, repoPath, @@ -645,57 +825,144 @@ export const runBenchExecutionLoop = async ({ buildIndexFlag, buildSqliteFlag, benchArgsPrefix, - benchArgsSuffix + benchArgsSuffix: perRepoBenchArgsSuffix }); progressRuntime.update(); let summary = null; + let benchResult = null; + let crashQuarantineRecovery = null; if (dryRun) { appendLog(`[dry-run] node ${benchArgs.join(' ')}`); } else { - const benchProcessEnv = { ...repoEnvBase }; + const benchProcessEnv = applyToolchainDaemonPolicyEnv(repoEnvBase); if (!Object.prototype.hasOwnProperty.call(benchProcessEnv, 'PAIROFCLEATS_CRASH_LOG_ANNOUNCE')) { benchProcessEnv.PAIROFCLEATS_CRASH_LOG_ANNOUNCE = '0'; } - const benchResult = await processRunner.runProcess(`bench ${repoLabel}`, process.execPath, benchArgs, { + benchResult = await processRunner.runProcess(`bench ${repoLabel}`, process.execPath, benchArgs, { cwd: scriptRoot, env: benchProcessEnv, + timeoutMs: timeoutProfile.hardTimeoutMs, + idleTimeoutMs: timeoutProfile.idleTimeoutMs, continueOnError: true }); if (!benchResult.ok) { artifactStateCache.delete(repoPath); const diskFull = hasDiskFullMessageInHistory(); - if (diskFull) { - appendLog(`[error] disk full while benchmarking ${repoLabel}; continuing.`, 'error'); + let crashRetention = null; + if (!diskFull && isCrashLikeBenchResult(benchResult)) { + crashRetention = await lifecycle.attachCrashRetention({ + task, + repoLabel, + repoPath, + repoCacheRoot, + outFile, + failureReason: 'bench', + failureCode: benchResult.code ?? null, + failureContext: benchResult.diagnostics?.crashAttribution || null, + schedulerEvents: benchResult.schedulerEvents || [] + }); + const quarantineDecision = resolveBenchCrashQuarantineDecision({ + task, + benchResult, + crashRetention + }); + if (quarantineDecision) { + appendLog( + `[crash-quarantine] ${repoLabel}: retrying with worker pool disabled ` + + `(${quarantineDecision.reason}).`, + 'warn' + ); + const retryEnv = { + ...benchProcessEnv, + ...quarantineDecision.envOverrides + }; + const retryResult = await processRunner.runProcess(`bench ${repoLabel} [quarantine]`, process.execPath, benchArgs, { + cwd: scriptRoot, + env: retryEnv, + timeoutMs: timeoutProfile.hardTimeoutMs, + idleTimeoutMs: timeoutProfile.idleTimeoutMs, + continueOnError: true + }); + if (retryResult.ok) { + crashQuarantineRecovery = { + schemaVersion: BENCH_CRASH_QUARANTINE_SCHEMA_VERSION, + quarantineId: quarantineDecision.quarantineId, + reason: quarantineDecision.reason, + likelySubsystem: quarantineDecision.likelySubsystem, + envOverrides: { ...quarantineDecision.envOverrides }, + priorCrashRetention: crashRetention || null + }; + benchResult = retryResult; + } else { + benchResult = retryResult; + } + } + } + if (!benchResult.ok) { + if (!crashRetention) { + crashRetention = await lifecycle.attachCrashRetention({ + task, + repoLabel, + repoPath, + repoCacheRoot, + outFile, + failureReason: diskFull ? 'disk-full' : 'bench', + failureCode: benchResult.code ?? null, + failureContext: benchResult.diagnostics?.crashAttribution || null, + schedulerEvents: benchResult.schedulerEvents || [] + }); + } + if (diskFull) { + appendLog(`[error] disk full while benchmarking ${repoLabel}; continuing.`, 'error'); + } + appendLog(`[error] benchmark failed for ${repoLabel}; continuing.`, 'error'); + const failureReason = diskFull ? 'disk-full' : 'bench'; + progressRuntime.completeRepo(); + appendLog('[metrics] failed (bench)'); + const result = { + ...task, + repoPath, + outFile, + summary: null, + failed: true, + failureReason, + failureCode: benchResult.code ?? null, + failureSignal: benchResult.signal ?? null, + timeoutKind: benchResult.timeoutKind || null, + timeoutDecision: benchResult.timeoutDecision || null, + lastActivity: benchResult.lastActivity || null, + ...(crashRetention + ? { + diagnostics: { + process: benchResult.diagnostics || null, + progressConfidence: benchResult.progressConfidence || null, + crashRetention + } + } + : { + diagnostics: { + process: benchResult.diagnostics || null, + progressConfidence: benchResult.progressConfidence || null + } + }) + }; + for (const line of buildBenchRepoCloseoutSummaryLines({ + repoLabel, + outcome: 'failed', + failureReason, + diagnostics: result.diagnostics?.process || null, + progressConfidence: result.diagnostics?.progressConfidence || null, + crashRetention: crashRetention || null, + timeoutDecision: result.timeoutDecision || null + })) { + appendLog(line, 'warn'); + } + results.push(result); + runLedger?.recordRepoCompleted?.(result); + continue; } - appendLog(`[error] benchmark failed for ${repoLabel}; continuing.`, 'error'); - const failureReason = diskFull ? 'disk-full' : 'bench'; - const crashRetention = await lifecycle.attachCrashRetention({ - task, - repoLabel, - repoPath, - repoCacheRoot, - outFile, - failureReason, - failureCode: benchResult.code ?? null, - schedulerEvents: benchResult.schedulerEvents || [] - }); - progressRuntime.completeRepo(); - appendLog('[metrics] failed (bench)'); - results.push({ - ...task, - repoPath, - outFile, - summary: null, - failed: true, - failureReason, - failureCode: benchResult.code ?? null, - ...(crashRetention - ? { diagnostics: { crashRetention } } - : {}) - }); - continue; } markArtifactsPresent({ @@ -709,7 +976,9 @@ export const runBenchExecutionLoop = async ({ summary = JSON.parse(raw).summary || null; } catch (err) { appendLog(`[error] failed to read bench report for ${repoLabel}; continuing.`, 'error'); - if (err && err.message) display.error(err.message); + if (err && err.message) { + appendLog(err.message, 'error', { forceOutput: true }); + } const crashRetention = await lifecycle.attachCrashRetention({ task, repoLabel, @@ -722,7 +991,7 @@ export const runBenchExecutionLoop = async ({ }); progressRuntime.completeRepo(); appendLog('[metrics] failed (report)'); - results.push({ + const result = { ...task, repoPath, outFile, @@ -730,17 +999,57 @@ export const runBenchExecutionLoop = async ({ failed: true, failureReason: 'report', failureCode: null, - ...(crashRetention - ? { diagnostics: { crashRetention } } - : {}) - }); + diagnostics: { + process: benchResult.diagnostics || null, + progressConfidence: benchResult.progressConfidence || null, + ...(crashRetention ? { crashRetention } : {}) + } + }; + for (const line of buildBenchRepoCloseoutSummaryLines({ + repoLabel, + outcome: 'failed', + failureReason: 'report', + diagnostics: result.diagnostics?.process || null, + progressConfidence: result.diagnostics?.progressConfidence || null, + crashRetention: crashRetention || null + })) { + appendLog(line, 'warn'); + } + results.push(result); + runLedger?.recordRepoCompleted?.(result); continue; } } progressRuntime.completeRepo(); appendLog(`[metrics] ${formatMetricSummary(summary)}`); - results.push({ ...task, repoPath, outFile, summary }); + const result = { + ...task, + repoPath, + outFile, + summary, + diagnostics: benchResult + ? { + process: benchResult.diagnostics || null, + progressConfidence: benchResult.progressConfidence || null, + ...(crashQuarantineRecovery ? { crashQuarantineRecovery } : {}) + } + : {} + }; + const repoSummaryLines = buildBenchRepoCloseoutSummaryLines({ + repoLabel, + outcome: 'ok', + diagnostics: result.diagnostics?.process || null, + progressConfidence: result.diagnostics?.progressConfidence || null, + crashRetention: null + }); + if (repoSummaryLines.length > 1 || /\|/.test(repoSummaryLines[0] || '')) { + for (const line of repoSummaryLines) { + appendLog(line); + } + } + results.push(result); + runLedger?.recordRepoCompleted?.(result); } finally { await lifecycle.cleanRepoCache({ repoCacheRoot, repoLabel }); } diff --git a/tools/bench/language-summarize.js b/tools/bench/language-summarize.js index bc3b91863..f025fb69b 100644 --- a/tools/bench/language-summarize.js +++ b/tools/bench/language-summarize.js @@ -1,7 +1,8 @@ #!/usr/bin/env node import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; import path from 'node:path'; +import { writeJsonFileResolved } from '../../src/shared/json-file.js'; +import { writeTextIfChanged } from '../shared/generated-report.js'; import { summarizeResults } from './language/report.js'; const NON_REPO_RESULTS_FOLDERS = new Set(['logs', 'usr']); @@ -371,7 +372,7 @@ const renderMarkdown = ({ summary }) => { }; const printUsage = () => { - console.error('Usage: node tools/bench/language-summarize.js [options]'); + console.error('Usage: pairofcleats bench summarize [options]'); console.error(''); console.error('Options:'); console.error(' --results Results root (default: ./benchmarks/results)'); @@ -499,10 +500,8 @@ const run = async () => { baselineDiff, matrix: withSummary.map(({ _summarySource, ...row }) => row) }; - await fsPromises.mkdir(path.dirname(outJsonPath), { recursive: true }); - await fsPromises.mkdir(path.dirname(outMdPath), { recursive: true }); - await fsPromises.writeFile(outJsonPath, JSON.stringify(output, null, 2), 'utf8'); - await fsPromises.writeFile(outMdPath, renderMarkdown({ summary: output }), 'utf8'); + await writeJsonFileResolved(outJsonPath, output); + await writeTextIfChanged(outMdPath, renderMarkdown({ summary: output }), { encoding: 'utf8' }); if (options.jsonOutput) { process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); } else { diff --git a/tools/bench/language/canaries.js b/tools/bench/language/canaries.js new file mode 100644 index 000000000..fb943a3fa --- /dev/null +++ b/tools/bench/language/canaries.js @@ -0,0 +1,891 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; +import { resolveWindowsCmdInvocation } from '../../../src/shared/subprocess/windows-cmd.js'; +import { classifyBenchTask } from './verdict.js'; +import { + buildBenchEnvironmentMetadata, + createBenchDiagnosticClassifier +} from './logging.js'; +import { sumDiagnosticCounts } from './diagnostics.js'; + +const DEFAULT_CANARY_ROOT = path.join(process.cwd(), 'tests', 'fixtures', 'bench-runtime-canaries'); +const DEFAULT_LIVE_CANARY_TIMEOUT_MS = 5 * 60 * 1000; +const VERSION_PROBE_TIMEOUT_MS = 1500; +const BENCH_RUNTIME_ENVIRONMENT_PROBES = Object.freeze([ + { id: 'node', command: process.execPath, args: ['--version'] }, + { id: 'git', command: 'git', args: ['--version'] }, + { id: 'npm', command: 'npm', args: ['--version'] }, + { id: 'cargo', command: 'cargo', args: ['--version'] }, + { id: 'rustc', command: 'rustc', args: ['--version'] }, + { id: 'go', command: 'go', args: ['version'] }, + { id: 'swift', command: 'swift', args: ['--version'] }, + { id: 'python', command: 'python', args: ['--version'] }, + { id: 'gopls', command: 'gopls', args: ['version'] }, + { id: 'sourcekit-lsp', command: 'sourcekit-lsp', args: ['--version'] }, + { id: 'rust-analyzer', command: 'rust-analyzer', args: ['--version'] } +]); + +const environmentSnapshotCache = new Map(); + +export const DEFAULT_BENCH_RUNTIME_CANARY_ROOT = DEFAULT_CANARY_ROOT; +export const BENCH_RUNTIME_CANARY_MANIFEST_SCHEMA_VERSION = 2; +export const BENCH_RUNTIME_LIVE_CANARY_RESULT_SCHEMA_VERSION = 1; +export const BENCH_RUNTIME_LIVE_CANARY_SUMMARY_SCHEMA_VERSION = 1; +export const BENCH_RUNTIME_BLOCKER_CONFIRMATION_SCHEMA_VERSION = 1; +export const BENCH_RUNTIME_BLOCKER_CLOSURE_EVIDENCE_SCHEMA_VERSION = 1; + +export const BENCH_RUNTIME_LIVE_CANARY_STATUS = Object.freeze({ + BASELINE_CONFIRMED: 'baseline_confirmed', + TARGET_ACHIEVED: 'target_achieved', + REGRESSED: 'regressed', + INCONCLUSIVE: 'inconclusive', + RUNNER_FAILED: 'runner_failed' +}); + +const DIRECT_METRIC_KEYS = Object.freeze([ + 'resultClass', + 'productionCleanStatus', + 'timeoutClasses', + 'countsByDiagnosticType', + 'countsByFailureClass', + 'crashCount', + 'taskCount' +]); + +const DIRECT_COUNT_KEYS = Object.freeze({ + artifactStallCount: 'artifact_tail_stall', + fallbackCount: 'fallback_used', + providerBlockedCount: 'provider_preflight_blocked', + providerDegradedCount: 'provider_degraded_mode_entered', + providerTimeoutCount: 'provider_request_timeout', + circuitBreakerCount: 'provider_circuit_breaker' +}); + +const resolveBenchTimeoutClasses = (tasks) => { + const out = new Set(); + for (const task of Array.isArray(tasks) ? tasks : []) { + const timeoutDecision = task?.timeoutDecision && typeof task.timeoutDecision === 'object' + ? task.timeoutDecision + : null; + const candidates = [ + timeoutDecision?.timeoutClass, + timeoutDecision?.candidateTimeoutClass, + task?.taskStatus?.primaryFailureClass + ]; + for (const candidate of candidates) { + const text = String(candidate || '').trim(); + if (text) out.add(text); + } + } + return Array.from(out).sort((left, right) => left.localeCompare(right)); +}; + +const normalizeCountMap = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .map(([key, raw]) => [String(key), Number(raw)]) + .filter(([, count]) => Number.isFinite(count) && count > 0) + .sort(([left], [right]) => left.localeCompare(right)) + ); +}; + +const normalizeConstraintCountMap = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .map(([key, raw]) => [String(key), Number(raw)]) + .filter(([, count]) => Number.isFinite(count) && count >= 0) + .sort(([left], [right]) => left.localeCompare(right)) + ); +}; + +const normalizeTextList = (value) => Array.from(new Set( + (Array.isArray(value) ? value : []) + .map((entry) => String(entry || '').trim()) + .filter(Boolean) +)).sort((left, right) => left.localeCompare(right)); + +const inferExpectedPlatform = (value) => { + const text = String(value || '').trim().toLowerCase(); + if (!text || text.includes('cross-platform')) return null; + if (text.includes('windows')) return 'win32'; + if (text.includes('macos') || text.includes('darwin')) return 'darwin'; + if (text.includes('linux')) return 'linux'; + return null; +}; + +const collectExpectedProbeIds = (entry) => { + const corpus = [ + entry?.environment?.toolchain, + entry?.environment?.providerAvailability, + entry?.repo, + entry?.profile + ].map((value) => String(value || '').toLowerCase()).join(' '); + const ids = new Set(['node', 'git', 'npm']); + if (corpus.includes('rust')) { + ids.add('cargo'); + ids.add('rustc'); + ids.add('rust-analyzer'); + } + if (corpus.includes('go') || corpus.includes('gopls')) { + ids.add('go'); + ids.add('gopls'); + } + if (corpus.includes('swift') || corpus.includes('sourcekit')) { + ids.add('swift'); + ids.add('sourcekit-lsp'); + } + if (corpus.includes('python')) ids.add('python'); + return Array.from(ids).sort((left, right) => left.localeCompare(right)); +}; + +const runVersionProbe = ({ command, args }) => { + try { + const invocation = process.platform === 'win32' + ? resolveWindowsCmdInvocation(command, args, process.env) + : { command, args }; + const result = spawnSync(invocation.command, invocation.args, { + cwd: process.cwd(), + env: process.env, + encoding: 'utf8', + timeout: VERSION_PROBE_TIMEOUT_MS + }); + const stdout = String(result.stdout || '').trim(); + const stderr = String(result.stderr || '').trim(); + const version = stdout || stderr || null; + return { + available: result.status === 0, + version, + exitCode: Number.isFinite(Number(result.status)) ? Number(result.status) : null + }; + } catch (error) { + return { + available: false, + version: null, + exitCode: null, + error: error?.message || String(error) + }; + } +}; + +const buildBenchRuntimeEnvironmentSnapshot = (entry) => { + const probeIds = collectExpectedProbeIds(entry); + const probes = Object.fromEntries( + BENCH_RUNTIME_ENVIRONMENT_PROBES + .filter((probe) => probeIds.includes(probe.id)) + .map((probe) => [probe.id, runVersionProbe(probe)]) + ); + const metadata = buildBenchEnvironmentMetadata(process.env); + const mismatches = []; + const expectedPlatform = inferExpectedPlatform(entry?.environment?.os); + if (expectedPlatform && metadata.platform !== expectedPlatform) { + mismatches.push(`expected platform ${expectedPlatform}, got ${metadata.platform}`); + } + for (const probeId of probeIds) { + const probe = probes[probeId]; + if (!probe) continue; + if (!probe.available && probeId !== 'git' && probeId !== 'npm') { + mismatches.push(`expected tool/provider ${probeId} to be available`); + } + } + const fingerprint = crypto + .createHash('sha1') + .update(JSON.stringify({ + metadata, + probes + })) + .digest('hex'); + return { + declaredContract: entry?.environment || null, + actual: metadata, + probes, + expectedProbeIds: probeIds, + mismatches, + fingerprint: `sha1:${fingerprint}` + }; +}; + +const getBenchRuntimeEnvironmentSnapshot = (entry) => { + const cacheKey = JSON.stringify({ + environment: entry?.environment || null, + probeIds: collectExpectedProbeIds(entry) + }); + if (!environmentSnapshotCache.has(cacheKey)) { + environmentSnapshotCache.set(cacheKey, buildBenchRuntimeEnvironmentSnapshot(entry)); + } + return environmentSnapshotCache.get(cacheKey); +}; + +const toNonEmptyLines = (content) => String(content || '') + .split(/\r?\n/u) + .map((line) => line.trimEnd()) + .filter((line) => line.trim()); + +const parseBenchmarkConfirmationLaneSpec = (value) => { + const text = String(value || '').trim(); + const normalized = text.toLowerCase(); + const lanes = Array.from(new Set( + ['small', 'medium', 'large'] + .filter((lane) => normalized.includes(lane)) + )); + return { + raw: text || null, + lanes + }; +}; + +const buildCountHelpers = (metrics) => { + const counts = normalizeCountMap(metrics?.countsByDiagnosticType); + return { + ...metrics, + countsByDiagnosticType: counts, + countsByFailureClass: normalizeCountMap(metrics?.countsByFailureClass), + timeoutClasses: normalizeTextList(metrics?.timeoutClasses), + crashCount: Number.isFinite(Number(metrics?.crashCount)) ? Number(metrics.crashCount) : 0, + taskCount: Number.isFinite(Number(metrics?.taskCount)) ? Number(metrics.taskCount) : 0, + artifactStallCount: Number(metrics?.artifactStallCount ?? counts.artifact_tail_stall ?? 0) || 0, + fallbackCount: Number(metrics?.fallbackCount ?? counts.fallback_used ?? 0) || 0, + providerBlockedCount: Number(metrics?.providerBlockedCount ?? counts.provider_preflight_blocked ?? 0) || 0, + providerDegradedCount: Number(metrics?.providerDegradedCount ?? counts.provider_degraded_mode_entered ?? 0) || 0, + providerTimeoutCount: Number(metrics?.providerTimeoutCount ?? counts.provider_request_timeout ?? 0) || 0, + circuitBreakerCount: Number(metrics?.circuitBreakerCount ?? counts.provider_circuit_breaker ?? 0) || 0 + }; +}; + +const extractBenchRuntimeTaskMetrics = (entry) => { + const taskStatus = entry?.taskStatus || classifyBenchTask(entry); + const countsByFailureClass = {}; + if (taskStatus?.primaryFailureClass) { + countsByFailureClass[taskStatus.primaryFailureClass] = 1; + } + const timeoutClasses = []; + if (taskStatus?.resultClass === 'timed_out' && taskStatus?.primaryFailureClass) { + timeoutClasses.push(taskStatus.primaryFailureClass); + } + return buildCountHelpers({ + resultClass: String(taskStatus?.resultClass || '').trim() || null, + productionCleanStatus: null, + timeoutClasses, + countsByDiagnosticType: normalizeCountMap(sumDiagnosticCounts(entry)), + countsByFailureClass, + crashCount: taskStatus?.resultClass === 'crashed' ? 1 : 0, + taskCount: 1 + }); +}; + +export const resolveBenchRuntimeCanaryRoot = (root = process.cwd()) => ( + path.join(root, 'tests', 'fixtures', 'bench-runtime-canaries') +); + +export const loadBenchRuntimeCanaryManifest = async (root = process.cwd()) => { + const canaryRoot = resolveBenchRuntimeCanaryRoot(root); + const manifestPath = path.join(canaryRoot, 'manifest.json'); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')); + return { + canaryRoot, + manifestPath, + manifest + }; +}; + +export const loadBenchRuntimeCanaryFixture = async (entry, root = process.cwd()) => { + const canaryRoot = resolveBenchRuntimeCanaryRoot(root); + const filePath = path.join(canaryRoot, String(entry?.file || '')); + const content = await fsPromises.readFile(filePath, 'utf8'); + return { + filePath, + content + }; +}; + +export const replayBenchRuntimeCanary = async (entry, root = process.cwd()) => { + const { filePath, content } = await loadBenchRuntimeCanaryFixture(entry, root); + const lines = toNonEmptyLines(content); + const classifier = createBenchDiagnosticClassifier(); + const signals = []; + const eventTypes = new Set(); + const failureClasses = new Set(); + + for (const line of lines) { + if (entry?.kind === 'structured-stream') { + const parsed = JSON.parse(line); + const classified = classifier.classify({ event: parsed, source: 'stream' }); + for (const signal of classified) { + signals.push(signal); + if (signal?.eventType) eventTypes.add(signal.eventType); + if (signal?.failureClass) failureClasses.add(signal.failureClass); + } + continue; + } + if (entry?.kind === 'log-fragment') { + const classified = classifier.classify({ line, source: 'log' }); + for (const signal of classified) { + signals.push(signal); + if (signal?.eventType) eventTypes.add(signal.eventType); + if (signal?.failureClass) failureClasses.add(signal.failureClass); + } + } + } + + const requiredPatterns = Array.isArray(entry?.requiredPatterns) ? entry.requiredPatterns : []; + const matchedPatterns = requiredPatterns.filter((pattern) => String(content).includes(String(pattern))); + + return { + filePath, + lineCount: lines.length, + eventTypes: Array.from(eventTypes).sort((left, right) => left.localeCompare(right)), + failureClasses: Array.from(failureClasses).sort((left, right) => left.localeCompare(right)), + matchedPatterns, + requiredPatterns, + signals + }; +}; + +const validateLiveCanaryEnvironment = (environment) => { + const failures = []; + if (!environment || typeof environment !== 'object' || Array.isArray(environment)) { + failures.push('environment contract is required'); + return failures; + } + const requiredKeys = [ + 'os', + 'toolchain', + 'providerAvailability', + 'cacheState', + 'configProfile' + ]; + for (const key of requiredKeys) { + if (!String(environment[key] || '').trim()) { + failures.push(`environment.${key} is required`); + } + } + return failures; +}; + +export const validateBenchRuntimeCanaryManifest = (manifest) => { + const failures = []; + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + return ['manifest must be an object']; + } + if (Number(manifest.schemaVersion) !== BENCH_RUNTIME_CANARY_MANIFEST_SCHEMA_VERSION) { + failures.push(`expected schemaVersion ${BENCH_RUNTIME_CANARY_MANIFEST_SCHEMA_VERSION}`); + } + if (!Array.isArray(manifest.entries)) { + failures.push('entries array is required'); + } + if (!Array.isArray(manifest.liveCanaries)) { + failures.push('liveCanaries array is required'); + } + for (const entry of Array.isArray(manifest.liveCanaries) ? manifest.liveCanaries : []) { + const id = String(entry?.id || '').trim(); + if (!id) failures.push('live canary id is required'); + if (!String(entry?.repo || '').trim()) failures.push(`${id || ''}: repo is required`); + if (!String(entry?.profile || '').trim()) failures.push(`${id || ''}: profile is required`); + if (!String(entry?.canaryKind || '').trim()) failures.push(`${id || ''}: canaryKind is required`); + if (!String(entry?.currentFailureMode || '').trim()) { + failures.push(`${id || ''}: currentFailureMode is required`); + } + if (!String(entry?.expectedImprovedMode || '').trim()) { + failures.push(`${id || ''}: expectedImprovedMode is required`); + } + if (!String(entry?.benchmarkConfirmationLane || '').trim()) { + failures.push(`${id || ''}: benchmarkConfirmationLane is required`); + } + if (!entry?.proofOfSimilarity || typeof entry.proofOfSimilarity !== 'object' || Array.isArray(entry.proofOfSimilarity)) { + failures.push(`${id || ''}: proofOfSimilarity is required`); + } else { + if (!String(entry.proofOfSimilarity.referenceRun || '').trim()) { + failures.push(`${id || ''}: proofOfSimilarity.referenceRun is required`); + } + if (!String(entry.proofOfSimilarity.note || '').trim()) { + failures.push(`${id || ''}: proofOfSimilarity.note is required`); + } + } + failures.push(...validateLiveCanaryEnvironment(entry?.environment).map((failure) => `${id || ''}: ${failure}`)); + if (!entry?.runner || typeof entry.runner !== 'object' || Array.isArray(entry.runner)) { + failures.push(`${id || ''}: runner is required`); + } else { + if (!String(entry.runner.type || '').trim()) failures.push(`${id || ''}: runner.type is required`); + if (!String(entry.runner.script || '').trim()) failures.push(`${id || ''}: runner.script is required`); + } + if (!entry?.currentContract || typeof entry.currentContract !== 'object' || Array.isArray(entry.currentContract)) { + failures.push(`${id || ''}: currentContract is required`); + } + if (!entry?.targetContract || typeof entry.targetContract !== 'object' || Array.isArray(entry.targetContract)) { + failures.push(`${id || ''}: targetContract is required`); + } + } + return failures; +}; + +export const extractBenchRuntimeCanaryMetrics = (payload) => { + const direct = payload?.canaryMetrics && typeof payload.canaryMetrics === 'object' && !Array.isArray(payload.canaryMetrics) + ? payload.canaryMetrics + : null; + if (direct) { + const metrics = {}; + for (const key of DIRECT_METRIC_KEYS) { + if (direct[key] !== undefined) metrics[key] = direct[key]; + } + for (const [field, diagnosticType] of Object.entries(DIRECT_COUNT_KEYS)) { + if (direct[field] !== undefined) metrics[field] = direct[field]; + else if (direct.countsByDiagnosticType?.[diagnosticType] !== undefined) { + metrics[field] = direct.countsByDiagnosticType[diagnosticType]; + } + } + return buildCountHelpers(metrics); + } + + const run = payload?.run && typeof payload.run === 'object' ? payload.run : {}; + const tasks = Array.isArray(payload?.tasks) ? payload.tasks : []; + return buildCountHelpers({ + resultClass: String(run.aggregateResultClass || '').trim() || null, + productionCleanStatus: String(run.productionClean?.status || '').trim() || null, + timeoutClasses: resolveBenchTimeoutClasses(tasks), + countsByDiagnosticType: normalizeCountMap(run.countsByDiagnosticType), + countsByFailureClass: normalizeCountMap(run.countsByFailureClass), + crashCount: Number(run.countsByResultClass?.crashed || 0), + taskCount: tasks.length + }); +}; + +export const evaluateBenchRuntimeCanaryContract = (contract, metrics) => { + const failures = []; + const normalized = buildCountHelpers(metrics); + const allowedResultClasses = normalizeTextList(contract?.allowedResultClasses); + if (allowedResultClasses.length && !allowedResultClasses.includes(String(normalized.resultClass || '').trim())) { + failures.push(`resultClass ${normalized.resultClass || 'unknown'} not in ${allowedResultClasses.join(', ')}`); + } + const requiredTimeoutClasses = normalizeTextList(contract?.requiredTimeoutClasses); + for (const timeoutClass of requiredTimeoutClasses) { + if (!normalized.timeoutClasses.includes(timeoutClass)) { + failures.push(`missing timeoutClass ${timeoutClass}`); + } + } + const requiredFailureClasses = normalizeTextList(contract?.requiredFailureClasses); + const failureClasses = Object.keys(normalized.countsByFailureClass || {}); + for (const failureClass of requiredFailureClasses) { + if (!failureClasses.includes(failureClass)) { + failures.push(`missing failureClass ${failureClass}`); + } + } + const minCounts = normalizeConstraintCountMap(contract?.minCountsByDiagnosticType); + for (const [diagnosticType, minCount] of Object.entries(minCounts)) { + const actual = Number(normalized.countsByDiagnosticType?.[diagnosticType] || 0); + if (actual < minCount) { + failures.push(`diagnostic ${diagnosticType} expected >= ${minCount}, got ${actual}`); + } + } + const maxCounts = normalizeConstraintCountMap(contract?.maxCountsByDiagnosticType); + for (const [diagnosticType, maxCount] of Object.entries(maxCounts)) { + const actual = Number(normalized.countsByDiagnosticType?.[diagnosticType] || 0); + if (actual > maxCount) { + failures.push(`diagnostic ${diagnosticType} expected <= ${maxCount}, got ${actual}`); + } + } + if (Number.isFinite(Number(contract?.minCrashCount)) && normalized.crashCount < Number(contract.minCrashCount)) { + failures.push(`crashCount expected >= ${Number(contract.minCrashCount)}, got ${normalized.crashCount}`); + } + if (Number.isFinite(Number(contract?.maxCrashCount)) && normalized.crashCount > Number(contract.maxCrashCount)) { + failures.push(`crashCount expected <= ${Number(contract.maxCrashCount)}, got ${normalized.crashCount}`); + } + const requiredProductionCleanStatus = String(contract?.productionCleanStatus || '').trim(); + if (requiredProductionCleanStatus && normalized.productionCleanStatus !== requiredProductionCleanStatus) { + failures.push( + `productionCleanStatus expected ${requiredProductionCleanStatus}, got ${normalized.productionCleanStatus || 'unknown'}` + ); + } + return { + ok: failures.length === 0, + failures, + metrics: normalized + }; +}; + +export const evaluateBenchRuntimeCanaryForbiddenRegressions = (rules, metrics) => { + const normalized = buildCountHelpers(metrics); + const failures = []; + for (const rule of Array.isArray(rules) ? rules : []) { + const type = String(rule?.type || '').trim(); + if (!type) continue; + if (type === 'resultClassDisallowed') { + const values = normalizeTextList(rule?.values); + if (values.includes(String(normalized.resultClass || '').trim())) { + failures.push(`resultClass ${normalized.resultClass} is disallowed`); + } + continue; + } + if (type === 'timeoutClassDisallowed') { + const values = normalizeTextList(rule?.values); + const matched = values.filter((value) => normalized.timeoutClasses.includes(value)); + for (const value of matched) { + failures.push(`timeoutClass ${value} is disallowed`); + } + continue; + } + if (type === 'diagnosticMax') { + const diagnosticType = String(rule?.diagnosticType || '').trim(); + const max = Number(rule?.max); + if (!diagnosticType || !Number.isFinite(max)) continue; + const actual = Number(normalized.countsByDiagnosticType?.[diagnosticType] || 0); + if (actual > max) { + failures.push(`diagnostic ${diagnosticType} exceeded max ${max} (got ${actual})`); + } + continue; + } + if (type === 'crashCountMax') { + const max = Number(rule?.max); + if (Number.isFinite(max) && normalized.crashCount > max) { + failures.push(`crashCount exceeded max ${max} (got ${normalized.crashCount})`); + } + } + } + return failures; +}; + +const substituteRunnerToken = (value, tokens) => String(value || '').replace(/\{([A-Za-z0-9_]+)\}/g, (_match, key) => { + const resolved = tokens[key]; + return resolved == null ? '' : String(resolved); +}); + +const resolveRunnerConfig = (entry, root, workDir, outJsonPath) => { + const runner = entry?.runner && typeof entry.runner === 'object' ? entry.runner : {}; + const tokens = { + root, + canaryRoot: resolveBenchRuntimeCanaryRoot(root), + workDir, + outJson: outJsonPath + }; + const cwd = path.resolve(root, substituteRunnerToken(runner.cwd || '.', tokens)); + const args = (Array.isArray(runner.args) ? runner.args : []).map((arg) => substituteRunnerToken(arg, tokens)); + const env = Object.fromEntries( + Object.entries(runner.env && typeof runner.env === 'object' ? runner.env : {}) + .map(([key, value]) => [key, substituteRunnerToken(value, tokens)]) + ); + if (runner.type === 'bench-language') { + return { + cmd: process.execPath, + args: [path.resolve(root, 'tools', 'bench', 'language-repos.js'), ...args], + cwd, + env + }; + } + return { + cmd: process.execPath, + args: [path.resolve(root, substituteRunnerToken(runner.script || '', tokens)), ...args], + cwd, + env + }; +}; + +export const runBenchRuntimeLiveCanary = async (entry, root = process.cwd()) => { + const environment = getBenchRuntimeEnvironmentSnapshot(entry); + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'poc-bench-canary-')); + const outJsonPath = path.join(tempDir, `${String(entry?.id || 'canary').replace(/[^a-z0-9_-]/gi, '_')}.json`); + const { cmd, args, cwd, env } = resolveRunnerConfig(entry, root, tempDir, outJsonPath); + const timeoutMs = Number.isFinite(Number(entry?.runner?.timeoutMs)) + ? Math.max(1, Math.floor(Number(entry.runner.timeoutMs))) + : DEFAULT_LIVE_CANARY_TIMEOUT_MS; + const stdoutChunks = []; + const stderrChunks = []; + + let result; + try { + result = await spawnSubprocess(cmd, args, { + cwd, + env: { + ...process.env, + ...env + }, + timeoutMs, + onStdout: (chunk) => stdoutChunks.push(String(chunk || '')), + onStderr: (chunk) => stderrChunks.push(String(chunk || '')) + }); + } catch (error) { + const stderr = stderrChunks.join(''); + const stdout = stdoutChunks.join(''); + await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + return { + id: String(entry?.id || '').trim() || null, + issue: Number.isFinite(Number(entry?.issue)) ? Number(entry.issue) : null, + status: BENCH_RUNTIME_LIVE_CANARY_STATUS.RUNNER_FAILED, + ok: false, + closureReady: false, + runner: { cmd, args, cwd, timeoutMs }, + environment, + stdout, + stderr, + error: error?.message || String(error) + }; + } + + const stdout = stdoutChunks.join(''); + const stderr = stderrChunks.join(''); + let payload = null; + if (fs.existsSync(outJsonPath)) { + payload = JSON.parse(await fsPromises.readFile(outJsonPath, 'utf8')); + } else { + const trimmed = stdout.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + payload = JSON.parse(trimmed); + } + } + const metrics = extractBenchRuntimeCanaryMetrics(payload || {}); + const current = evaluateBenchRuntimeCanaryContract(entry?.currentContract || {}, metrics); + const target = evaluateBenchRuntimeCanaryContract(entry?.targetContract || {}, metrics); + const regressionFailures = evaluateBenchRuntimeCanaryForbiddenRegressions(entry?.forbiddenRegressions || [], metrics); + let status = BENCH_RUNTIME_LIVE_CANARY_STATUS.INCONCLUSIVE; + if (regressionFailures.length) { + status = BENCH_RUNTIME_LIVE_CANARY_STATUS.REGRESSED; + } else if (target.ok) { + status = BENCH_RUNTIME_LIVE_CANARY_STATUS.TARGET_ACHIEVED; + } else if (current.ok) { + status = BENCH_RUNTIME_LIVE_CANARY_STATUS.BASELINE_CONFIRMED; + } + + await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + return { + schemaVersion: BENCH_RUNTIME_LIVE_CANARY_RESULT_SCHEMA_VERSION, + id: String(entry?.id || '').trim() || null, + issue: Number.isFinite(Number(entry?.issue)) ? Number(entry.issue) : null, + repo: String(entry?.repo || '').trim() || null, + profile: String(entry?.profile || '').trim() || null, + canaryKind: String(entry?.canaryKind || '').trim() || null, + benchmarkConfirmationLane: String(entry?.benchmarkConfirmationLane || '').trim() || null, + proofOfSimilarity: entry?.proofOfSimilarity || null, + environment, + runner: { + cmd, + args, + cwd, + timeoutMs, + exitCode: Number(result?.exitCode), + signal: result?.signal || null + }, + status, + ok: status !== BENCH_RUNTIME_LIVE_CANARY_STATUS.RUNNER_FAILED && current.ok, + closureReady: target.ok && regressionFailures.length === 0, + metrics, + current, + target, + regressionFailures, + stdout, + stderr + }; +}; + +export const buildBenchRuntimeLiveCanarySummary = (results, { requireTarget = false } = {}) => { + const rows = Array.isArray(results) ? results : []; + const countsByStatus = Object.fromEntries( + Object.values(BENCH_RUNTIME_LIVE_CANARY_STATUS) + .map((status) => [status, rows.filter((entry) => entry?.status === status).length]) + ); + const blockedIssues = Array.from(new Set( + rows + .filter((entry) => (requireTarget ? !entry?.closureReady : !entry?.ok)) + .map((entry) => Number(entry?.issue)) + .filter(Number.isFinite) + )).sort((left, right) => left - right); + const ok = rows.length > 0 && blockedIssues.length === 0; + const environmentFingerprints = Array.from(new Set( + rows + .map((entry) => String(entry?.environment?.fingerprint || '').trim()) + .filter(Boolean) + )).sort((left, right) => left.localeCompare(right)); + return { + schemaVersion: BENCH_RUNTIME_LIVE_CANARY_SUMMARY_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + requireTarget, + ok, + countsByStatus, + blockedIssues, + environmentFingerprints, + canaries: rows.map((entry) => ({ + id: entry?.id || null, + issue: entry?.issue || null, + repo: entry?.repo || null, + profile: entry?.profile || null, + status: entry?.status || null, + closureReady: entry?.closureReady === true, + benchmarkConfirmationLane: entry?.benchmarkConfirmationLane || null, + proofOfSimilarity: entry?.proofOfSimilarity || null, + environment: entry?.environment || null, + metrics: entry?.metrics || null, + currentFailures: entry?.current?.failures || [], + targetFailures: entry?.target?.failures || [], + regressionFailures: Array.isArray(entry?.regressionFailures) ? entry.regressionFailures : [] + })) + }; +}; + +export const buildBenchRuntimeBlockerConfirmationSummary = ({ + manifest, + tasks, + generatedAt = null, + runAggregateResultClass = null, + runEnvironmentFingerprint = null, + runLabel = null +}) => { + const liveCanaries = Array.isArray(manifest?.liveCanaries) ? manifest.liveCanaries : []; + const taskRows = Array.isArray(tasks) ? tasks : []; + const canaries = []; + for (const entry of liveCanaries) { + const repo = String(entry?.repo || '').trim(); + if (!repo) continue; + const task = taskRows.find((row) => String(row?.repo || '').trim() === repo); + if (!task) continue; + const metrics = extractBenchRuntimeTaskMetrics(task); + const target = evaluateBenchRuntimeCanaryContract(entry?.targetContract || {}, metrics); + const regressionFailures = evaluateBenchRuntimeCanaryForbiddenRegressions(entry?.forbiddenRegressions || [], metrics); + const taskStatus = task?.taskStatus || classifyBenchTask(task); + canaries.push({ + id: String(entry?.id || '').trim() || null, + issue: Number.isFinite(Number(entry?.issue)) ? Number(entry.issue) : null, + repo, + benchmarkConfirmationLane: parseBenchmarkConfirmationLaneSpec(entry?.benchmarkConfirmationLane), + runLabel: String(runLabel || '').trim() || null, + runGeneratedAt: String(generatedAt || '').trim() || null, + runAggregateResultClass: String(runAggregateResultClass || '').trim() || null, + runEnvironmentFingerprint: String(runEnvironmentFingerprint || '').trim() || null, + benchmarkConfirmed: target.ok && regressionFailures.length === 0, + taskStatus: { + resultClass: taskStatus?.resultClass || null, + primaryFailureClass: taskStatus?.primaryFailureClass || null, + degradationClasses: Array.isArray(taskStatus?.degradationClasses) ? taskStatus.degradationClasses : [] + }, + metrics, + targetFailures: target.failures, + regressionFailures + }); + } + return { + schemaVersion: BENCH_RUNTIME_BLOCKER_CONFIRMATION_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + runGeneratedAt: String(generatedAt || '').trim() || null, + runLabel: String(runLabel || '').trim() || null, + runAggregateResultClass: String(runAggregateResultClass || '').trim() || null, + runEnvironmentFingerprint: String(runEnvironmentFingerprint || '').trim() || null, + confirmationCount: canaries.length, + confirmedIssues: canaries + .filter((entry) => entry.benchmarkConfirmed) + .map((entry) => entry.issue) + .filter(Number.isFinite) + .sort((left, right) => left - right), + canaries + }; +}; + +export const buildBenchRuntimeBlockerClosureEvidence = ({ + liveSummary, + benchmarkConfirmations +}) => { + const canaryRows = Array.isArray(liveSummary?.canaries) ? liveSummary.canaries : []; + const confirmationSummaries = Array.isArray(benchmarkConfirmations) ? benchmarkConfirmations : []; + const blockers = canaryRows.map((entry) => { + const confirmations = confirmationSummaries.flatMap((summary) => ( + Array.isArray(summary?.canaries) + ? summary.canaries.filter((candidate) => candidate?.id === entry?.id) + : [] + )); + const benchmarkConfirmed = confirmations.some((candidate) => candidate?.benchmarkConfirmed === true); + const closureReady = entry?.closureReady === true && benchmarkConfirmed; + const closureFailures = []; + if (entry?.closureReady !== true) { + closureFailures.push('live canary target contract not yet satisfied'); + } + if (!benchmarkConfirmed) { + closureFailures.push('no benchmark confirmation report satisfied the target contract'); + } + return { + id: entry?.id || null, + issue: entry?.issue || null, + repo: entry?.repo || null, + benchmarkConfirmationLane: entry?.benchmarkConfirmationLane || null, + liveCanaryStatus: entry?.status || null, + liveCanaryClosureReady: entry?.closureReady === true, + benchmarkConfirmed, + closureReady, + environment: entry?.environment || null, + currentFailures: Array.isArray(entry?.currentFailures) ? entry.currentFailures : [], + targetFailures: Array.isArray(entry?.targetFailures) ? entry.targetFailures : [], + regressionFailures: Array.isArray(entry?.regressionFailures) ? entry.regressionFailures : [], + confirmations, + closureFailures + }; + }); + const blockedIssues = blockers + .filter((entry) => entry?.closureReady !== true) + .map((entry) => Number(entry?.issue)) + .filter(Number.isFinite) + .sort((left, right) => left - right); + return { + schemaVersion: BENCH_RUNTIME_BLOCKER_CLOSURE_EVIDENCE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + liveSummarySchemaVersion: Number(liveSummary?.schemaVersion) || null, + benchmarkConfirmationSchemaVersions: confirmationSummaries + .map((entry) => Number(entry?.schemaVersion)) + .filter(Number.isFinite), + ok: blockers.length > 0 && blockedIssues.length === 0, + blockedIssues, + blockers + }; +}; + +export const formatBenchRuntimeLiveCanarySummaryMarkdown = (summary) => { + const counts = summary?.countsByStatus && typeof summary.countsByStatus === 'object' + ? summary.countsByStatus + : {}; + const lines = [ + '# Bench Runtime Live Canary Summary', + '', + `- generated: ${String(summary?.generatedAt || '').trim() || 'unknown'}`, + `- require target: ${summary?.requireTarget === true ? 'yes' : 'no'}`, + `- ok: ${summary?.ok === true ? 'yes' : 'no'}`, + `- blocked issues: ${Array.isArray(summary?.blockedIssues) && summary.blockedIssues.length ? summary.blockedIssues.join(', ') : 'none'}`, + `- environment fingerprints: ${Array.isArray(summary?.environmentFingerprints) && summary.environmentFingerprints.length ? summary.environmentFingerprints.join(', ') : 'none'}`, + '', + '## Status counts', + '' + ]; + for (const [status, count] of Object.entries(counts)) { + lines.push(`- ${status}: ${count}`); + } + lines.push('', '## Canaries', ''); + for (const entry of Array.isArray(summary?.canaries) ? summary.canaries : []) { + lines.push(`- ${entry.id}: ${entry.status} (issue #${entry.issue}, lane ${entry.benchmarkConfirmationLane || 'unknown'})`); + if (entry?.environment?.mismatches?.length) { + lines.push(` environment mismatches: ${entry.environment.mismatches.join('; ')}`); + } + } + return `${lines.join('\n')}\n`; +}; + +export const formatBenchRuntimeBlockerClosureEvidenceMarkdown = (summary) => { + const lines = [ + '# Bench Runtime Blocker Closure Evidence', + '', + `- generated: ${String(summary?.generatedAt || '').trim() || 'unknown'}`, + `- ok: ${summary?.ok === true ? 'yes' : 'no'}`, + `- blocked issues: ${Array.isArray(summary?.blockedIssues) && summary.blockedIssues.length ? summary.blockedIssues.join(', ') : 'none'}`, + '', + '## Blockers', + '' + ]; + for (const entry of Array.isArray(summary?.blockers) ? summary.blockers : []) { + lines.push( + `- ${entry.id}: closure=${entry.closureReady === true ? 'ready' : 'blocked'} ` + + `(issue #${entry.issue}, benchmark=${entry.benchmarkConfirmed === true ? 'confirmed' : 'missing'})` + ); + for (const failure of Array.isArray(entry?.closureFailures) ? entry.closureFailures : []) { + lines.push(` - ${failure}`); + } + } + return `${lines.join('\n')}\n`; +}; diff --git a/tools/bench/language/cli.js b/tools/bench/language/cli.js index 18389320c..a6147840d 100644 --- a/tools/bench/language/cli.js +++ b/tools/bench/language/cli.js @@ -1,7 +1,9 @@ import path from 'node:path'; import { createCli } from '../../../src/shared/cli.js'; import { BENCH_OPTIONS, mergeCliOptions, validateBenchArgs } from '../../../src/shared/cli-options.js'; +import { normalizeLegacyCacheRootPath } from '../../../src/shared/cache-roots.js'; import { getCacheRoot, resolveToolRoot } from '../../shared/dict-utils.js'; +import { resolveBenchMode } from './policy.js'; export const BENCH_REPO_TIMEOUT_DEFAULT_MS = 30 * 60 * 1000; @@ -60,6 +62,11 @@ export const parseBenchLanguageArgs = (rawArgs = process.argv.slice(2)) => { root: { type: 'string' }, 'cache-root': { type: 'string' }, 'cache-suffix': { type: 'string' }, + mode: { type: 'string' }, + 'control-slice': { type: 'boolean', default: false }, + 'control-slice-max': { type: 'number' }, + 'corpus-version': { type: 'string' }, + 'waiver-file': { type: 'string' }, results: { type: 'string' }, log: { type: 'string' }, language: { type: 'string' }, @@ -75,7 +82,7 @@ export const parseBenchLanguageArgs = (rawArgs = process.argv.slice(2)) => { } ); const argv = createCli({ - scriptName: 'bench-language', + scriptName: 'pairofcleats bench language', options: benchOptions, argv: ['node', 'tools/bench/language-repos.js', ...(rawArgs || [])] }).parse(); @@ -85,12 +92,24 @@ export const parseBenchLanguageArgs = (rawArgs = process.argv.slice(2)) => { const runSuffix = buildRunSuffix(); const configPath = path.resolve(argv.config || path.join(scriptRoot, 'benchmarks', 'repos.json')); const reposRoot = path.resolve(argv.root || path.join(scriptRoot, 'benchmarks', 'repos')); - const cacheRootBase = path.resolve(argv['cache-root'] || path.join(getCacheRoot(), 'bench-language')); + const cacheRootInput = argv['cache-root'] || path.join(getCacheRoot(), 'bench-language'); + const cacheRootBase = normalizeLegacyCacheRootPath(cacheRootInput) || path.resolve(cacheRootInput); + const mode = resolveBenchMode(argv.mode); const cacheSuffixRaw = typeof argv['cache-suffix'] === 'string' ? argv['cache-suffix'].trim() : ''; const cacheRun = argv['cache-run'] === true; - const cacheSuffix = cacheSuffixRaw || (cacheRun ? runSuffix : ''); - const cacheRoot = cacheSuffix ? path.resolve(cacheRootBase, cacheSuffix) : cacheRootBase; + const cacheSuffix = cacheSuffixRaw || (cacheRun || mode === 'cold' ? runSuffix : ''); + const modeCacheRoot = ( + mode === 'tooling' + ? path.resolve(cacheRootBase, 'tooling') + : mode === 'reliability' + ? path.resolve(cacheRootBase, 'reliability') + : mode === 'cold' + ? path.resolve(cacheRootBase, 'cold') + : cacheRootBase + ); + const cacheRoot = cacheSuffix ? path.resolve(modeCacheRoot, cacheSuffix) : modeCacheRoot; const resultsRoot = path.resolve(argv.results || path.join(scriptRoot, 'benchmarks', 'results')); + const waiverFile = argv['waiver-file'] ? path.resolve(argv['waiver-file']) : null; const logRoot = path.join(resultsRoot, 'logs', 'bench-language'); const logPath = argv.log ? path.resolve(argv.log) @@ -124,10 +143,13 @@ export const parseBenchLanguageArgs = (rawArgs = process.argv.slice(2)) => { argv, scriptRoot, runSuffix, + mode, configPath, reposRoot, cacheRoot, resultsRoot, + corpusVersion: argv['corpus-version'] ? String(argv['corpus-version']).trim() : '', + waiverFile, logRoot, logPath, cloneEnabled, diff --git a/tools/bench/language/diagnostics.js b/tools/bench/language/diagnostics.js new file mode 100644 index 000000000..6ac1e95a0 --- /dev/null +++ b/tools/bench/language/diagnostics.js @@ -0,0 +1,16 @@ +export const sumDiagnosticCounts = (entry, key = 'countsByType') => { + const sources = [ + entry?.diagnostics?.process?.[key], + entry?.diagnostics?.[key] + ]; + const out = {}; + for (const source of sources) { + if (!source || typeof source !== 'object' || Array.isArray(source)) continue; + for (const [diagnosticKey, value] of Object.entries(source)) { + const count = Number(value); + if (!Number.isFinite(count) || count <= 0) continue; + out[diagnosticKey] = (out[diagnosticKey] || 0) + count; + } + } + return out; +}; diff --git a/tools/bench/language/diff.js b/tools/bench/language/diff.js new file mode 100644 index 000000000..73a624569 --- /dev/null +++ b/tools/bench/language/diff.js @@ -0,0 +1,166 @@ +import fsPromises from 'node:fs/promises'; +import { buildBenchOwnershipDiff, buildBenchReuseFromSummary } from './ownership.js'; + +export const BENCH_RUN_DIFF_SCHEMA_VERSION = 1; + +const toNumberOrNull = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const average = (values) => { + const list = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(Number(value))); + if (!list.length) return null; + return list.reduce((sum, value) => sum + Number(value), 0) / list.length; +}; + +const countByDiagnosticType = (entry, type) => { + const direct = Number(entry?.diagnostics?.process?.countsByType?.[type]); + if (Number.isFinite(direct)) return direct; + const nested = Number(entry?.diagnostics?.countsByType?.[type]); + return Number.isFinite(nested) ? nested : 0; +}; + +const summarizeTask = (entry, methodology = null) => { + const reuse = buildBenchReuseFromSummary({ + summary: entry?.summary || null, + methodology + }); + return { + buildIndexMs: toNumberOrNull(entry?.summary?.buildMs?.index), + crashCount: entry?.taskStatus?.resultClass === 'crashed' ? 1 : 0, + timeoutCount: entry?.taskStatus?.resultClass === 'timed_out' ? 1 : 0, + degradationCount: Array.isArray(entry?.taskStatus?.degradationClasses) + ? entry.taskStatus.degradationClasses.length + : 0, + artifactTailStallCount: countByDiagnosticType(entry, 'artifact_tail_stall'), + cacheHitRate: reuse?.overall?.averageHitRate ?? null, + coldStartHitRate: reuse?.coldStart?.averageHitRate ?? null, + intraRunHitRate: reuse?.intraRun?.averageHitRate ?? null, + crossRunHitRate: reuse?.crossRun?.averageHitRate ?? null, + sqliteRssMb: average( + ['sqlite', 'sqlite-fts', 'fts'] + .map((backend) => entry?.summary?.memoryRss?.[backend]?.mean) + .map(toNumberOrNull) + .filter(Number.isFinite) + .map((value) => value / (1024 * 1024)) + ) + }; +}; + +const buildTaskKey = (entry) => [ + String(entry?.language || 'unknown'), + String(entry?.tier || 'unknown'), + String(entry?.repo || 'unknown') +].join(':'); + +const buildTaskMap = (report) => { + const out = new Map(); + for (const entry of Array.isArray(report?.tasks) ? report.tasks : []) { + out.set(buildTaskKey(entry), entry); + } + return out; +}; + +const buildAggregateDelta = (beforeValue, afterValue) => { + const before = toNumberOrNull(beforeValue); + const after = toNumberOrNull(afterValue); + if (before == null && after == null) return null; + const delta = before != null && after != null + ? Number((after - before).toFixed(6)) + : null; + return { + before, + after, + delta + }; +}; + +const summarizeLanguageGroup = (tasks, methodology = null) => { + const rows = (Array.isArray(tasks) ? tasks : []).map((entry) => summarizeTask(entry, methodology)); + return { + repoCount: rows.length, + buildIndexMs: average(rows.map((row) => row.buildIndexMs)), + crashCount: rows.reduce((sum, row) => sum + row.crashCount, 0), + timeoutCount: rows.reduce((sum, row) => sum + row.timeoutCount, 0), + degradationCount: rows.reduce((sum, row) => sum + row.degradationCount, 0), + artifactTailStallCount: rows.reduce((sum, row) => sum + row.artifactTailStallCount, 0), + cacheHitRate: average(rows.map((row) => row.cacheHitRate)), + coldStartHitRate: average(rows.map((row) => row.coldStartHitRate)), + intraRunHitRate: average(rows.map((row) => row.intraRunHitRate)), + crossRunHitRate: average(rows.map((row) => row.crossRunHitRate)), + sqliteRssMb: average(rows.map((row) => row.sqliteRssMb)) + }; +}; + +const buildSummaryDeltaFields = (beforeSummary, afterSummary) => ({ + buildIndexMs: buildAggregateDelta(beforeSummary.buildIndexMs, afterSummary.buildIndexMs), + crashCount: buildAggregateDelta(beforeSummary.crashCount, afterSummary.crashCount), + timeoutCount: buildAggregateDelta(beforeSummary.timeoutCount, afterSummary.timeoutCount), + degradationCount: buildAggregateDelta(beforeSummary.degradationCount, afterSummary.degradationCount), + artifactTailStallCount: buildAggregateDelta( + beforeSummary.artifactTailStallCount, + afterSummary.artifactTailStallCount + ), + cacheHitRate: buildAggregateDelta(beforeSummary.cacheHitRate, afterSummary.cacheHitRate), + coldStartHitRate: buildAggregateDelta(beforeSummary.coldStartHitRate, afterSummary.coldStartHitRate), + intraRunHitRate: buildAggregateDelta(beforeSummary.intraRunHitRate, afterSummary.intraRunHitRate), + crossRunHitRate: buildAggregateDelta(beforeSummary.crossRunHitRate, afterSummary.crossRunHitRate), + sqliteRssMb: buildAggregateDelta(beforeSummary.sqliteRssMb, afterSummary.sqliteRssMb) +}); + +export const buildBenchRunDiff = ({ before, after }) => { + const beforeTaskMap = buildTaskMap(before); + const afterTaskMap = buildTaskMap(after); + const taskKeys = Array.from(new Set([...beforeTaskMap.keys(), ...afterTaskMap.keys()])).sort(); + const byRepo = taskKeys.map((key) => { + const beforeEntry = beforeTaskMap.get(key) || null; + const afterEntry = afterTaskMap.get(key) || null; + const beforeSummary = summarizeTask(beforeEntry, before?.methodology || null); + const afterSummary = summarizeTask(afterEntry, after?.methodology || null); + return { + taskKey: key, + language: beforeEntry?.language || afterEntry?.language || null, + tier: beforeEntry?.tier || afterEntry?.tier || null, + repo: beforeEntry?.repo || afterEntry?.repo || null, + ...buildSummaryDeltaFields(beforeSummary, afterSummary) + }; + }); + + const languageSet = new Set([ + ...Array.from(beforeTaskMap.values()).map((entry) => entry.language), + ...Array.from(afterTaskMap.values()).map((entry) => entry.language) + ]); + const byLanguage = Array.from(languageSet) + .filter(Boolean) + .sort((left, right) => String(left).localeCompare(String(right))) + .map((language) => { + const beforeTasks = Array.from(beforeTaskMap.values()).filter((entry) => entry.language === language); + const afterTasks = Array.from(afterTaskMap.values()).filter((entry) => entry.language === language); + const beforeSummary = summarizeLanguageGroup(beforeTasks, before?.methodology || null); + const afterSummary = summarizeLanguageGroup(afterTasks, after?.methodology || null); + return { + language, + repoCount: buildAggregateDelta(beforeSummary.repoCount, afterSummary.repoCount), + ...buildSummaryDeltaFields(beforeSummary, afterSummary) + }; + }); + + return { + schemaVersion: BENCH_RUN_DIFF_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + before: { + generatedAt: before?.generatedAt || null, + methodology: before?.methodology || null + }, + after: { + generatedAt: after?.generatedAt || null, + methodology: after?.methodology || null + }, + ownership: buildBenchOwnershipDiff({ before, after }), + byLanguage, + byRepo + }; +}; + +export const loadBenchRunReport = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8')); diff --git a/tools/bench/language/logging.js b/tools/bench/language/logging.js index 6b70bcd87..f05fe62c5 100644 --- a/tools/bench/language/logging.js +++ b/tools/bench/language/logging.js @@ -1,21 +1,60 @@ import crypto from 'node:crypto'; -import { log, logError } from '../../../src/shared/progress.js'; +import { log, logError } from '../../../src/shared/progress-runtime.js'; +import { + normalizeReuseSource, + normalizeReuseSurface, + resolveQualityImpactForCause, + resolveScmFallbackCause +} from '../../../src/shared/reuse-diagnostics.js'; const ENV_METADATA_KEYS = Object.freeze([ 'NODE_OPTIONS', + 'ORG_GRADLE_DAEMON', + 'GRADLE_OPTS', 'PAIROFCLEATS_TESTING', 'PAIROFCLEATS_TEST_CONFIG', 'PAIROFCLEATS_CACHE_ROOT', 'PAIROFCLEATS_CRASH_LOG_ANNOUNCE' ]); -export const BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION = 1; +export const BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION = 2; export const BENCH_DIAGNOSTIC_EVENT_TYPES = Object.freeze([ 'parser_crash', 'scm_timeout', 'queue_delay_hotspot', 'artifact_tail_stall', - 'fallback_used' + 'runtime_timeout_budget_extended', + 'runtime_timeout', + 'fallback_used', + 'warning_suppressed', + 'provider_preflight_start', + 'provider_preflight_finish', + 'provider_preflight_blocked', + 'provider_request_timeout', + 'provider_request_failed', + 'provider_circuit_breaker', + 'provider_degraded_mode_entered', + 'provider_degraded_mode_cleared', + 'workspace_partition_decision' +]); +export const BENCH_DIAGNOSTIC_PARITY_EVENT_TYPES = Object.freeze([ + 'fallback_used', + 'warning_suppressed', + 'provider_preflight_blocked', + 'provider_request_timeout', + 'provider_request_failed', + 'provider_circuit_breaker', + 'provider_degraded_mode_entered', + 'workspace_partition_decision' +]); +export const BENCH_DIAGNOSTIC_MATERIAL_PARITY_EVENT_TYPES = Object.freeze([ + 'fallback_used', + 'warning_suppressed', + 'provider_preflight_blocked', + 'provider_request_timeout', + 'provider_request_failed', + 'provider_circuit_breaker', + 'provider_degraded_mode_entered' ]); export const BENCH_PROGRESS_CONFIDENCE_SCHEMA_VERSION = 1; export const BENCH_PROGRESS_CONFIDENCE_THRESHOLDS = Object.freeze({ @@ -30,6 +69,745 @@ export const BENCH_PROGRESS_CONFIDENCE_COMPONENT_WEIGHTS = Object.freeze({ }); const BENCH_DIAGNOSTIC_EVENT_TYPE_SET = new Set(BENCH_DIAGNOSTIC_EVENT_TYPES); +const TOOLING_PREFLIGHT_EVENT_STATE_BY_EVENT = Object.freeze({ + start: 'running', + queued: 'queued', + dequeued: 'running', + ok: 'ready', + cache_hit: 'ready', + blocked: 'blocked', + degraded: 'degraded', + failed: 'failed', + timeout: 'degraded', + teardown_timeout: 'failed', + teardown_failed: 'failed', + teardown_abort: 'failed', + teardown_force_cleanup: 'failed' +}); +const TOOLING_REQUEST_METHOD_BY_STAGE = Object.freeze({ + documentsymbol: 'textDocument/documentSymbol', + hover: 'textDocument/hover', + semantic_tokens: 'textDocument/semanticTokens/full', + signature_help: 'textDocument/signatureHelp', + inlay_hints: 'textDocument/inlayHint', + definition: 'textDocument/definition', + type_definition: 'textDocument/typeDefinition', + references: 'textDocument/references' +}); +const TOOLING_PREFLIGHT_START_PATTERN = /\[tooling\]\s+preflight:start\s+provider=(?[^\s]+)\s+id=(?[^\s]+)(?.*)$/iu; +const TOOLING_PREFLIGHT_FINISH_PATTERN = /\[tooling\]\s+preflight:(?cache_hit|ok|blocked|degraded|failed|timeout)\s+provider=(?[^\s]+)\s+id=(?[^\s]+)(?.*)$/iu; +const TOOLING_REQUEST_SIGNAL_PATTERN = /\[tooling\]\s+request:(?timeout|failed)\s+provider=(?[^\s]+)\s+method=(?[^\s]+)(?.*)$/iu; +const TOOLING_CIRCUIT_BREAKER_PATTERN = /\[tooling\]\s+(?[^\s]+)\s+circuit breaker tripped\./iu; +const TOOLING_DEGRADED_ENTER_PATTERN = /\[tooling\]\s+(?[^\s]+)\s+degraded mode active \(fail-open\)\./iu; +const TOOLING_DEGRADED_CLEAR_PATTERN = /\[tooling\]\s+(?[^\s]+)\s+degraded mode cleared\./iu; +const TOOLING_WORKSPACE_PARTITION_PATTERN = /\[tooling\]\s+workspace:partition\s+provider=(?[^\s]+)(?.*)$/iu; +const TOOLING_WARNING_SUPPRESSED_PATTERN = /\[tooling\]\s+(?[^\s]+)\s+suppressed\s+(?\d+)\s+(?.+?)\s+stderr line\(s\)(?.*)$/iu; +const IMPORT_WARNING_SUPPRESSED_POLICY_PATTERN = /\[imports\]\s+all captured unresolved samples were suppressed by live policy\s+\((?\d+)\)\./iu; +const IMPORT_WARNING_SUPPRESSION_EVENT_PATTERN = /\[imports\]\s+suppression:\s+policy=(?[^\s]+)\s+count=(?\d+)\s+degraded=(?[01])\s+visible=(?\d+)\s+total=(?\d+)\s+actionable=(?\d+)\s+omittedFailureCauses=(?[^\s]+)$/iu; +const IMPORT_WARNING_SUPPRESSED_COUNT_PATTERN = /\[imports\]\s+suppressed\s+(?\d+)\s+import resolution warnings\./iu; +const SCM_FILE_META_SNAPSHOT_PATTERN = /\[scm\]\s+file-meta snapshot:\s+source=(?[^\s]+)\s+requested=(?\d+)\s+reused=(?\d+)\s+fetched=(?\d+)\.(?.*)$/iu; +const TOOLING_PROVIDER_DONE_PATTERN = /\[tooling\]\s+provider\s+\d+\/\d+\s+done\s+id=(?[^\s]+)\s+outcome=(?[^\s]+)\s+source=(?[^\s]+)\s+chunks=(?\d+)\s+elapsedMs=(?\d+)\./iu; +const TOOLING_CACHE_SKIPPED_PATTERN = /\[tooling\]\s+provider cache skipped for\s+(?[^\s:]+):\s+oversized\s+\((?\d+)\s+bytes\)\./iu; +const TOOLING_CACHE_READ_FAILED_PATTERN = /\[tooling\]\s+provider cache read failed for\s+(?[^\s;]+);\s+using live run\./iu; +const TOOLING_CACHE_WRITE_FAILED_PATTERN = /\[tooling\]\s+provider cache write failed for\s+(?[^\s.]+)\./iu; +const TOOLING_FIELD_PATTERN = /([a-zA-Z][a-zA-Z0-9_]*)=("([^"]*)"|[^\s]+)/gu; + +export const BENCH_DIAGNOSTIC_SEVERITY_LEVELS = Object.freeze([ + 'info', + 'warn', + 'error' +]); +const BENCH_DIAGNOSTIC_SEVERITY_RANK = Object.freeze({ + info: 0, + warn: 1, + error: 2 +}); + +const normalizeDiagnosticField = (value, maxLength = 160) => ( + normalizeBenchDiagnosticText(value, { maxLength }) +); + +const parseToolingFields = (raw) => { + const out = Object.create(null); + const text = String(raw || ''); + for (const match of text.matchAll(TOOLING_FIELD_PATTERN)) { + const key = String(match[1] || '').trim(); + const rawValue = String(match[3] ?? match[2] ?? '').trim(); + if (!key || !rawValue) continue; + out[key.toLowerCase()] = rawValue; + } + return out; +}; + +const parseRustWorkspaceSuppressionCounts = (message) => ({ + repoInvalidity: toNonNegativeCount(/\brepo-invalidity=(\d+)/iu.exec(String(message || ''))?.[1]) || 0, + toolchainNoise: toNonNegativeCount(/\btoolchain-noise=(\d+)/iu.exec(String(message || ''))?.[1]) || 0 +}); + +const shouldEmitToolingWarningSuppressed = ({ providerId = null, failureClass = null, message = '' } = {}) => { + const normalizedProviderId = String(providerId || '').trim().toLowerCase(); + const normalizedFailureClass = String(failureClass || '').trim().toLowerCase(); + if (normalizedProviderId === 'rust-analyzer' && normalizedFailureClass === 'stderr:duplicate workspace') { + const counts = parseRustWorkspaceSuppressionCounts(message); + if (counts.repoInvalidity <= 0 && counts.toolchainNoise > 0) { + return false; + } + } + return true; +}; + +const shouldEmitImportSuppressionSignal = ({ + policy = null, + degradedRun = false, + actionableCount = 0 +} = {}) => { + const normalizedPolicy = String(policy || '').trim().toLowerCase(); + if (normalizedPolicy !== 'live') return true; + return degradedRun === true || (Number(actionableCount) || 0) > 0; +}; + +const toPositiveCount = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.max(1, Math.floor(parsed)) : null; +}; + +const toNonNegativeCount = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.max(0, Math.floor(parsed)) : null; +}; + +const normalizeWorkspacePartitionValue = (value) => { + const text = String(value || '').trim(); + if (!text) return null; + return text; +}; + +const parseScmSnapshotObservation = (text) => { + const match = SCM_FILE_META_SNAPSHOT_PATTERN.exec(text); + if (!match) return null; + const source = normalizeReuseSource(match.groups?.source) || 'unknown'; + const requested = toNonNegativeCount(match.groups?.requested); + const reused = toNonNegativeCount(match.groups?.reused); + const fetched = toNonNegativeCount(match.groups?.fetched); + const fields = parseToolingFields(match.groups?.rest || ''); + const timeoutCount = toNonNegativeCount(fields.timeoutcount) || 0; + const timeoutRetries = toNonNegativeCount(fields.timeoutretries) || 0; + const cooldownSkips = toNonNegativeCount(fields.cooldownskips) || 0; + const unavailableChunks = toNonNegativeCount(fields.unavailablechunks) || 0; + const elapsedMs = toNonNegativeCount(fields.elapsedms); + const causeClass = resolveScmFallbackCause({ + source, + timeoutCount, + cooldownSkips, + unavailableChunks + }); + return { + kind: 'scm_snapshot', + reuseSurface: 'scm-derived', + reuseSource: source, + causeClass, + qualityImpact: resolveQualityImpactForCause(causeClass), + requestedCount: requested, + reusedCount: reused, + fetchedCount: fetched, + timeCostMs: elapsedMs, + timeoutCount, + timeoutRetries, + cooldownSkips, + unavailableChunks + }; +}; + +const parseToolingProviderDoneObservation = (text) => { + const match = TOOLING_PROVIDER_DONE_PATTERN.exec(text); + if (!match) return null; + const providerId = String(match.groups?.providerId || '').trim() || null; + const source = normalizeReuseSource(match.groups?.source) || 'unknown'; + const chunks = toNonNegativeCount(match.groups?.chunks); + const elapsedMs = toNonNegativeCount(match.groups?.elapsedMs); + const outcome = String(match.groups?.outcome || '').trim().toLowerCase() || null; + const causeClass = source === 'cache' + ? 'cache_hit' + : (source === 'live' ? 'cache_miss' : source); + return { + kind: 'provider_result', + reuseSurface: 'provider-result', + reuseSource: source, + providerId, + causeClass, + qualityImpact: resolveQualityImpactForCause(causeClass), + chunkCount: chunks, + timeCostMs: elapsedMs, + outcome + }; +}; + +const parseToolingCacheObservation = (text) => { + const skippedMatch = TOOLING_CACHE_SKIPPED_PATTERN.exec(text); + if (skippedMatch) { + return { + kind: 'provider_cache', + reuseSurface: 'provider-result', + reuseSource: 'live', + providerId: String(skippedMatch.groups?.providerId || '').trim() || null, + causeClass: 'cache_invalid', + qualityImpact: resolveQualityImpactForCause('cache_invalid'), + sizeBytes: toNonNegativeCount(skippedMatch.groups?.sizeBytes) + }; + } + const readFailedMatch = TOOLING_CACHE_READ_FAILED_PATTERN.exec(text); + if (readFailedMatch) { + return { + kind: 'provider_cache', + reuseSurface: 'provider-result', + reuseSource: 'live', + providerId: String(readFailedMatch.groups?.providerId || '').trim() || null, + causeClass: 'cache_invalid', + qualityImpact: resolveQualityImpactForCause('cache_invalid') + }; + } + const writeFailedMatch = TOOLING_CACHE_WRITE_FAILED_PATTERN.exec(text); + if (writeFailedMatch) { + return { + kind: 'provider_cache', + reuseSurface: 'provider-result', + reuseSource: 'write-failed', + providerId: String(writeFailedMatch.groups?.providerId || '').trim() || null, + causeClass: 'cache_write_failed', + qualityImpact: 'future-reuse-risk' + }; + } + return null; +}; + +export const parseBenchReuseObservation = (line = '') => { + const text = String(line || '').trim(); + if (!text) return null; + return parseScmSnapshotObservation(text) + || parseToolingCacheObservation(text) + || parseToolingProviderDoneObservation(text) + || null; +}; + +const buildDiagnosticSignal = ({ + eventType, + message, + source = 'stream', + providerId = null, + workspacePartition = null, + requestMethod = null, + failureClass = null, + preflightId = null, + preflightClass = null, + preflightState = null, + reuseSurface = null, + reuseSource = null, + qualityImpact = null, + timeCostMs = null, + requestedCount = null, + reusedCount = null, + fetchedCount = null, + chunkCount = null, + timeoutKind = null, + phase = null, + resourceClass = null, + failureMode = null, + decisionReason = null, + outcome = null, + effectiveBudgetMs = null, + skippedWork = null, + partialSuccess = null, + suppressedCount = null, + suppressionPolicy = null, + omittedSampleClasses = null, + degradedRun = null, + visibleSampleCount = null, + actionableCount = null, + totalCount = null, + stage = null, + taskId = null, + level = null, + severity = null +} = {}) => { + if (!isBenchDiagnosticEventType(eventType)) return null; + return { + eventType, + message: String(message || '').trim(), + source: String(source || 'stream').trim() || 'stream', + providerId: String(providerId || '').trim() || null, + workspacePartition: normalizeWorkspacePartitionValue(workspacePartition), + requestMethod: String(requestMethod || '').trim() || null, + failureClass: String(failureClass || '').trim() || null, + preflightId: String(preflightId || '').trim() || null, + preflightClass: String(preflightClass || '').trim() || null, + preflightState: String(preflightState || '').trim() || null, + reuseSurface: normalizeReuseSurface(reuseSurface), + reuseSource: normalizeReuseSource(reuseSource), + qualityImpact: String(qualityImpact || '').trim().toLowerCase() || null, + timeCostMs: toNonNegativeCount(timeCostMs), + requestedCount: toNonNegativeCount(requestedCount), + reusedCount: toNonNegativeCount(reusedCount), + fetchedCount: toNonNegativeCount(fetchedCount), + chunkCount: toNonNegativeCount(chunkCount), + timeoutKind: String(timeoutKind || '').trim() || null, + phase: String(phase || '').trim() || null, + resourceClass: String(resourceClass || '').trim() || null, + failureMode: String(failureMode || '').trim() || null, + decisionReason: String(decisionReason || '').trim() || null, + outcome: String(outcome || '').trim() || null, + effectiveBudgetMs: toNonNegativeCount(effectiveBudgetMs), + skippedWork: Array.isArray(skippedWork) + ? skippedWork.map((entry) => String(entry || '').trim()).filter(Boolean) + : null, + partialSuccess: typeof partialSuccess === 'boolean' ? partialSuccess : null, + suppressedCount: toNonNegativeCount(suppressedCount), + suppressionPolicy: String(suppressionPolicy || '').trim() || null, + omittedSampleClasses: Array.isArray(omittedSampleClasses) + ? omittedSampleClasses.map((entry) => String(entry || '').trim()).filter(Boolean) + : null, + degradedRun: typeof degradedRun === 'boolean' ? degradedRun : null, + visibleSampleCount: toNonNegativeCount(visibleSampleCount), + actionableCount: toNonNegativeCount(actionableCount), + totalCount: toNonNegativeCount(totalCount), + stage: String(stage || '').trim() || null, + taskId: String(taskId || '').trim() || null, + level: String(level || '').trim() || null, + severity: normalizeBenchDiagnosticSeverity(severity) + }; +}; + +export const normalizeBenchDiagnosticSeverity = (value, fallback = 'info') => { + const text = String(value || '').trim().toLowerCase(); + if (text && Object.prototype.hasOwnProperty.call(BENCH_DIAGNOSTIC_SEVERITY_RANK, text)) return text; + return String(fallback || 'info').trim().toLowerCase(); +}; + +export const resolveBenchDiagnosticSeverity = ({ + eventType, + failureClass = null, + preflightState = null +} = {}) => { + const type = String(eventType || '').trim(); + const failure = String(failureClass || '').trim().toLowerCase(); + const state = String(preflightState || '').trim().toLowerCase(); + switch (type) { + case 'parser_crash': + case 'runtime_timeout': + return 'error'; + case 'provider_preflight_start': + case 'provider_degraded_mode_cleared': + case 'runtime_timeout_budget_extended': + return 'info'; + case 'provider_preflight_finish': + if (state === 'ready' || failure === 'ready') return 'info'; + return 'warn'; + case 'scm_timeout': + case 'queue_delay_hotspot': + case 'artifact_tail_stall': + case 'fallback_used': + case 'warning_suppressed': + case 'provider_preflight_blocked': + case 'provider_request_timeout': + case 'provider_request_failed': + case 'provider_circuit_breaker': + case 'provider_degraded_mode_entered': + case 'workspace_partition_decision': + return 'warn'; + default: + return 'info'; + } +}; + +export const createBenchDiagnosticClassifier = () => { + const preflightByKey = new Map(); + + const classifyStructuredDiagnostics = ({ + event = null, + source = 'stream' + } = {}) => { + const candidates = Array.isArray(event?.benchDiagnostics) + ? event.benchDiagnostics + : (event?.benchDiagnostic && typeof event.benchDiagnostic === 'object' && !Array.isArray(event.benchDiagnostic) + ? [event.benchDiagnostic] + : []); + if (!candidates.length) return []; + const signals = []; + for (const entry of candidates) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const signal = buildDiagnosticSignal({ + eventType: entry.eventType, + message: entry.message || event?.message || '', + source: entry.source || source, + providerId: entry.providerId || event?.providerId || null, + workspacePartition: entry.workspacePartition || null, + requestMethod: entry.requestMethod || null, + failureClass: entry.failureClass || null, + preflightId: entry.preflightId || null, + preflightClass: entry.preflightClass || null, + preflightState: entry.preflightState || null, + reuseSurface: entry.reuseSurface || null, + reuseSource: entry.reuseSource || null, + qualityImpact: entry.qualityImpact || null, + timeCostMs: entry.timeCostMs, + requestedCount: entry.requestedCount, + reusedCount: entry.reusedCount, + fetchedCount: entry.fetchedCount, + chunkCount: entry.chunkCount, + timeoutKind: entry.timeoutKind || null, + phase: entry.phase || null, + resourceClass: entry.resourceClass || null, + failureMode: entry.failureMode || null, + decisionReason: entry.decisionReason || null, + outcome: entry.outcome || null, + effectiveBudgetMs: entry.effectiveBudgetMs, + skippedWork: entry.skippedWork || null, + partialSuccess: entry.partialSuccess ?? null, + suppressedCount: entry.suppressedCount, + suppressionPolicy: entry.suppressionPolicy, + omittedSampleClasses: entry.omittedSampleClasses, + degradedRun: entry.degradedRun ?? null, + visibleSampleCount: entry.visibleSampleCount, + actionableCount: entry.actionableCount, + totalCount: entry.totalCount, + stage: entry.stage ?? event?.stage ?? null, + taskId: entry.taskId ?? event?.taskId ?? null, + level: entry.level ?? event?.level ?? null, + severity: normalizeBenchDiagnosticSeverity( + entry.severity, + resolveBenchDiagnosticSeverity({ + eventType: entry.eventType, + failureClass: entry.failureClass || null, + preflightState: entry.preflightState || null + }) + ) + }); + if (signal) signals.push(signal); + } + return signals; + }; + + const classify = ({ + line = '', + event = null, + source = 'stream' + } = {}) => { + const text = String( + event && typeof event.message === 'string' && event.message.trim() + ? event.message + : line + ).trim(); + if (!text) return []; + const signals = []; + const structuredSignals = classifyStructuredDiagnostics({ event, source }); + if (structuredSignals.length) return structuredSignals; + + const preflightStartMatch = TOOLING_PREFLIGHT_START_PATTERN.exec(text); + if (preflightStartMatch) { + const providerId = String(preflightStartMatch.groups?.providerId || '').trim(); + const preflightId = String(preflightStartMatch.groups?.preflightId || '').trim(); + const fields = parseToolingFields(preflightStartMatch.groups?.rest || ''); + const preflightClass = String(fields.class || '').trim() || null; + if (providerId && preflightId) { + preflightByKey.set(`${providerId}|${preflightId}`, { + preflightClass + }); + } + const signal = buildDiagnosticSignal({ + eventType: 'provider_preflight_start', + message: text, + source, + providerId, + preflightId, + preflightClass, + preflightState: TOOLING_PREFLIGHT_EVENT_STATE_BY_EVENT.start, + failureClass: 'start', + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_preflight_start', + failureClass: 'start', + preflightState: TOOLING_PREFLIGHT_EVENT_STATE_BY_EVENT.start + }) + }); + return signal ? [signal] : []; + } + + const preflightFinishMatch = TOOLING_PREFLIGHT_FINISH_PATTERN.exec(text); + if (preflightFinishMatch) { + const providerId = String(preflightFinishMatch.groups?.providerId || '').trim(); + const preflightId = String(preflightFinishMatch.groups?.preflightId || '').trim(); + const eventName = String(preflightFinishMatch.groups?.event || '').trim().toLowerCase(); + const fields = parseToolingFields(preflightFinishMatch.groups?.rest || ''); + const cached = preflightByKey.get(`${providerId}|${preflightId}`) || null; + const preflightClass = String(fields.class || cached?.preflightClass || '').trim() || null; + const preflightState = String( + fields.state || TOOLING_PREFLIGHT_EVENT_STATE_BY_EVENT[eventName] || '' + ).trim().toLowerCase() || null; + const failureClass = eventName === 'ok' || preflightState === 'ready' + ? 'ready' + : eventName; + const finishSignal = buildDiagnosticSignal({ + eventType: 'provider_preflight_finish', + message: text, + source, + providerId, + preflightId, + preflightClass, + preflightState, + failureClass, + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_preflight_finish', + failureClass, + preflightState + }) + }); + if (finishSignal) signals.push(finishSignal); + if (preflightState === 'blocked' || failureClass === 'blocked') { + const blockedSignal = buildDiagnosticSignal({ + eventType: 'provider_preflight_blocked', + message: text, + source, + providerId, + preflightId, + preflightClass, + preflightState, + failureClass: 'blocked', + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_preflight_blocked', + failureClass: 'blocked', + preflightState + }) + }); + if (blockedSignal) signals.push(blockedSignal); + } + return signals; + } + + const requestMatch = TOOLING_REQUEST_SIGNAL_PATTERN.exec(text); + if (requestMatch) { + const providerId = String(requestMatch.groups?.providerId || '').trim(); + const requestMethod = String(requestMatch.groups?.requestMethod || '').trim(); + const kind = String(requestMatch.groups?.kind || '').trim().toLowerCase(); + const fields = parseToolingFields(requestMatch.groups?.rest || ''); + const stageName = String(fields.stage || '').trim().toLowerCase(); + const requestStageMethod = stageName ? TOOLING_REQUEST_METHOD_BY_STAGE[stageName] : null; + const signal = buildDiagnosticSignal({ + eventType: kind === 'timeout' ? 'provider_request_timeout' : 'provider_request_failed', + message: text, + source, + providerId, + requestMethod: requestMethod || requestStageMethod || null, + workspacePartition: fields.workspacepartition || null, + failureClass: String(fields.class || kind).trim() || kind, + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: kind === 'timeout' ? 'provider_request_timeout' : 'provider_request_failed', + failureClass: String(fields.class || kind).trim() || kind + }) + }); + return signal ? [signal] : []; + } + + const circuitMatch = TOOLING_CIRCUIT_BREAKER_PATTERN.exec(text); + if (circuitMatch) { + const signal = buildDiagnosticSignal({ + eventType: 'provider_circuit_breaker', + message: text, + source, + providerId: circuitMatch.groups?.providerId || null, + failureClass: 'circuit_breaker', + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_circuit_breaker', + failureClass: 'circuit_breaker' + }) + }); + return signal ? [signal] : []; + } + + const degradedEnterMatch = TOOLING_DEGRADED_ENTER_PATTERN.exec(text); + if (degradedEnterMatch) { + const signal = buildDiagnosticSignal({ + eventType: 'provider_degraded_mode_entered', + message: text, + source, + providerId: degradedEnterMatch.groups?.providerId || null, + failureClass: 'fail_open', + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_degraded_mode_entered', + failureClass: 'fail_open' + }) + }); + return signal ? [signal] : []; + } + + const degradedClearMatch = TOOLING_DEGRADED_CLEAR_PATTERN.exec(text); + if (degradedClearMatch) { + const signal = buildDiagnosticSignal({ + eventType: 'provider_degraded_mode_cleared', + message: text, + source, + providerId: degradedClearMatch.groups?.providerId || null, + failureClass: 'recovered', + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'provider_degraded_mode_cleared', + failureClass: 'recovered' + }) + }); + return signal ? [signal] : []; + } + + const workspaceMatch = TOOLING_WORKSPACE_PARTITION_PATTERN.exec(text); + if (workspaceMatch) { + const providerId = String(workspaceMatch.groups?.providerId || '').trim(); + const fields = parseToolingFields(workspaceMatch.groups?.rest || ''); + const signal = buildDiagnosticSignal({ + eventType: 'workspace_partition_decision', + message: text, + source, + providerId, + workspacePartition: fields.workspacepartition || null, + failureClass: String(fields.reason || fields.state || '').trim() || null, + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'workspace_partition_decision', + failureClass: String(fields.reason || fields.state || '').trim() || null + }) + }); + return signal ? [signal] : []; + } + + const toolingWarningSuppressedMatch = TOOLING_WARNING_SUPPRESSED_PATTERN.exec(text); + if (toolingWarningSuppressedMatch) { + const providerId = String(toolingWarningSuppressedMatch.groups?.providerId || '').trim(); + const count = String(toolingWarningSuppressedMatch.groups?.count || '').trim(); + const kind = normalizeDiagnosticField(toolingWarningSuppressedMatch.groups?.kind || '', 96) || 'stderr'; + const failureClass = `stderr:${kind}`; + if (!shouldEmitToolingWarningSuppressed({ + providerId, + failureClass, + message: text + })) { + return []; + } + const signal = buildDiagnosticSignal({ + eventType: 'warning_suppressed', + message: text, + source, + providerId, + failureClass, + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'warning_suppressed', + failureClass: `${failureClass}:${count}` + }) + }); + return signal ? [signal] : []; + } + + const importPolicySuppressedMatch = IMPORT_WARNING_SUPPRESSED_POLICY_PATTERN.exec(text); + if (importPolicySuppressedMatch) { + return []; + } + + const importSuppressionEventMatch = IMPORT_WARNING_SUPPRESSION_EVENT_PATTERN.exec(text); + if (importSuppressionEventMatch) { + const count = String(importSuppressionEventMatch.groups?.count || '').trim(); + const policy = String(importSuppressionEventMatch.groups?.policy || '').trim().toLowerCase() || 'unknown'; + const omittedSampleClasses = String(importSuppressionEventMatch.groups?.failureCauses || '') + .split(',') + .map((entry) => normalizeDiagnosticField(entry, 48)) + .filter(Boolean); + const degradedRun = String(importSuppressionEventMatch.groups?.degraded || '') === '1'; + const actionableCount = Number(importSuppressionEventMatch.groups?.actionable || 0); + if (!shouldEmitImportSuppressionSignal({ + policy, + degradedRun, + actionableCount + })) { + return []; + } + const signal = buildDiagnosticSignal({ + eventType: 'warning_suppressed', + message: text, + source, + failureClass: `imports_${policy}:${count || 'unknown'}`, + suppressedCount: Number(count || 0), + suppressionPolicy: policy, + omittedSampleClasses, + degradedRun, + visibleSampleCount: Number(importSuppressionEventMatch.groups?.visible || 0), + actionableCount, + totalCount: Number(importSuppressionEventMatch.groups?.total || 0), + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'warning_suppressed', + failureClass: `imports_${policy}:${count || 'unknown'}` + }) + }); + return signal ? [signal] : []; + } + + const importCountSuppressedMatch = IMPORT_WARNING_SUPPRESSED_COUNT_PATTERN.exec(text); + if (importCountSuppressedMatch) { + return []; + } + + const reuseObservation = parseBenchReuseObservation(text); + if (reuseObservation && ['provider_unavailable', 'provider_unhealthy', 'cache_invalid'].includes(reuseObservation.causeClass)) { + const signal = buildDiagnosticSignal({ + eventType: 'fallback_used', + message: text, + source, + providerId: reuseObservation.providerId || null, + failureClass: reuseObservation.causeClass, + reuseSurface: reuseObservation.reuseSurface, + reuseSource: reuseObservation.reuseSource, + qualityImpact: reuseObservation.qualityImpact, + timeCostMs: reuseObservation.timeCostMs, + requestedCount: reuseObservation.requestedCount, + reusedCount: reuseObservation.reusedCount, + fetchedCount: reuseObservation.fetchedCount, + chunkCount: reuseObservation.chunkCount, + stage: event?.stage || null, + taskId: event?.taskId || null, + level: event?.level || null, + severity: resolveBenchDiagnosticSeverity({ + eventType: 'fallback_used', + failureClass: reuseObservation.causeClass + }) + }); + return signal ? [signal] : []; + } + + return []; + }; + + return { classify }; +}; export const isBenchDiagnosticEventType = (value) => ( typeof value === 'string' && BENCH_DIAGNOSTIC_EVENT_TYPE_SET.has(value) @@ -156,7 +934,17 @@ export const buildBenchDiagnosticSignature = ({ stage = '', taskId = '', source = '', - message = '' + message = '', + providerId = '', + workspacePartition = '', + requestMethod = '', + failureClass = '', + preflightId = '', + preflightClass = '', + preflightState = '', + reuseSurface = '', + reuseSource = '', + qualityImpact = '' } = {}) => { const type = isBenchDiagnosticEventType(eventType) ? eventType : 'unknown'; return [ @@ -164,6 +952,16 @@ export const buildBenchDiagnosticSignature = ({ normalizeBenchDiagnosticText(stage, { maxLength: 64 }) || '-', normalizeBenchDiagnosticText(taskId, { maxLength: 96 }) || '-', normalizeBenchDiagnosticText(source, { maxLength: 48 }) || '-', + normalizeDiagnosticField(providerId, 64) || '-', + normalizeDiagnosticField(workspacePartition, 80) || '-', + normalizeDiagnosticField(requestMethod, 80) || '-', + normalizeDiagnosticField(failureClass, 80) || '-', + normalizeDiagnosticField(preflightId, 96) || '-', + normalizeDiagnosticField(preflightClass, 64) || '-', + normalizeDiagnosticField(preflightState, 48) || '-', + normalizeDiagnosticField(reuseSurface, 48) || '-', + normalizeDiagnosticField(reuseSource, 48) || '-', + normalizeDiagnosticField(qualityImpact, 48) || '-', normalizeBenchDiagnosticText(message, { maxLength: 200 }) || '-' ].join('|'); }; @@ -181,17 +979,27 @@ export const buildBenchDiagnosticEventId = ({ eventType, signature } = {}) => { export const buildBenchEnvironmentMetadata = (env = process.env) => { const selected = {}; - for (const key of ENV_METADATA_KEYS) { - if (!Object.prototype.hasOwnProperty.call(env, key)) continue; - const value = env[key]; + for (const [key, value] of Object.entries(env || {})) { + const includeKey = ENV_METADATA_KEYS.includes(key) || key.startsWith('PAIROFCLEATS_'); + if (!includeKey) continue; if (value == null || value === '') continue; selected[key] = String(value); } + const fingerprint = crypto + .createHash('sha1') + .update(JSON.stringify({ + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, + selected + })) + .digest('hex'); return { platform: process.platform, arch: process.arch, nodeVersion: process.version, - selected + selected, + fingerprint: `sha1:${fingerprint}` }; }; diff --git a/tools/bench/language/metrics.js b/tools/bench/language/metrics.js index 4607b2f70..47044400d 100644 --- a/tools/bench/language/metrics.js +++ b/tools/bench/language/metrics.js @@ -38,6 +38,7 @@ export { */ export { THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION, + THROUGHPUT_LEDGER_REGRESSION_METRICS, getBestHitRate, computeLowHitSeverity, computeThroughputLedgerRegression diff --git a/tools/bench/language/metrics/line-stats.js b/tools/bench/language/metrics/line-stats.js index 8d17b5782..a97906929 100644 --- a/tools/bench/language/metrics/line-stats.js +++ b/tools/bench/language/metrics/line-stats.js @@ -11,7 +11,7 @@ import { normalizeDocumentExtractionPolicy, normalizeExtractedText } from '../.. import { getLanguageForFile } from '../../../../src/index/language-registry.js'; import { extractComments, normalizeCommentConfig } from '../../../../src/index/comments.js'; import { detectFrontmatter } from '../../../../src/index/segments.js'; -import { runWithConcurrency } from '../../../../src/shared/concurrency.js'; +import { runWithConcurrency } from '../../../../src/shared/concurrency/run-with-queue.js'; import { readTextFile } from '../../../../src/shared/encoding.js'; import { buildLineIndex, offsetToLine } from '../../../../src/shared/lines.js'; import { countLinesForEntries } from '../../../../src/shared/file-stats.js'; diff --git a/tools/bench/language/metrics/regression.js b/tools/bench/language/metrics/regression.js index f5d393579..d9003e8ff 100644 --- a/tools/bench/language/metrics/regression.js +++ b/tools/bench/language/metrics/regression.js @@ -1,6 +1,39 @@ import { isValidThroughputLedger } from './stage-ledger.js'; +import { summarizeNumericDistribution } from '../../../shared/numeric-distribution.js'; export const THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION = 1; +export const THROUGHPUT_LEDGER_REGRESSION_METRICS = Object.freeze([ + { + key: 'chunksPerSec', + label: 'chunks/s', + kind: 'rate', + regressionThresholdPct: -0.08 + }, + { + key: 'filesPerSec', + label: 'files/s', + kind: 'rate', + regressionThresholdPct: -0.08 + }, + { + key: 'tokensPerSec', + label: 'tokens/s', + kind: 'rate', + regressionThresholdPct: -0.08 + }, + { + key: 'bytesPerSec', + label: 'bytes/s', + kind: 'rate', + regressionThresholdPct: -0.08 + }, + { + key: 'durationMs', + label: 'duration', + kind: 'duration', + regressionThresholdPct: 0.08 + } +]); const toFiniteRate = (value) => { const parsed = Number(value); @@ -77,35 +110,22 @@ export const computeLowHitSeverity = ({ }; }; -const meanNumeric = (values) => { - const numeric = (Array.isArray(values) ? values : []) - .map((value) => Number(value)) - .filter(Number.isFinite); - if (!numeric.length) return null; - return numeric.reduce((sum, value) => sum + value, 0) / numeric.length; +const resolveBaselineConfidence = (summary) => { + const count = Number(summary?.count); + const coefficientOfVariation = Number(summary?.coefficientOfVariation); + if (!Number.isFinite(count) || count <= 0) return 'none'; + if (count < 2) return 'low'; + if (count < 4) return 'medium'; + if (Number.isFinite(coefficientOfVariation) && coefficientOfVariation > 0.25) return 'medium'; + return 'high'; }; -export const computeThroughputLedgerRegression = ({ - currentLedger = null, - baselineLedgers = [], - metric = 'chunksPerSec', - regressionThresholdPct = -0.08 -} = {}) => { - if (!isValidThroughputLedger(currentLedger)) return null; - const baselineEntries = (Array.isArray(baselineLedgers) ? baselineLedgers : []) - .filter((entry) => isValidThroughputLedger(entry)); - if (!baselineEntries.length) { - return { - schemaVersion: THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION, - metric, - baselineCount: 0, - comparedEntries: 0, - regressionThresholdPct, - regressions: [], - improvements: [] - }; - } - +const buildRegressionSummary = ({ + currentLedger, + baselineEntries, + metricConfig +}) => { + const metric = metricConfig?.key || 'chunksPerSec'; const baselineMap = new Map(); for (const baseline of baselineEntries) { for (const [modeKey, modeEntry] of Object.entries(baseline.modalities || {})) { @@ -122,8 +142,10 @@ export const computeThroughputLedgerRegression = ({ const regressions = []; const improvements = []; let comparedEntries = 0; - const threshold = Number(regressionThresholdPct); - const resolvedThreshold = Number.isFinite(threshold) ? threshold : -0.08; + const resolvedThreshold = Number(metricConfig?.regressionThresholdPct); + const threshold = Number.isFinite(resolvedThreshold) + ? resolvedThreshold + : (metricConfig?.kind === 'duration' ? 0.08 : -0.08); for (const [modeKey, modeEntry] of Object.entries(currentLedger.modalities || {})) { for (const [stageKey, stageEntry] of Object.entries(modeEntry?.stages || {})) { @@ -131,7 +153,8 @@ export const computeThroughputLedgerRegression = ({ if (!Number.isFinite(currentRate) || currentRate <= 0) continue; const key = `${modeKey}:${stageKey}`; const baselineRates = baselineMap.get(key) || []; - const baselineRate = meanNumeric(baselineRates); + const baselineSummary = summarizeNumericDistribution(baselineRates); + const baselineRate = Number(baselineSummary?.median); if (!Number.isFinite(baselineRate) || baselineRate <= 0) continue; const deltaRate = currentRate - baselineRate; const deltaPct = deltaRate / baselineRate; @@ -140,34 +163,122 @@ export const computeThroughputLedgerRegression = ({ modality: modeKey, stage: stageKey, metric, + metricKind: metricConfig?.kind || 'rate', + metricLabel: metricConfig?.label || metric, currentRate, baselineRate, + baselineMean: baselineSummary?.mean ?? null, + baselineMedian: baselineSummary?.median ?? null, + baselineMin: baselineSummary?.min ?? null, + baselineMax: baselineSummary?.max ?? null, + baselineP95: baselineSummary?.p95 ?? null, + baselineStdDev: baselineSummary?.stdDev ?? null, + baselineCv: baselineSummary?.coefficientOfVariation ?? null, + baselineConfidence: resolveBaselineConfidence(baselineSummary), deltaRate, deltaPct, baselineSamples: baselineRates.length }; - if (deltaPct <= resolvedThreshold) { + const isRegression = metricConfig?.kind === 'duration' + ? deltaPct >= Math.abs(threshold) + : deltaPct <= threshold; + const isImprovement = metricConfig?.kind === 'duration' + ? deltaPct <= -Math.abs(threshold) + : deltaPct >= Math.abs(threshold); + if (isRegression) { regressions.push(row); - } else if (deltaPct >= Math.abs(resolvedThreshold)) { + } else if (isImprovement) { improvements.push(row); } } } regressions.sort((left, right) => ( - Number(left.deltaPct) - Number(right.deltaPct) + metricConfig?.kind === 'duration' + ? (Number(right.deltaPct) - Number(left.deltaPct)) + : (Number(left.deltaPct) - Number(right.deltaPct)) ) || left.modality.localeCompare(right.modality) || left.stage.localeCompare(right.stage)); improvements.sort((left, right) => ( - Number(right.deltaPct) - Number(left.deltaPct) + metricConfig?.kind === 'duration' + ? (Number(left.deltaPct) - Number(right.deltaPct)) + : (Number(right.deltaPct) - Number(left.deltaPct)) ) || left.modality.localeCompare(right.modality) || left.stage.localeCompare(right.stage)); return { - schemaVersion: THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION, metric, + metricKind: metricConfig?.kind || 'rate', + metricLabel: metricConfig?.label || metric, baselineCount: baselineEntries.length, comparedEntries, - regressionThresholdPct: resolvedThreshold, + regressionThresholdPct: threshold, regressions, improvements }; }; + +export const computeThroughputLedgerRegression = ({ + currentLedger = null, + baselineLedgers = [], + metric = 'chunksPerSec', + regressionThresholdPct = -0.08 +} = {}) => { + if (!isValidThroughputLedger(currentLedger)) return null; + const baselineEntries = (Array.isArray(baselineLedgers) ? baselineLedgers : []) + .filter((entry) => isValidThroughputLedger(entry)); + const metricConfigs = THROUGHPUT_LEDGER_REGRESSION_METRICS.map((config) => ( + config.key === metric + ? { ...config, regressionThresholdPct } + : config + )); + if (!baselineEntries.length) { + const metrics = Object.fromEntries(metricConfigs.map((config) => [ + config.key, + { + metric: config.key, + metricKind: config.kind, + metricLabel: config.label, + baselineCount: 0, + comparedEntries: 0, + regressionThresholdPct: config.key === metric ? regressionThresholdPct : config.regressionThresholdPct, + regressions: [], + improvements: [] + } + ])); + return { + schemaVersion: THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION, + metric, + baselineCount: 0, + comparedEntries: 0, + regressionThresholdPct, + regressions: [], + improvements: [], + metrics + }; + } + const metrics = Object.fromEntries(metricConfigs.map((config) => [ + config.key, + buildRegressionSummary({ + currentLedger, + baselineEntries, + metricConfig: config + }) + ])); + const primary = metrics[metric] || { + baselineCount: baselineEntries.length, + comparedEntries: 0, + regressionThresholdPct, + regressions: [], + improvements: [] + }; + + return { + schemaVersion: THROUGHPUT_LEDGER_DIFF_SCHEMA_VERSION, + metric, + baselineCount: primary.baselineCount, + comparedEntries: primary.comparedEntries, + regressionThresholdPct: primary.regressionThresholdPct, + regressions: primary.regressions, + improvements: primary.improvements, + metrics + }; +}; diff --git a/tools/bench/language/ownership.js b/tools/bench/language/ownership.js new file mode 100644 index 000000000..bc4f4e663 --- /dev/null +++ b/tools/bench/language/ownership.js @@ -0,0 +1,726 @@ +import { resolveLanguageFamily } from '../query-generator.js'; + +export const BENCH_REUSE_SCHEMA_VERSION = 1; +export const BENCH_OWNERSHIP_SCHEMA_VERSION = 1; +export const BENCH_OWNERSHIP_POLICY_VERSION = 'bench-language-family-ownership-v1'; +export const BENCH_OWNERSHIP_DIFF_SCHEMA_VERSION = 1; + +const MEMORY_BACKENDS = new Set(['memory']); +const SQLITE_BACKENDS = new Set(['sqlite', 'sqlite-fts', 'fts']); +const OWNERSHIP_TOP_REPO_LIMIT = 5; +const DOMINANT_PHASE_KEYS = Object.freeze([ + 'scan', + 'scheduler', + 'artifactCloseout', + 'sqlite', + 'tooling' +]); +const DEFAULT_FAMILY_BUDGET = Object.freeze({ + reuse: { + coldStartMin: 0.4, + intraRunMin: 0.82, + crossRunMin: 0.7 + }, + rss: { + sqliteAvgMbMax: 1536 + }, + throughput: { + buildIndexMsAvgMax: 240000, + artifactTailStallPerRepoMax: 1, + queueDelayHotspotPerRepoMax: 0.5 + }, + phaseShare: { + scanMaxShare: 0.7, + schedulerMaxShare: 0.18, + artifactCloseoutMaxShare: 0.25, + sqliteMaxShare: 0.4, + toolingMaxShare: 0.2 + } +}); +const FAMILY_BUDGET_OVERRIDES = Object.freeze({ + clike: { + rss: { sqliteAvgMbMax: 1792 }, + throughput: { buildIndexMsAvgMax: 260000 } + }, + data: { + reuse: { coldStartMin: 0.42, intraRunMin: 0.83, crossRunMin: 0.72 }, + rss: { sqliteAvgMbMax: 1664 }, + throughput: { buildIndexMsAvgMax: 260000 } + }, + jvm: { + reuse: { coldStartMin: 0.35, intraRunMin: 0.78, crossRunMin: 0.68 }, + rss: { sqliteAvgMbMax: 2304 }, + throughput: { buildIndexMsAvgMax: 320000 }, + phaseShare: { sqliteMaxShare: 0.45 } + }, + scripting: { + reuse: { coldStartMin: 0.45, intraRunMin: 0.85, crossRunMin: 0.74 }, + rss: { sqliteAvgMbMax: 1280 }, + throughput: { buildIndexMsAvgMax: 180000 } + }, + systems: { + reuse: { coldStartMin: 0.38, intraRunMin: 0.8, crossRunMin: 0.7 }, + rss: { sqliteAvgMbMax: 2048 }, + throughput: { buildIndexMsAvgMax: 300000 }, + phaseShare: { artifactCloseoutMaxShare: 0.3 } + } +}); + +const toFiniteNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const roundValue = (value, digits = 4) => { + if (value == null || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return Number(parsed.toFixed(digits)); +}; + +const average = (values) => { + const numeric = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter(Number.isFinite); + if (!numeric.length) return null; + return numeric.reduce((sum, value) => sum + value, 0) / numeric.length; +}; + +const sumValues = (values) => ( + (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter(Number.isFinite) + .reduce((sum, value) => sum + value, 0) +); + +const compareNullableAscending = (left, right) => { + const l = toFiniteNumber(left); + const r = toFiniteNumber(right); + if (l == null && r == null) return 0; + if (l == null) return 1; + if (r == null) return -1; + return l - r; +}; + +const compareNullableDescending = (left, right) => { + const l = toFiniteNumber(left); + const r = toFiniteNumber(right); + if (l == null && r == null) return 0; + if (l == null) return 1; + if (r == null) return -1; + return r - l; +}; + +const pushOrderedLimited = (rows, entry, limit, compare) => { + const list = Array.isArray(rows) ? rows : []; + let insertAt = list.length; + while (insertAt > 0 && compare(entry, list[insertAt - 1]) < 0) { + insertAt -= 1; + } + if (list.length < limit) { + list.splice(insertAt, 0, entry); + return; + } + if (insertAt >= limit) return; + list.splice(insertAt, 0, entry); + list.length = limit; +}; + +const buildDelta = (beforeValue, afterValue, digits = 6) => { + const before = toFiniteNumber(beforeValue); + const after = toFiniteNumber(afterValue); + if (before == null && after == null) return null; + return { + before, + after, + delta: before != null && after != null + ? Number((after - before).toFixed(digits)) + : null + }; +}; + +const resolveGuardrailStatus = ({ actual = null, min = null, max = null } = {}) => { + const value = toFiniteNumber(actual); + if (value == null) return 'not_applicable'; + if (Number.isFinite(Number(min)) && value < Number(min)) return 'breached'; + if (Number.isFinite(Number(max)) && value > Number(max)) return 'breached'; + return 'within_budget'; +}; + +const buildGuardrail = ({ + label, + actual = null, + min = null, + max = null, + unit = null +} = {}) => { + const status = resolveGuardrailStatus({ actual, min, max }); + return { + label, + status, + actual: roundValue(actual, unit === 'ratio' ? 4 : 2), + min: roundValue(min, unit === 'ratio' ? 4 : 2), + max: roundValue(max, unit === 'ratio' ? 4 : 2), + unit + }; +}; + +const resolveLanguageFamilyForTask = (entry) => ( + resolveLanguageFamily({ languages: [entry?.language] }) || 'general' +); + +const isBackendIncluded = (backend, allowed) => allowed.has(String(backend || '').trim().toLowerCase()); + +const collectHitRatesForSummary = (summary, predicate) => { + const out = []; + if (!summary || typeof summary !== 'object') return out; + const backends = summary.backends || Object.keys(summary.hitRate || {}); + for (const backend of Array.isArray(backends) ? backends : []) { + if (typeof predicate === 'function' && !predicate(backend)) continue; + const value = toFiniteNumber(summary?.hitRate?.[backend]); + if (value != null) out.push({ backend, value }); + } + return out; +}; + +const summarizeReuseLane = (rows, { label, backendSet = null, modeGate = null } = {}) => { + const applicableRows = rows.filter((entry) => { + if (typeof modeGate === 'function' && !modeGate(entry)) return false; + return true; + }); + const hits = []; + const backendHits = new Map(); + const worstRepos = []; + for (const entry of applicableRows) { + const candidates = collectHitRatesForSummary(entry?.summary, (backend) => ( + backendSet ? isBackendIncluded(backend, backendSet) : true + )); + const values = candidates.map((candidate) => candidate.value).filter(Number.isFinite); + const repoValue = average(values); + for (const candidate of candidates) { + if (!backendHits.has(candidate.backend)) backendHits.set(candidate.backend, []); + backendHits.get(candidate.backend).push(candidate.value); + hits.push(candidate.value); + } + if (repoValue != null) { + pushOrderedLimited(worstRepos, { + language: entry.language, + tier: entry.tier, + repo: entry.repo, + hitRate: roundValue(repoValue, 4) + }, OWNERSHIP_TOP_REPO_LIMIT, (left, right) => ( + compareNullableAscending(left.hitRate, right.hitRate) + ) || String(left.repo).localeCompare(String(right.repo))); + } + } + return { + label, + repoCount: applicableRows.length, + backendAverages: Object.fromEntries( + Array.from(backendHits.entries()) + .sort((left, right) => left[0].localeCompare(right[0])) + .map(([backend, values]) => [backend, roundValue(average(values), 4)]) + ), + averageHitRate: roundValue(average(hits), 4), + worstRepos + }; +}; + +export const buildBenchReuseFromSummary = ({ + summary = null, + methodology = null +} = {}) => { + const shellEntry = { summary }; + const mode = String(methodology?.mode || '').trim().toLowerCase() || 'warm'; + return { + schemaVersion: BENCH_REUSE_SCHEMA_VERSION, + mode, + coldStart: summarizeReuseLane([shellEntry], { + label: 'cold-start', + modeGate: () => mode === 'cold' + }), + intraRun: summarizeReuseLane([shellEntry], { + label: 'intra-run', + backendSet: MEMORY_BACKENDS + }), + crossRun: summarizeReuseLane([shellEntry], { + label: 'cross-run', + backendSet: SQLITE_BACKENDS + }), + overall: summarizeReuseLane([shellEntry], { label: 'overall' }) + }; +}; + +export const buildBenchReuseSummary = ({ + tasks = [], + methodology = null +} = {}) => { + const mode = String(methodology?.mode || '').trim().toLowerCase() || 'warm'; + return { + schemaVersion: BENCH_REUSE_SCHEMA_VERSION, + mode, + coldStart: summarizeReuseLane(tasks, { + label: 'cold-start', + modeGate: () => mode === 'cold' + }), + intraRun: summarizeReuseLane(tasks, { + label: 'intra-run', + backendSet: MEMORY_BACKENDS + }), + crossRun: summarizeReuseLane(tasks, { + label: 'cross-run', + backendSet: SQLITE_BACKENDS + }), + overall: summarizeReuseLane(tasks, { label: 'overall' }) + }; +}; + +const countByDiagnosticType = (entry, type) => { + const direct = Number(entry?.diagnostics?.process?.countsByType?.[type]); + if (Number.isFinite(direct)) return direct; + const nested = Number(entry?.diagnostics?.countsByType?.[type]); + return Number.isFinite(nested) ? nested : 0; +}; + +const resolveTaskPhaseSummary = (entry, methodology = null) => { + const stages = entry?.stageTimingProfile?.stages || {}; + const queueDelayMs = toFiniteNumber(entry?.stageTimingProfile?.watchdog?.queueDelayMs?.summary?.totalMs) || 0; + const toolingMs = methodology?.toolingMode === 'included' + ? (toFiniteNumber(entry?.summary?.buildMs?.tooling) || 0) + : 0; + const phaseDurations = { + scan: sumValues([ + stages.discovery, + stages.importScan, + stages.scmMeta, + stages.parseChunk, + stages.inference, + stages.embedding + ]), + scheduler: queueDelayMs, + artifactCloseout: sumValues([stages.artifactWrite]), + sqlite: sumValues([stages.sqliteBuild]), + tooling: toolingMs + }; + let totalObservedMs = sumValues(Object.values(phaseDurations)); + if (totalObservedMs <= 0) { + if (countByDiagnosticType(entry, 'artifact_tail_stall') > 0) { + phaseDurations.artifactCloseout = 1; + totalObservedMs = 1; + } else if (countByDiagnosticType(entry, 'queue_delay_hotspot') > 0) { + phaseDurations.scheduler = 1; + totalObservedMs = 1; + } + } + const ranked = Object.entries(phaseDurations) + .sort((left, right) => (right[1] - left[1]) || left[0].localeCompare(right[0])); + const dominant = ranked.find(([, value]) => value > 0) || null; + const dominantPhase = dominant?.[0] || null; + const dominantShare = dominantPhase && totalObservedMs > 0 + ? dominant[1] / totalObservedMs + : null; + return { + totalObservedMs: roundValue(totalObservedMs, 2), + phaseDurations: Object.fromEntries( + Object.entries(phaseDurations).map(([key, value]) => [key, roundValue(value, 2) || 0]) + ), + dominantPhase, + dominantShare: roundValue(dominantShare, 4) + }; +}; + +const mergeBudget = (family) => { + const override = FAMILY_BUDGET_OVERRIDES[family] || null; + if (!override) return JSON.parse(JSON.stringify(DEFAULT_FAMILY_BUDGET)); + return { + reuse: { + ...DEFAULT_FAMILY_BUDGET.reuse, + ...(override.reuse || {}) + }, + rss: { + ...DEFAULT_FAMILY_BUDGET.rss, + ...(override.rss || {}) + }, + throughput: { + ...DEFAULT_FAMILY_BUDGET.throughput, + ...(override.throughput || {}) + }, + phaseShare: { + ...DEFAULT_FAMILY_BUDGET.phaseShare, + ...(override.phaseShare || {}) + } + }; +}; + +const buildOffenderIssues = (repoSummary, budget) => { + const issues = []; + if (repoSummary.reuse.intraRunHitRate != null && repoSummary.reuse.intraRunHitRate < budget.reuse.intraRunMin) { + issues.push('intra_run_reuse'); + } + if (repoSummary.reuse.crossRunHitRate != null && repoSummary.reuse.crossRunHitRate < budget.reuse.crossRunMin) { + issues.push('cross_run_reuse'); + } + if (repoSummary.reuse.coldStartHitRate != null && repoSummary.reuse.coldStartHitRate < budget.reuse.coldStartMin) { + issues.push('cold_start_reuse'); + } + if (repoSummary.sqliteRssMb != null && repoSummary.sqliteRssMb > budget.rss.sqliteAvgMbMax) { + issues.push('sqlite_rss'); + } + if (repoSummary.buildIndexMs != null && repoSummary.buildIndexMs > budget.throughput.buildIndexMsAvgMax) { + issues.push('build_index'); + } + if ((repoSummary.artifactTailStallCount || 0) > budget.throughput.artifactTailStallPerRepoMax) { + issues.push('artifact_tail_stall'); + } + if ((repoSummary.queueDelayHotspotCount || 0) > budget.throughput.queueDelayHotspotPerRepoMax) { + issues.push('queue_delay_hotspot'); + } + const dominantPhase = repoSummary.phase.dominantPhase; + if (dominantPhase) { + const maxShare = budget.phaseShare[`${dominantPhase}MaxShare`]; + if (repoSummary.phase.dominantShare != null && repoSummary.phase.dominantShare > maxShare) { + issues.push(`dominant_phase:${dominantPhase}`); + } + } + return issues; +}; + +const buildTaskHotspotRow = (entry, methodology, budget) => { + const intraRunValues = collectHitRatesForSummary(entry?.summary, (backend) => isBackendIncluded(backend, MEMORY_BACKENDS)) + .map((candidate) => candidate.value); + const crossRunValues = collectHitRatesForSummary(entry?.summary, (backend) => isBackendIncluded(backend, SQLITE_BACKENDS)) + .map((candidate) => candidate.value); + const coldStartValues = String(methodology?.mode || '').trim().toLowerCase() === 'cold' + ? collectHitRatesForSummary(entry?.summary).map((candidate) => candidate.value) + : []; + const sqliteRssValues = ['sqlite', 'sqlite-fts', 'fts'] + .map((backend) => entry?.summary?.memoryRss?.[backend]?.mean) + .map(toFiniteNumber) + .filter(Number.isFinite) + .map((value) => value / (1024 * 1024)); + const phase = resolveTaskPhaseSummary(entry, methodology); + const repoSummary = { + language: entry.language, + tier: entry.tier, + repo: entry.repo, + repoPath: entry.repoPath || null, + reuse: { + intraRunHitRate: roundValue(average(intraRunValues), 4), + crossRunHitRate: roundValue(average(crossRunValues), 4), + coldStartHitRate: roundValue(average(coldStartValues), 4) + }, + sqliteRssMb: roundValue(average(sqliteRssValues), 2), + buildIndexMs: roundValue(entry?.summary?.buildMs?.index, 2), + artifactTailStallCount: countByDiagnosticType(entry, 'artifact_tail_stall'), + queueDelayHotspotCount: countByDiagnosticType(entry, 'queue_delay_hotspot'), + degradationCount: Array.isArray(entry?.taskStatus?.degradationClasses) + ? entry.taskStatus.degradationClasses.length + : 0, + resultClass: entry?.taskStatus?.resultClass || null, + phase + }; + return { + ...repoSummary, + issues: buildOffenderIssues(repoSummary, budget) + }; +}; + +const buildFamilySummary = (family, rows, methodology) => { + const budget = mergeBudget(family); + const languages = [...new Set(rows.map((entry) => entry.language).filter(Boolean))].sort((left, right) => left.localeCompare(right)); + const reuse = { + coldStart: { + averageHitRate: roundValue(average(rows.map((entry) => entry.reuse.coldStartHitRate)), 4), + guardrail: buildGuardrail({ + label: 'cold-start reuse', + actual: average(rows.map((entry) => entry.reuse.coldStartHitRate)), + min: budget.reuse.coldStartMin, + unit: 'ratio' + }) + }, + intraRun: { + averageHitRate: roundValue(average(rows.map((entry) => entry.reuse.intraRunHitRate)), 4), + guardrail: buildGuardrail({ + label: 'intra-run reuse', + actual: average(rows.map((entry) => entry.reuse.intraRunHitRate)), + min: budget.reuse.intraRunMin, + unit: 'ratio' + }) + }, + crossRun: { + averageHitRate: roundValue(average(rows.map((entry) => entry.reuse.crossRunHitRate)), 4), + guardrail: buildGuardrail({ + label: 'cross-run reuse', + actual: average(rows.map((entry) => entry.reuse.crossRunHitRate)), + min: budget.reuse.crossRunMin, + unit: 'ratio' + }) + } + }; + const sqliteRssMb = average(rows.map((entry) => entry.sqliteRssMb)); + const buildIndexMsAvg = average(rows.map((entry) => entry.buildIndexMs)); + const artifactTailStallPerRepo = rows.length + ? rows.reduce((sum, entry) => sum + (Number(entry.artifactTailStallCount) || 0), 0) / rows.length + : null; + const queueDelayHotspotPerRepo = rows.length + ? rows.reduce((sum, entry) => sum + (Number(entry.queueDelayHotspotCount) || 0), 0) / rows.length + : null; + const phaseCounts = Object.fromEntries(DOMINANT_PHASE_KEYS.map((key) => [key, 0])); + const phaseShares = Object.fromEntries(DOMINANT_PHASE_KEYS.map((key) => [key, []])); + for (const entry of rows) { + const dominantPhase = entry.phase.dominantPhase; + if (!dominantPhase || !Object.prototype.hasOwnProperty.call(phaseCounts, dominantPhase)) continue; + phaseCounts[dominantPhase] += 1; + if (entry.phase.dominantShare != null) phaseShares[dominantPhase].push(entry.phase.dominantShare); + } + const dominantPhase = Object.entries(phaseCounts) + .sort((left, right) => (right[1] - left[1]) + || (average(phaseShares[right[0]]) || 0) - (average(phaseShares[left[0]]) || 0) + || left[0].localeCompare(right[0]))[0]?.[1] > 0 + ? Object.entries(phaseCounts) + .sort((left, right) => (right[1] - left[1]) + || (average(phaseShares[right[0]]) || 0) - (average(phaseShares[left[0]]) || 0) + || left[0].localeCompare(right[0]))[0][0] + : null; + const dominantPhaseShare = dominantPhase ? average(phaseShares[dominantPhase]) : null; + const dominantPhaseBudget = dominantPhase ? budget.phaseShare[`${dominantPhase}MaxShare`] : null; + const guardrails = [ + reuse.coldStart.guardrail, + reuse.intraRun.guardrail, + reuse.crossRun.guardrail, + buildGuardrail({ + label: 'sqlite RSS', + actual: sqliteRssMb, + max: budget.rss.sqliteAvgMbMax, + unit: 'mb' + }), + buildGuardrail({ + label: 'build index', + actual: buildIndexMsAvg, + max: budget.throughput.buildIndexMsAvgMax, + unit: 'ms' + }), + buildGuardrail({ + label: 'artifact closeout stalls per repo', + actual: artifactTailStallPerRepo, + max: budget.throughput.artifactTailStallPerRepoMax, + unit: 'count' + }), + buildGuardrail({ + label: 'scheduler queue-delay hotspots per repo', + actual: queueDelayHotspotPerRepo, + max: budget.throughput.queueDelayHotspotPerRepoMax, + unit: 'count' + }), + buildGuardrail({ + label: dominantPhase ? `${dominantPhase} phase share` : 'dominant phase share', + actual: dominantPhaseShare, + max: dominantPhaseBudget, + unit: 'ratio' + }) + ]; + const activeGuardrails = guardrails.filter((entry) => entry.status !== 'not_applicable'); + const breachedGuardrails = activeGuardrails.filter((entry) => entry.status === 'breached'); + const topOffenders = []; + for (const entry of rows) { + const severity = entry.issues.length + + Math.max(0, ((entry.buildIndexMs || 0) - budget.throughput.buildIndexMsAvgMax) / Math.max(1, budget.throughput.buildIndexMsAvgMax)) + + Math.max(0, ((entry.sqliteRssMb || 0) - budget.rss.sqliteAvgMbMax) / Math.max(1, budget.rss.sqliteAvgMbMax)) + + Math.max(0, ((budget.reuse.intraRunMin - (entry.reuse.intraRunHitRate || budget.reuse.intraRunMin)) / Math.max(0.01, budget.reuse.intraRunMin))) + + Math.max(0, ((budget.reuse.crossRunMin - (entry.reuse.crossRunHitRate || budget.reuse.crossRunMin)) / Math.max(0.01, budget.reuse.crossRunMin))); + pushOrderedLimited(topOffenders, { + language: entry.language, + tier: entry.tier, + repo: entry.repo, + repoPath: entry.repoPath, + issues: entry.issues, + severity: roundValue(severity, 3), + buildIndexMs: entry.buildIndexMs, + sqliteRssMb: entry.sqliteRssMb, + intraRunHitRate: entry.reuse.intraRunHitRate, + crossRunHitRate: entry.reuse.crossRunHitRate, + coldStartHitRate: entry.reuse.coldStartHitRate, + artifactTailStallCount: entry.artifactTailStallCount, + queueDelayHotspotCount: entry.queueDelayHotspotCount, + dominantPhase: entry.phase.dominantPhase, + dominantPhaseShare: entry.phase.dominantShare, + resultClass: entry.resultClass + }, OWNERSHIP_TOP_REPO_LIMIT, (left, right) => ( + compareNullableDescending(left.severity, right.severity) + ) || String(left.repo).localeCompare(String(right.repo))); + } + return { + family, + repoCount: rows.length, + languages, + methodologyMode: methodology?.mode || null, + budgets: budget, + reuse, + rss: { + sqliteAvgMb: roundValue(sqliteRssMb, 2), + guardrail: guardrails[3] + }, + throughput: { + buildIndexMsAvg: roundValue(buildIndexMsAvg, 2), + artifactTailStallPerRepo: roundValue(artifactTailStallPerRepo, 3), + queueDelayHotspotPerRepo: roundValue(queueDelayHotspotPerRepo, 3), + guardrails: { + buildIndex: guardrails[4], + artifactTailStallPerRepo: guardrails[5], + queueDelayHotspotPerRepo: guardrails[6] + } + }, + phaseOwnership: { + dominantPhase, + dominantPhaseShare: roundValue(dominantPhaseShare, 4), + guardrail: guardrails[7], + repoCounts: phaseCounts + }, + guardrails: { + activeCount: activeGuardrails.length, + breachedCount: breachedGuardrails.length, + entries: guardrails + }, + topOffenders + }; +}; + +export const buildBenchOwnershipSummary = ({ + tasks = [], + methodology = null +} = {}) => { + const taskRows = (Array.isArray(tasks) ? tasks : []) + .map((entry) => ({ + family: resolveLanguageFamilyForTask(entry), + row: buildTaskHotspotRow(entry, methodology, mergeBudget(resolveLanguageFamilyForTask(entry))) + })); + const families = new Map(); + for (const entry of taskRows) { + if (!families.has(entry.family)) families.set(entry.family, []); + families.get(entry.family).push(entry.row); + } + const familySummaries = Array.from(families.entries()) + .map(([family, rows]) => buildFamilySummary(family, rows, methodology)) + .sort((left, right) => ( + Number(right?.guardrails?.breachedCount || 0) - Number(left?.guardrails?.breachedCount || 0) + ) || ( + compareNullableDescending(left?.throughput?.buildIndexMsAvg, right?.throughput?.buildIndexMsAvg) + ) || left.family.localeCompare(right.family)); + const topHotspots = []; + for (const family of familySummaries) { + for (const offender of family.topOffenders) { + pushOrderedLimited(topHotspots, { + family: family.family, + breachedGuardrails: family.guardrails.breachedCount, + ...offender + }, OWNERSHIP_TOP_REPO_LIMIT, (left, right) => ( + compareNullableDescending(left.severity, right.severity) + ) || String(left.repo).localeCompare(String(right.repo))); + } + } + return { + schemaVersion: BENCH_OWNERSHIP_SCHEMA_VERSION, + policyVersion: BENCH_OWNERSHIP_POLICY_VERSION, + methodologyMode: methodology?.mode || null, + familyCount: familySummaries.length, + families: familySummaries, + topHotspots + }; +}; + +const normalizeOwnershipSummary = (report) => { + if (report?.ownership?.schemaVersion === BENCH_OWNERSHIP_SCHEMA_VERSION) return report.ownership; + return buildBenchOwnershipSummary({ + tasks: report?.tasks || [], + methodology: report?.methodology || null + }); +}; + +export const buildBenchOwnershipDiff = ({ + before = null, + after = null +} = {}) => { + const beforeSummary = normalizeOwnershipSummary(before); + const afterSummary = normalizeOwnershipSummary(after); + const beforeFamilies = new Map( + (Array.isArray(beforeSummary?.families) ? beforeSummary.families : []) + .map((entry) => [entry.family, entry]) + ); + const afterFamilies = new Map( + (Array.isArray(afterSummary?.families) ? afterSummary.families : []) + .map((entry) => [entry.family, entry]) + ); + const familyNames = Array.from(new Set([...beforeFamilies.keys(), ...afterFamilies.keys()])).sort(); + const byFamily = familyNames.map((family) => { + const beforeFamily = beforeFamilies.get(family) || null; + const afterFamily = afterFamilies.get(family) || null; + return { + family, + repoCount: buildDelta(beforeFamily?.repoCount, afterFamily?.repoCount, 0), + breachedGuardrails: buildDelta(beforeFamily?.guardrails?.breachedCount, afterFamily?.guardrails?.breachedCount, 0), + buildIndexMsAvg: buildDelta(beforeFamily?.throughput?.buildIndexMsAvg, afterFamily?.throughput?.buildIndexMsAvg), + artifactTailStallPerRepo: buildDelta( + beforeFamily?.throughput?.artifactTailStallPerRepo, + afterFamily?.throughput?.artifactTailStallPerRepo + ), + queueDelayHotspotPerRepo: buildDelta( + beforeFamily?.throughput?.queueDelayHotspotPerRepo, + afterFamily?.throughput?.queueDelayHotspotPerRepo + ), + sqliteAvgMb: buildDelta(beforeFamily?.rss?.sqliteAvgMb, afterFamily?.rss?.sqliteAvgMb), + coldStartHitRate: buildDelta( + beforeFamily?.reuse?.coldStart?.averageHitRate, + afterFamily?.reuse?.coldStart?.averageHitRate + ), + intraRunHitRate: buildDelta( + beforeFamily?.reuse?.intraRun?.averageHitRate, + afterFamily?.reuse?.intraRun?.averageHitRate + ), + crossRunHitRate: buildDelta( + beforeFamily?.reuse?.crossRun?.averageHitRate, + afterFamily?.reuse?.crossRun?.averageHitRate + ), + dominantPhase: { + before: beforeFamily?.phaseOwnership?.dominantPhase || null, + after: afterFamily?.phaseOwnership?.dominantPhase || null + }, + dominantPhaseShare: buildDelta( + beforeFamily?.phaseOwnership?.dominantPhaseShare, + afterFamily?.phaseOwnership?.dominantPhaseShare + ) + }; + }); + const regressions = []; + for (const entry of byFamily) { + const severity = sumValues([ + entry.breachedGuardrails?.delta, + entry.buildIndexMsAvg?.delta != null && entry.buildIndexMsAvg.delta > 0 ? entry.buildIndexMsAvg.delta / 1000 : 0, + entry.sqliteAvgMb?.delta != null && entry.sqliteAvgMb.delta > 0 ? entry.sqliteAvgMb.delta / 256 : 0, + entry.artifactTailStallPerRepo?.delta != null && entry.artifactTailStallPerRepo.delta > 0 ? entry.artifactTailStallPerRepo.delta * 5 : 0, + entry.queueDelayHotspotPerRepo?.delta != null && entry.queueDelayHotspotPerRepo.delta > 0 ? entry.queueDelayHotspotPerRepo.delta * 5 : 0, + entry.intraRunHitRate?.delta != null && entry.intraRunHitRate.delta < 0 ? Math.abs(entry.intraRunHitRate.delta) * 100 : 0, + entry.crossRunHitRate?.delta != null && entry.crossRunHitRate.delta < 0 ? Math.abs(entry.crossRunHitRate.delta) * 100 : 0, + entry.coldStartHitRate?.delta != null && entry.coldStartHitRate.delta < 0 ? Math.abs(entry.coldStartHitRate.delta) * 100 : 0 + ]); + if (severity <= 0) continue; + pushOrderedLimited(regressions, { + family: entry.family, + severity: roundValue(severity, 3), + buildIndexMsAvg: entry.buildIndexMsAvg, + sqliteAvgMb: entry.sqliteAvgMb, + intraRunHitRate: entry.intraRunHitRate, + crossRunHitRate: entry.crossRunHitRate, + coldStartHitRate: entry.coldStartHitRate, + breachedGuardrails: entry.breachedGuardrails, + dominantPhase: entry.dominantPhase + }, OWNERSHIP_TOP_REPO_LIMIT, (left, right) => ( + compareNullableDescending(left.severity, right.severity) + ) || left.family.localeCompare(right.family)); + } + return { + schemaVersion: BENCH_OWNERSHIP_DIFF_SCHEMA_VERSION, + policyVersion: BENCH_OWNERSHIP_POLICY_VERSION, + byFamily, + topRegressions: regressions + }; +}; diff --git a/tools/bench/language/policy.js b/tools/bench/language/policy.js new file mode 100644 index 000000000..7c73ed974 --- /dev/null +++ b/tools/bench/language/policy.js @@ -0,0 +1,190 @@ +import { sha1 } from '../../../src/shared/hash.js'; +import { PROGRESS_TIMEOUT_POLICY_VERSION } from '../../../src/shared/indexing/progress-timeout-policy.js'; + +export const BENCH_METHODOLOGY_SCHEMA_VERSION = 1; +export const BENCH_METHODOLOGY_POLICY_VERSION = 'bench-language-methodology-v1'; +const DEFAULT_CONTROL_SLICE_MAX_TASKS = 12; +const CONTROL_SLICE_TIER_ORDER = Object.freeze([ + 'small', + 'medium', + 'large', + 'xlarge' +]); + +const toText = (value) => String(value == null ? '' : value).trim(); + +export const resolveBenchMode = (value) => { + const normalized = toText(value).toLowerCase(); + if (normalized === 'cold') return 'cold'; + if (normalized === 'reliability') return 'reliability'; + if (normalized === 'tooling') return 'tooling'; + return 'warm'; +}; + +export const resolveBenchModePolicy = (mode) => { + const normalizedMode = resolveBenchMode(mode); + switch (normalizedMode) { + case 'cold': + return { + mode: normalizedMode, + cacheMode: 'cold', + toolingMode: 'disabled', + scoreIncludesTooling: false + }; + case 'tooling': + return { + mode: normalizedMode, + cacheMode: 'warm', + toolingMode: 'included', + scoreIncludesTooling: true + }; + case 'reliability': + return { + mode: normalizedMode, + cacheMode: 'warm', + toolingMode: 'disabled', + scoreIncludesTooling: false + }; + default: + return { + mode: 'warm', + cacheMode: 'warm', + toolingMode: 'disabled', + scoreIncludesTooling: false + }; + } +}; + +const buildTaskId = (task) => [ + toText(task?.language) || 'unknown', + toText(task?.tier) || 'unknown', + toText(task?.repo) || 'unknown' +].join(':'); + +const compareTasks = (left, right) => ( + toText(left?.language).localeCompare(toText(right?.language)) + || CONTROL_SLICE_TIER_ORDER.indexOf(toText(left?.tier).toLowerCase()) - CONTROL_SLICE_TIER_ORDER.indexOf(toText(right?.tier).toLowerCase()) + || toText(left?.tier).localeCompare(toText(right?.tier)) + || toText(left?.repo).localeCompare(toText(right?.repo)) +); + +export const selectBenchControlSlice = (tasks, { maxTasks = DEFAULT_CONTROL_SLICE_MAX_TASKS } = {}) => { + const ordered = (Array.isArray(tasks) ? tasks.slice() : []).sort(compareTasks); + const cap = Number.isFinite(Number(maxTasks)) + ? Math.max(1, Math.floor(Number(maxTasks))) + : DEFAULT_CONTROL_SLICE_MAX_TASKS; + const selected = []; + const selectedIds = new Set(); + const byLanguage = new Map(); + for (const task of ordered) { + const language = toText(task?.language) || 'unknown'; + if (!byLanguage.has(language)) byLanguage.set(language, []); + byLanguage.get(language).push(task); + } + for (const language of Array.from(byLanguage.keys()).sort((left, right) => left.localeCompare(right))) { + const entries = byLanguage.get(language) || []; + for (const tier of CONTROL_SLICE_TIER_ORDER) { + const match = entries.find((task) => toText(task?.tier).toLowerCase() === tier); + if (!match) continue; + const taskId = buildTaskId(match); + if (selectedIds.has(taskId)) continue; + selected.push(match); + selectedIds.add(taskId); + if (selected.length >= cap) { + return { + maxTasks: cap, + tasks: selected.slice(), + taskIds: selected.map((task) => buildTaskId(task)) + }; + } + } + } + for (const task of ordered) { + const taskId = buildTaskId(task); + if (selectedIds.has(taskId)) continue; + selected.push(task); + selectedIds.add(taskId); + if (selected.length >= cap) break; + } + return { + maxTasks: cap, + tasks: selected, + taskIds: selected.map((task) => buildTaskId(task)) + }; +}; + +export const createBenchMethodologyPolicy = ({ + argv = {}, + tasks = [], + configPath = null, + waiverFile = null, + corpusVersion = null +} = {}) => { + const modePolicy = resolveBenchModePolicy(argv?.mode); + const selectedCorpusVersion = toText(corpusVersion) + || `repos-${sha1(JSON.stringify((Array.isArray(tasks) ? tasks : []).map((task) => buildTaskId(task))))}`; + const controlSlice = selectBenchControlSlice(tasks, { + maxTasks: Number(argv?.['control-slice-max']) || DEFAULT_CONTROL_SLICE_MAX_TASKS + }); + const repoOrder = (Array.isArray(tasks) ? tasks : []).map((task) => buildTaskId(task)); + return { + schemaVersion: BENCH_METHODOLOGY_SCHEMA_VERSION, + policyVersion: BENCH_METHODOLOGY_POLICY_VERSION, + mode: modePolicy.mode, + corpusVersion: selectedCorpusVersion, + configPath: toText(configPath) || null, + repoOrder, + cacheMode: modePolicy.cacheMode, + toolingMode: modePolicy.toolingMode, + scoreIncludesTooling: modePolicy.scoreIncludesTooling, + timeoutPolicyVersion: PROGRESS_TIMEOUT_POLICY_VERSION, + waiverFile: toText(waiverFile) || null, + passFailThresholds: { + maxFailedRepos: 0, + maxRetainedCrashBundles: 0, + maxUnwaivedIssues: 0 + }, + productionCleanGate: { + profile: `${modePolicy.mode}-production-clean`, + thresholds: { + maxUnwaivedIssues: 0, + maxDegradedRepos: 0, + maxFallbackRepos: 0, + maxFallbackTimeCostMs: 0, + maxProviderTimeoutRepos: 0, + maxCircuitBreakerRepos: 0, + maxProviderDegradationRepos: 0, + maxPreflightBlockedRepos: 0, + maxArtifactStallRepos: 0, + maxQualityBudgetLossRepos: 0 + } + }, + controlSlice: { + maxTasks: controlSlice.maxTasks, + taskIds: controlSlice.taskIds + } + }; +}; + +export const filterTasksToControlSlice = (tasks, methodology) => { + const taskIdSet = new Set( + Array.isArray(methodology?.controlSlice?.taskIds) + ? methodology.controlSlice.taskIds + : [] + ); + if (!taskIdSet.size) return Array.isArray(tasks) ? tasks.slice() : []; + return (Array.isArray(tasks) ? tasks : []).filter((task) => taskIdSet.has(buildTaskId(task))); +}; + +export const buildBenchMetricTags = (methodology) => { + if (!methodology || typeof methodology !== 'object') return null; + return { + mode: methodology.mode || null, + cacheMode: methodology.cacheMode || null, + toolingMode: methodology.toolingMode || null, + corpusVersion: methodology.corpusVersion || null, + policyVersion: methodology.policyVersion || null + }; +}; + +export const buildBenchMethodologyTaskId = buildTaskId; diff --git a/tools/bench/language/process.js b/tools/bench/language/process.js index 46cf0a421..2d028fbb0 100644 --- a/tools/bench/language/process.js +++ b/tools/bench/language/process.js @@ -1,10 +1,19 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { spawnSubprocess } from '../../../src/shared/subprocess.js'; +import { spawnSubprocess } from '../../../src/shared/subprocess/runner.js'; +import { composeAbortSignals } from '../../../src/shared/abort.js'; import { killProcessTree as killPidTree } from '../../../src/shared/kill-tree.js'; +import { createTimeoutError, runWithTimeout } from '../../../src/shared/promise-timeout.js'; import { createProgressLineDecoder } from '../../../src/shared/cli/progress-stream.js'; import { parseProgressEventLine } from '../../../src/shared/cli/progress-events.js'; +import { + buildProgressTimeoutBudget, + evaluateProgressTimeout, + normalizeProgressTimeoutOwnerPolicy, + PROGRESS_TIMEOUT_CLASSES, + PROGRESS_TIMEOUT_OUTCOMES +} from '../../../src/shared/indexing/progress-timeout-policy.js'; import { exitLikeCommandResult } from '../../shared/cli-utils.js'; import { BENCH_DIAGNOSTIC_STREAM_SCHEMA_VERSION, @@ -12,7 +21,11 @@ import { formatBenchProgressConfidence, buildBenchDiagnosticEventId, buildBenchDiagnosticSignature, - normalizeBenchDiagnosticText + createBenchDiagnosticClassifier, + parseBenchReuseObservation, + normalizeBenchDiagnosticSeverity, + normalizeBenchDiagnosticText, + resolveBenchDiagnosticSeverity } from './logging.js'; const SCHEDULER_EVENT_WINDOW = 40; @@ -21,12 +34,41 @@ const DIAGNOSTIC_STREAM_SUFFIX = '.diagnostics.jsonl'; const PROGRESS_CONFIDENCE_STREAM_SUFFIX = '.progress-confidence.jsonl'; const DIAGNOSTIC_REPEAT_COUNT = 5; const DIAGNOSTIC_REPEAT_INTERVAL_MS = 30 * 1000; +const BENCH_INTERACTIVE_DIAGNOSTIC_SILENT_TYPES = new Set([ + 'provider_preflight_start', + 'provider_preflight_finish', + 'workspace_partition_decision', + 'fallback_used', + 'runtime_timeout_budget_extended', + 'runtime_timeout' +]); +const BENCH_INTERACTIVE_DIAGNOSTIC_REPEAT_COUNT_BY_TYPE = Object.freeze({ + provider_preflight_blocked: 2, + provider_circuit_breaker: 2, + provider_degraded_mode_entered: 2, + provider_request_timeout: 4, + provider_request_failed: 4, + queue_delay_hotspot: 6, + artifact_tail_stall: 3 +}); +const BENCH_INTERACTIVE_DIAGNOSTIC_REPEAT_INTERVAL_BY_TYPE = Object.freeze({ + provider_request_timeout: 45 * 1000, + provider_request_failed: 45 * 1000, + queue_delay_hotspot: 90 * 1000, + artifact_tail_stall: 90 * 1000 +}); +const BENCH_REPO_DIAGNOSTIC_TOP_SIGNAL_LIMIT = 6; const PROGRESS_CONFIDENCE_EMIT_INTERVAL_MS = 15 * 1000; const PROGRESS_CONFIDENCE_EMIT_DELTA = 0.07; const QUEUE_DELAY_HOTSPOT_MS = 250; const QUEUE_DEPTH_HOTSPOT = 3; const HEARTBEAT_STALL_THRESHOLD_MS = 30 * 1000; const MAX_PROGRESS_SAMPLES = 240; +const TELEMETRY_FLUSH_TIMEOUT_MS = 5000; +const DEFAULT_IDLE_TIMEOUT_WATCHDOG_POLL_MS = 5000; +const PROCESS_ACTIVITY_PROBE_TIMEOUT_MS = 1500; +const PROCESS_ACTIVITY_CPU_DELTA_MS = 100; +const PROCESS_ACTIVITY_RSS_DELTA_BYTES = 4 * 1024 * 1024; const PARSER_CRASH_PATTERNS = Object.freeze([ /\bparser\b[\s\S]{0,80}\b(?:crash|crashed|fatal|abort|aborted)\b/i, @@ -114,12 +156,251 @@ const percentile = (values, ratio) => { const clamp01 = (value) => Math.max(0, Math.min(1, Number(value) || 0)); +const parseCpuDurationMs = (value) => { + const text = String(value || '').trim(); + if (!text) return null; + const daySplit = text.split('-'); + let days = 0; + let timeText = text; + if (daySplit.length === 2) { + days = Number.parseInt(daySplit[0], 10); + timeText = daySplit[1] || ''; + if (!Number.isFinite(days) || days < 0) return null; + } + const parts = timeText.split(':').map((entry) => Number.parseInt(entry, 10)); + if (!parts.every((entry) => Number.isFinite(entry) && entry >= 0)) return null; + if (parts.length === 2) { + const [minutes, seconds] = parts; + return ((days * 24 * 60 * 60) + (minutes * 60) + seconds) * 1000; + } + if (parts.length === 3) { + const [hours, minutes, seconds] = parts; + return ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; + } + return null; +}; + +const parseWindowsTasklistActivity = (stdout, pid) => { + const output = String(stdout || '').trim(); + if (!output || /INFO:\s+No tasks are running/i.test(output)) return { alive: false, pid }; + const line = output.split(/\r?\n/)[0] || ''; + const parts = line.split('","').map((part) => part.replace(/^"|"$/g, '')); + const parsedPid = Number(parts[1] || ''); + if (!Number.isFinite(parsedPid) || parsedPid !== pid) return { alive: false, pid }; + const rssKbText = String(parts[4] || '').replace(/[^0-9]/g, ''); + return { + alive: true, + pid, + cpuMs: parseCpuDurationMs(parts[7]), + rssBytes: Number.isFinite(Number(rssKbText)) ? Number(rssKbText) * 1024 : null + }; +}; + +const parseWindowsProcessProbeActivity = (stdout, pid) => { + const output = String(stdout || '').trim(); + if (!output) return { alive: false, pid }; + const line = output.split(/\r?\n/).map((entry) => entry.trim()).find(Boolean) || ''; + if (!line) return { alive: false, pid }; + const [pidText = '', cpuSecondsText = '', rssBytesText = ''] = line.split(',').map((entry) => entry.trim()); + const parsedPid = Number(pidText); + if (!Number.isFinite(parsedPid) || parsedPid !== pid) return { alive: false, pid }; + const cpuSeconds = Number(cpuSecondsText); + const rssBytes = Number(rssBytesText); + return { + alive: true, + pid, + cpuMs: Number.isFinite(cpuSeconds) && cpuSeconds >= 0 ? cpuSeconds * 1000 : null, + rssBytes: Number.isFinite(rssBytes) && rssBytes >= 0 ? rssBytes : null + }; +}; + +const parsePosixPsActivity = (stdout, pid) => { + const output = String(stdout || '').trim(); + if (!output) return { alive: false, pid }; + const line = output.split(/\r?\n/).map((entry) => entry.trim()).find(Boolean) || ''; + if (!line) return { alive: false, pid }; + const match = line.match(/^(?